sports_anchor
sports_anchor is the only signal in this library that gates a bot on a forecast we make ourselves. Every other entry here reads a number somebody else published — a temperature, a price, a hurricane probability. This one runs our own win-probability model over tonight's baseball games, compares its answer to what Kalshi is charging for the same team, and lets a bot trade only when our number beats the price by a margin you set. It is also the only signal that keeps a locked, never-overwritten record of every prediction it has ever made. That record is the reason this is the one page in the dictionary that can tell you from our own books whether the signal works — and the answer, over 880 finished games, is that its probabilities cannot be told apart from a constant.
- Bots using it
- 0 live · 0 out-of-sample · 1 archived (registry says 0 — it cannot see archived bots)
- Data source
- MLB Stats API + Kalshi API + our own finished games — all free, all keyless
- Poller · cron
- sports_edge_refresh · */30 * * * * (registry says "db", and our own backlog said "no poller")
- Table it reads
- mlb_model_edges · 118 rows now, pruned at 2 days
- Forward record
- 963 locked predictions, 880 scored 3 Jun – 13 Aug 2026, never pruned
- Backtest-replayable
- No — and the reason the code gives is refuted here (catch #8)
Three things about this signal are unusual, and they pull in opposite directions.
The first is that it is built with real care. The model does not fabricate a win percentage: it is fitted entirely on games we collected ourselves, the home-field advantage is derived from the observed home-win rate rather than assumed, a pitcher's earned-run average is shrunk toward the league mean in proportion to how few innings he has thrown, and the reported accuracy comes from a chronological train/test split rather than the data it was fitted on. Its docstring volunteers its own weaknesses. After a run of dictionary pages about feeds that died and thresholds that could never open, that is a genuine change of register.
The second is that it keeps receipts. Every prediction is written once, before the game, and never rewritten; the market's price is captured at the same moment; the real result is stitched in afterwards. Nothing in our system reads that table. It exists purely so that somebody could later check the model's homework. That is rare enough to be worth saying plainly: this is the best provenance surface in the whole signal library.
The third is that the receipts say it isn't working yet — and, more interestingly, they say why. The model has two features. One of them does nearly all the work. And because of the exact minute we choose to lock a prediction in, that feature is missing from 88.5% of the predictions we have ever made. What is left is a number that sits between 49.9% and 54.4%, says "home" 851 times out of 852, and turns the whole "model edge" gate into something much duller than it sounds.
Where the number actually comes from
Four ingredients: two public feeds, our own match results, and a model that combines them. Nothing here costs money and nothing needs a key.
-
MLB's own Stats API —
statsapi.mlb.com/api/v1/schedulewithhydrate=probablePitcher,team, for the next three days of games and who is expected to start them. Verified live from production today: HTTP 200 in 0.115 s, no key, no rate-limit headers, nine games listed for today. The same API answers/api/v1/people?personIds=…&hydrate=stats(group=pitching,type=season)for each starter's season ERA and innings pitched, cached locally inmlb_pitcher_era(335 pitchers, all with both figures). This is the same free feed that lineup_anchor and mlb_conditions_anchor run on. -
Kalshi's public API, fetched by
kalshi_polleron* * * * *intokalshi_markets. Only one series matters here:KXMLBGAME, the per-team "who wins this game" markets — 1,944 of them on record, every one filed under categorysports. (Worth noting after tropical_anchor, whose only bot asked for a category that does not exist. This one asks for a category that does.) -
Our own finished games —
predictive/sports_results_poller.pyon0 11 * * *fillssports_games, which holds 1,789 completed MLB games back to 26 March 2026, with scores, winners and the pitchers who actually started. This is the training set. -
The model,
predictive/sports_model.py. An Elo rating replayed over every one of those games with a deliberately low K of 6; a home-field advantage derived from the observed home-win rate rather than assumed, which currently works out at 18.9 Elo points; and each starter's ERA regressed toward a 4.10 league mean with 45 innings of prior, so that a two-inning 0.00 is not mistaken for talent. Those two numbers — the Elo gap and the ERA advantage — go into a two-feature logistic regression, written in plain Python, with L2 shrinkage on the slopes to curb overconfidence.
The assembly happens in predictive/sports_edge_refresh.py on */30 * * * *. Every
half hour it retrains the model, pulls the next three days of games, fetches any ERA it is missing,
predicts each game, matches each prediction to the live Kalshi market for that team, and writes
edge = model win-% − market price into mlb_model_edges, keyed
by market ticker. The gate itself, quants/sports_anchor.py, is then four lines: look up this
ticker, check the row is fresh, return edge >= min_edge. If there is no row, it returns
False. The trading engine never trains anything or calls an API in its hot path — which is good design,
and worth crediting.
The registry gets three things wrong, and so did our own backlog
This is the worst-resolved signal in the dictionary so far, so it is worth listing.
-
The poller resolves as
db. The registry hunts a docstring for the patternpredictive.<word>and this docstring contains the phrase "predictive.db", so it reports a poller calleddbwith no cron. The real poller issports_edge_refreshon*/30 * * * *. This is the fourth signal hit by that exact artefact, after energy_anchor, tropical_anchor and launch_anchor. -
It reports a table called
min_edge. There is no such table — checked inpredictive.db,quants.dbandanalytics.db.min_edgeis the configuration key you set. The registry harvests back-ticked words out of docstrings and calls them tables, the same way it inventedmin_samplesandmin_win_rateon the resolution_anchor page. -
live_bots: 0is an undercount. That column counts strategies whose status isliveoroos, so it cannot see archived ones — and there is an archived bot here. Every previous page in this dictionary has had to correct this figure for over-reporting: "1" meaning one out-of-sample bot and none live, six fires running. This is the first time it has hidden a bot instead of inflating one. -
Our own backlog filed this signal under "orphaned, no poller running". It has a poller,
every thirty minutes, and has had since 3 June. The dictionary index has been corrected to move it.
That index already warned readers to treat its no-poller list with suspicion, because
funding_anchor was rescued from
it the same way — also, as it happens, a
*/30job.
mlb_predictions — is written but never read by anything
in the system; it exists only so the model can be marked. Second: every prediction we have
locked, by the home team's model win-percentage, split by whether the starting pitcher's ERA was
available. Each panel is scaled to its own peak, so the left one understates how concentrated it is.
Third: the share of locked predictions that had an ERA, by the hour they were locked — the
dashed outline on the right is a live probe of what is available now, and is a different measurement,
not a lock. Bottom: Brier skill against a constant, with the 95% interval. All figures are from
read-only queries against production on 13 August 2026; the sources are named in the footer.
What it does for your bot
It is a per-market filter, not a master switch. Unlike finance_anchor or sentiment_anchor, which switch a whole bot on or off based on one global reading, this one is asked about each candidate market separately: for this exact team in this exact game, does our model like the price? Every market that is not an MLB game-winner market is dropped, because there is no row for it.
You set one number, min_edge, in percentage points. The builder renders it as
"only on MLB winner markets where our model beats the price by ≥ 5 pts". The code's default when
you leave it unset is 5; the docstring's worked example is 8. If the model says a team wins 58% of the
time and Kalshi is asking 50¢, the edge is 8 points.
It fails closed, and properly: no row, no fresh row, or a null edge all return False. That is the right default and this signal gets it right.
The record: 880 games, marked
This is the part no other page in this dictionary can do. Because mlb_predictions locks each
forecast before the game and is never rewritten, we can simply count. Below: every scored prediction from
3 June to 13 August 2026, against two baselines — the market's own pre-game price, and the dumbest
possible rule, "always pick the home team".
| 818 games, all three measured on the same set | our model | Kalshi's price | always pick home |
|---|---|---|---|
| Picked the winner | 53.91% | 52.57% | 52.61% |
| Brier score (lower is better) | 0.24938 | 0.24869 | 0.24883 |
| Log loss (lower is better) | 0.69178 | 0.69039 | 0.69080 |
| Brier skill vs a constant | −0.22% | +0.06% | 0 by definition |
Two things to read off that table. The first is that every number in it is within a rounding error of a coin flip. Log loss for a fair coin is 0.6931; all three columns sit within 0.003 of it. The second is that the model's probability forecasts score slightly worse than a constant — but the paired 95% interval on that difference runs from −1.45% to +1.00%, so the honest statement is not "worse", it is "after 818 games we still cannot distinguish it from a constant". Clustering the games by day, which they should be, widens the interval further.
On raw accuracy across all 880 scored games the model got 53.41% against always-pick-home's 52.61%. That gap is seven games, and it is not what it looks like: the model agrees with always-pick-home on 843 of 880 games, so the comparison only has 37 games of information in it. On those 37 the model went 22–15. The exact two-sided test for that is p = 0.32. There is no result there.
The catch
Ten, worst first.
1. Over 818 marked games, the model cannot be distinguished from a constant
Brier 0.24938 against 0.24883 for a rule that says "home, 53.4%" every single time and never looks at anything. Log loss 0.69178 against 0.69080. Skill score −0.22%, interval −1.45% to +1.00%. Against Kalshi's own pre-game price the model is likewise indistinguishable, and nominally behind.
This is not a backtest and it is not a simulation. It is our own locked, timestamped, never-rewritten record of what the model said before each game and what then happened. Of every signal written up in this dictionary, this is the only one whose central claim has been marked against reality — and the mark is a null. The rest of this page is about why, because the why is fixable.
2. The feature that does the work is missing from 88.5% of predictions — because of when we lock
The model has two inputs. The fitted slope on ERA advantage is 0.504; on the Elo gap it is 0.136. The starting pitcher carries roughly 3.7× the weight of team strength, which matches the module's own claim that ERA advantage is the dominant MLB signal. Yet only 111 of 963 locked predictions actually had it.
The reason is not that MLB withholds the data. 871 of 963 predictions — 90.4% — are locked at midnight UTC, the first refresh cycle after a new game-date enters the three-day look-ahead window. That is the earliest possible instant, and the instant at which the fewest probable starters have been announced. The evidence is the same code run at different hours: predictions locked at 00:00 had an ERA 9.4% of the time, those locked at 03:00 15.8%, and those locked at 15:00 42.6%. Probing MLB's API directly this afternoon, 11 of the 15 games three days out already have both starters listed — 73%, against the 9.4% we captured.
And it is never revisited. The lock is an INSERT OR IGNORE on the game id, so when the
pitcher is announced the next morning, the prediction is not updated. The design choice that makes the
record honest — write once, never overwrite — is the same one that guarantees it records the model at
its weakest.
3. Without the ERA, the model is a constant — and the gate becomes a price filter
When a starter's ERA is missing the code substitutes an ERA advantage of exactly zero, which leaves the small Elo term and the intercept. The result is not a weaker forecast; it is barely a forecast at all. Across the 852 predictions with no ERA, the home team's win probability ranged from 49.9% to 54.4% — a span of four and a half points — with 774 of them landing in just two buckets, 51% and 52%. It picked the home team 851 times out of 852.
That matters for the gate, not just the model. The edge is model win-% − market price. If
the model is pinned near 52 no matter who is playing, then edge ≥ 5 is arithmetically
almost identical to "the market price is below about 47¢", and edge ≥ 8 to
"below about 44¢". Nine times in ten, the model-edge gate is a price filter wearing a model's
clothes. The bot that used it, "MLB Model Edge", was written explicitly to replace an older bot called
"Sports Underdog Bias" that bought underdogs at 15–35¢. With the gate degenerate, its own 30–70¢ band
collapses to roughly 30–47¢: it becomes a version of the thing it replaced.
4. The table carries "edges" on games that have already been played
Kalshi files these markets with a close time roughly three days and four hours after first
pitch — a game at 13:10 UTC on 13 August is stored as closing at 17:10 UTC on 16 August — and
the status stays active the whole time. The refresher only skips markets that are not
active, so it keeps writing rows for games that finished yesterday, against prices that have already
resolved to 1¢ or 100¢. The two-day prune is keyed on when the row was last refreshed, not on
the game date, so refreshing it is what keeps it alive.
Right now the five largest edges in the table are all finished games. The biggest: our model gives
Baltimore 60.3%, the market says 1¢, edge +59.3 —
against a game Baltimore lost to Minnesota 7–5 the previous afternoon. Of the 28 rows for games already
played, 10 clear a min_edge of 8. The strongest signal this gate can
currently see is the model disagreeing with a result that is already in the books.
A price band saves you from the worst of it — a resolved market at 1¢ is outside any sane range — but not all of it, because settled games sit at every price in our snapshot, including a 33¢ row for a game that ended the night before.
5. The only bot that ever used it could not have produced a candidate
"MLB Model Edge" (id 30, a house bot) ran 1,646 evaluations between 11 and 16 June and produced 0 candidates, 0 fills and 0 errors before being archived. The reason is that two of its own filters are mutually exclusive. It asked for markets within 12 hours of close. But the refresher only builds edges for games in the next three days, and Kalshi's close time is three days after the game — so the nearest close time on any market that can have an edge row is 45.2 hours away. Nothing can be both.
Running the full funnel against live data today: 118 edge rows → 37 clear min_edge 5 → 83
sit in a 30–70¢ band → 47 clear a 5,000-contract volume floor → 0 are within 12 hours
of close. The bot was not unlucky. It was arithmetically shut.
6. The three-hour freshness check is not three hours
The gate requires updated_at >= datetime('now', '-3 hours'). But
updated_at is written as an ISO-8601 string —
2026-08-13T20:00:04.859324+00:00 — while SQLite's datetime() returns
2026-08-13 17:27:21, with a space. The comparison is textual, and 'T' sorts
after ' ', so any row bearing today's UTC date passes. Verified on production: a
one-minute freshness test still returned all 90 of today's rows, including ones 27 minutes old.
The real window is "since midnight UTC", breathing from three hours just after midnight to nearly twenty-four just before it. It does fail closed correctly once the date rolls over. This is the same defect already documented on finance_anchor and tropical_anchor — three signals now, same one-character cause.
7. It is not side-aware — and we knew that on the day it shipped
The gate returns a single edge per ticker with no notion of which side you are buying. A bot buying NO would be admitted precisely when the model likes YES — that is, it would trade against the signal it thinks it is following.
To the team's credit this was caught before anything shipped: deploy_c5_greenfield.py, dated
11 June, records that a ninth bot design, mlb-model-edge-no, was held back
for exactly this reason, with the note "needs a side-aware sports anchor — Dave's call". The catch is
that sixty-three days later the anchor is unchanged, and nothing in
validate_config stops somebody building that bot by hand tomorrow.
8. Not backtest-replayable — and the reason the code gives is refuted here
sports_anchor sits in _UNSUPPORTED_ENTRY in quants/backtest.py
under a comment dated 4 June: these anchors "each need a forward-captured series we don't have
retroactively". A backtest containing this gate therefore runs without it and still
returns a curve.
For this signal that justification does not hold. mlb_predictions is a
forward-captured series — 963 pre-game locked model probabilities with the market's pre-game price
alongside, never pruned, purpose-built and already three months deep. It was written by the same file
the excuse is about. The honest caveat is that it is keyed by game rather than by market ticker and
stores only the home side's price, so it replays the game rather than the exact contract; every
gate simulation on this page was run that way. That is a missing join, not a missing series. Same
shape as sentiment_anchor,
where eight years of history sits in a table the replay loop never reads.
9. The advertised "~61% out-of-sample" is real, reproducible — and measured on games this gate rarely sees
The docstring claims the model is "~61% out-of-sample". That is not a made-up number. Re-running the holdout read-only on production today reproduces it almost exactly: a chronological 70/30 split, fitted on the first 1,166 games and tested on the held-out later 501, gives 61.3% against 53.1% for always-pick-home on the same games. No peeking, no cherry-picking.
The problem is which games are in it. The feature builder only keeps a game if both starters' ERAs are known — which is true of almost all historical games, because we know who actually pitched. So 61.3% is measured exclusively on ERA-known games, while roughly nine in ten of the predictions this signal actually makes have no ERA at all. The number is honest about its method and silent about its population. On the 99 scored forward predictions that did have an ERA, accuracy was 55.6% — better than the 53.1% without, consistent in direction, and far short of 61% on a sample too small to lean on.
The docstring, to its credit, says the current-season ERA applied to past games is "a mild lookahead" and that the holdout should be treated as "directional/optimistic". It told us to distrust the 61%. The forward record proves it right.
10. Replayed over our own record, the gate does not clear the spread
Using the 818 games where we captured a pre-game price, buying the home side whenever the model's edge
cleared a threshold: at min_edge 5, 126 bets won 54.8% at an average price of 45.2¢; at 8,
72 bets won 50.0% at 44.0¢; at 10, 53 bets won 45.3% at 43.5¢; at 15, 18 bets won 55.6% at 42.9¢. The
break-even for a binary bought at price p is p itself, so those are nominal
advantages of +9.6, +6.0, +1.8 and +12.7 points — moving up and down with no relationship to how
selective the threshold is, which is the signature of noise, not edge.
Two reasons to treat even those as generous. The stored price is the midpoint, while
the live gate compares against the ask, which is worse. And the away side has to be
approximated as 100 minus the home price, which ignores the spread entirely; on that approximation the
away side is worse at every threshold, reaching 36.8% against a 36.7¢ break-even at
min_edge 10. This mirrors the arithmetic on the
resolution_anchor page: a win
rate is not an edge until you subtract what you paid.
The case for the defence
Six things this signal gets right
-
The forward record is the best piece of engineering in this dictionary. One row per
game, written once before it starts, protected by
INSERT OR IGNOREso it cannot be quietly improved after the fact; the market's price captured at the same moment; the real result stitched in later; never pruned. Its own code comment calls it "the honest provenance surface — what the model actually predicted before the game, never overwritten". Every damning number on this page exists because somebody built that table. Most signals here cannot be marked at all. - The model does not invent anything. Home-field advantage is derived from the observed home-win rate, not assumed. ERA is shrunk toward the league mean by innings pitched, so a two-inning 0.00 is not treated as ability. The logistic has L2 shrinkage on its slopes specifically to curb overconfidence. The reported accuracy comes from a chronological split, not the training data.
- The docstring is candid. It calls the model "a documented estimate", says plainly "it is not a guarantee", flags its own lookahead, and predicts that the real track record will have to accrue forward. It is the only module docstring in this dictionary that pre-emptively warns you about the number printed next to it.
- A defect was caught and a bot was refused. The side-awareness problem in catch #7 was found before deployment and a finished bot design was held rather than shipped broken. That is the right instinct, and it is worth recording alongside the criticism.
-
The pipeline is healthy. 3,160 clean runs and not one soft failure. It has taken 278
database is lockedcrashes from the shared-database contention that has appeared on six other pages in this dictionary, but because it re-runs every thirty minutes and rewrites every row, a lost cycle costs nothing. The engine also never trains a model or calls an API while deciding a trade — the heavy work is precomputed, which is exactly right. - The published record is a pessimistic view of what the gate sees. The 53.41% is the accuracy of a prediction locked three days early. The gate itself reads a table rebuilt every thirty minutes, so a market close to first pitch is judged on an ERA-informed number. The 111 ERA-known predictions are the fairer sample of what the gate actually acts on, and they are small but pointed the right way. Fixing catch #2 would not just improve the record — it would make the record measure the right thing.
The record: one bot, no trades
Exactly one bot has ever used sports_anchor, and it never traded. "MLB Model Edge"
(id 30) was a house bot created on 11 June 2026 as part of a fleet rebuild, configured for Kalshi and
Polymarket, sports category, YES side, 30–70¢, a 5,000-contract volume floor, within 12 hours of close,
{"min_edge": 5}, $5 flat, six fills a day maximum. It ran 1,646 evaluations, produced
0 candidates, filled nothing, errored never, and was archived. Its stated falsifier — "after 50
trades, settle rate not above average entry price by ~3 points" — never got a single trade to test.
There is no profit and loss on this page because there is none to report.
It is worth noting that even had the timing filter allowed it, the bot listed Polymarket as a venue
alongside Kalshi, and mlb_model_edges is keyed by Kalshi ticker. A Polymarket
market has no row, so the gate would have failed closed on every one. It could only ever have traded
Kalshi — the same mismatch that left
resolution_anchor's live bot
trading a book its data did not cover.
Across the whole site: 1 of 76 strategy configurations mentions this signal and 1 of 88 saved versions, both the archived bot. It is offered in the builder today.
If you are thinking of using it
The signal is not fake and the plumbing is not broken — which makes it different from most of what this
dictionary has had to report. What is broken is the timing of one write. Until predictions are locked
later, or re-locked when the starter is announced, the model you are gating on is a constant that says
"home, 52%", and the gate is a roundabout way of saying "buy anything under about 47¢". If you build on
it anyway: give it a threshold well above 8 so the degenerate mode cannot reach it, keep a price band so
settled markets cannot get in, and do not set hours_to_close_max below about 48 or you will
reproduce catch #5 exactly.
Everything on this page traces to one of four places: the output of
python3 -m quants.signal_registry --json; the source of
quants/sports_anchor.py, predictive/sports_edge_refresh.py and
predictive/sports_model.py on the production server; a read-only query against
predictive.db and quants.db whose window is stated where the number appears; or
a live request to statsapi.mlb.com made from production on 2026-08-13 (HTTP 200,
keyless). The forward record covers 3 June – 13 August 2026: 963 locked predictions, 880 scored
against real finals. Game results are MLB's own. The holdout accuracy was re-derived read-only rather
than quoted from the docstring. The registry's min_edge table does not exist, its poller
db is a regex artefact, and its live_bots: 0 omits one archived bot — all three
are corrected above. All TinyCorp bots trade simulated money.
New here? What is a signal? ·
Glossary ·
How a bot works