Trade Bot Dictionary · Sports

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.

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 DATA PATH — THREE FREE FEEDS, ONE MODEL WE FIT OURSELVES MLB Stats API schedule + ERA free · keyless Kalshi API kalshi_poller * * * * * sports_games 1,789 finals daily 11:00 sports_model.py Elo K=6 · HFA 18.9 + shrunk starter ERA 2-feature logistic ERA slope 3.7× Elo sports_edge_refresh */30 * * * * retrain · predict match to ticker 3,160 clean runs mlb_model_edges 118 rows · by ticker 28 already played mlb_predictions 963 locked pre-game NEVER pruned the gate edge ≥ min_edge no row → False this market clears → tradeable else → dropped nothing in the system reads this table — it is only how the model can be marked THE MODEL HAS TWO MODES. WE ALMOST ALWAYS GET THE DULL ONE. Every locked prediction, by the home team's model win-%. Each panel is scaled to its own peak — the left one is 7.7× taller in reality. ERA MISSING — 852 of 963 (88.5%) 15% 52% 95% 774 of 852 land in just two buckets: 51% and 52% whole range 49.9 – 54.4 picks HOME 851 times of 852 a constant, not a forecast ERA KNOWN — 111 of 963 (11.5%) 15% 52% 95% range 19.8 – 90.6 picks AWAY 40 of 111 the advertised model Fitted slopes: ERA advantage 0.504 vs Elo gap 0.136 — the feature carrying 3.7× the weight is the one we usually do not have. IT IS NOT MLB'S FAULT. IT IS THE MINUTE WE CHOOSE TO LOCK. Share of locked predictions that had both starters' ERA, by the hour of day the lock happened. 0% 40% 80% 00:00 UTC n = 871 9.4% 03:00 UTC n = 38 15.8% 15:00 UTC n = 54 42.6% live probe n = 15 73.3% a different measurement: how many games three days out have both ERAs available RIGHT NOW, at 20:30 UTC — not a lock. 871 of 963 predictions — 90.4% — are locked at midnight UTC, the first refresh after a new game-date enters the three-day window, and the moment MLB has published the fewest probable starters. AGAINST A CONSTANT: BRIER SKILL OVER 818 SCORED GAMES 0 — a constant 53.4% "home" every game the model −0.22% · 95% interval −1.45% to +1.00% Kalshi's price +0.06% — also indistinguishable worse than a constant better than a constant The interval swallows zero. The honest reading is not "the model is worse" — it is "after 818 games we still cannot tell it apart from a constant".
Top: the data path. Three free feeds, a model we fit ourselves, and a table the gate reads by market ticker. The second table — 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 setour modelKalshi's pricealways pick home
Picked the winner53.91%52.57%52.61%
Brier score (lower is better)0.249380.248690.24883
Log loss (lower is better)0.691780.690390.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 , 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 IGNORE so 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 locked crashes 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