lineup_anchor
lineup_anchor reads the market's title, works out which baseball teams it names, looks up who is scheduled to pitch for them today, and lets your bot trade only if those starters' season ERA clears a bar you set. "Only touch this game if both starters are under 3.50." It runs on MLB's own free Stats API, it has the cleanest data pipeline of anything in this dictionary, and its 30-team lookup table — the exact thing that is broken in injury_anchor — is correct in all thirty entries. We checked every one against MLB's own list while writing this.
Then there is the thing it cannot do, which is tell the two sides of a baseball game apart. The gate resolves the teams in the title, finds today's game, and returns one verdict for that game. It never compares the pitcher who passed with the team you actually asked about. So "Will the Phillies win?" and "Will the Cardinals win?" get the identical answer — and if only one of the two starters clears your bar, exactly one of those two answers is backwards.
That is not hypothetical. The builder ships a recipe called "Ace mismatch" — "back games where an ace (low ERA) is starting", side YES, ERA ≤ 3.00. Run it against tonight's fifteen games and it admits six markets. Three of the six are admitted on the opposing team's ace. It would buy the Athletics because Tampa Bay's Drew Rasmussen is pitching against them. Half of the trades back the team the ace is facing.
No bot has ever used this signal — 0 of 76 strategies, 0 of 88 saved versions — which is the only reason none of that has cost anything. We have polled it every six hours since 29 May regardless: 302 runs, not one missed.
- Bots using it
- 0 none live, none out-of-sample, none archived, none ever
- Data source
- MLB Stats API — statsapi.mlb.com, free, keyless
- Refresh
- Every 6 h 27 */6 * * *
- Rows stored
- 1,976 — 75 game-days, since 29 May
- Backtest-replayable
- No — see catch #7
- Anchor module
- quants/lineup_anchor.py
What it actually does
Most signals in this dictionary are master switches: one number for the whole world — is Bitcoin dominance above 55%, is the VIX below 18 — and the answer is the same for every market on the board. lineup_anchor is one of the three that are per-market, driven by the words in the market's own title. That makes it far more interesting than a global regime filter, and it is also where the trouble lives.
Four steps. First it scans the title for team names, matching abbreviation, nickname or city — PHI, Phillies and Philadelphia all resolve to team 143. Second, it queries mlb_probable_pitchers for today's games involving any team it found. Third, for each starting pitcher in those games it asks: does this pitcher have enough starts, enough innings, and an ERA inside your range? Fourth, it applies your side rule — "both" needs every starter in the game to clear the bar, "either" needs one — and if any matched game succeeds, the gate opens.
The configuration is five fields, of which the builder exposes three:
- side — "both" or "either". Default "both".
- max_era — an upper bound. The "this pitcher is good" direction.
- min_era — a lower bound. The "this pitcher is struggling" direction, for OVER theses. At least one of these two is required; a config with neither is rejected outright, so you cannot build a lineup_anchor that always passes. That is more than several signals here can say.
- min_starts — default 3. Not exposed in the builder, so a bot built by clicking always gets 3.
- min_innings — default 20.0. Also not exposed.
Those last two defaults are the quietly sensible part of this module, and they earn their keep. Tonight they correctly threw out Bryan King — a 3.83 ERA that looks like a fine starter until you notice it was earned over 49.1 relief innings across zero starts — and Daniel Lynch IV, a tidy-looking 2.45 with one start to his name. Both would have sailed through a naive ERA filter.
Where the data actually comes from
MLB's own Stats API — statsapi.mlb.com. No key, no registration, no quota, no documented rate limit; the source of truth for the sport, given away for free. This is one of the few places in the library where the registry's source field ("MLB feeds") is exactly right. Two endpoints, in sequence:
- /api/v1/schedule?sportId=1&date=YYYY-MM-DD&hydrate=probablePitcher — the day's games and who is expected to start.
- /api/v1/people/<id>?hydrate=stats(group=pitching,type=season,season=YYYY) — that pitcher's season line, one call each.
The fetch is predictive/lineup_poller.py on cron 27 */6 * * * — four times a day at 00:27, 06:27, 12:27 and 18:27 UTC. Each run polls two dates, today and tomorrow, so late-night markets that resolve after midnight have something to read. It writes into mlb_probable_pitchers in predictive.db, keyed on (game_pk, side), using INSERT OR REPLACE.
The table holds 1,976 rows across 75 game-days, from 29 May to tomorrow. Alongside ERA it stores WHIP, innings, games started, strikeouts, walks, the venue and the scheduled first pitch. The gate reads four of those columns and ignores the rest.
And now the part that deserves saying loudly, because on most of these pages this paragraph is bad news. This pipeline works. 302 completed runs since 29 May, 604 date-polls, and it has never once missed its slot. Zero database is locked failures — the affliction that killed 61 of injury_anchor's runs and starved funding_anchor for two days has never touched this poller. The vendor has failed us nine times in 604 polls, and all nine were this morning.
That morning matters, though, because of what the code does with a failure — see catch #4.
What it does for your bot
In principle, it narrows your bot to games where the pitching matches a thesis you can state in one sentence. Both starters are good, so take the under. One starter is an ace, so back him. Both are struggling, so take the over. Those are real, legible baseball theses and this is a reasonable-looking switch for expressing them.
In practice it does something narrower than it appears, and the difference is worth being precise about: lineup_anchor is a filter on games, wearing the costume of a filter on teams. It answers "is there a game today, involving a team named in this title, whose starters look like this?" — and that is genuinely all it answers. Two markets on opposite sides of the same game are indistinguishable to it.
For an under or an over that is fine, because a total is a property of the game rather than of a side. For a moneyline — which is what the shipped recipe buys — it is the wrong shape, and the measurement in band 3 is what "wrong shape" costs: six markets admitted, three of them backwards.
The catch
Eight, worst first. The first two are the ones that would cost money if a bot were switched on; the rest are things you would want to know before trusting an answer.
1. 🔴 It cannot tell the two sides of a game apart
The gate takes the set of teams in the title, pulls today's games involving any of them, tests each starter against your ERA bounds, and applies side. What it never does is compare the pitcher who passed with the team you asked about. The team columns come back from the query and are bound to throwaway variables. There is no step anywhere in the function that relates the qualifying starter to the title.
So lineup_anchor_satisfied() returns the same boolean for "Will the Phillies win?" and "Will the Cardinals win?" — always, by construction. With side: "either" a game qualifies if one starter clears the bar, and then both of that game's markets are admitted, only one of which is the one you meant.
Measured against the builder's own shipped recipe — "Ace mismatch", either, max_era 3.00, buy YES, described as "back games where an ace (low ERA) is starting" — on tonight's fifteen games: six markets admitted, three on the team's own ace and three on the opponent's. St. Louis admitted on Zack Wheeler's 2.69, Atlanta on Zac Thornton's 2.52, the Athletics on Drew Rasmussen's 2.75. In each of those three the recipe buys the team the ace is pitching against.
The even split is not bad luck; it is arithmetic. When exactly one starter in a game qualifies, that game contributes one correct market and one inverted one. An "either" moneyline bot on this gate has a structural coin-flip built into it before the baseball starts.
2. 🔴 A weather market can pass a starting-pitcher gate
The team scan matches city names, and cities are not owned by baseball. Of the 8,678 Kalshi markets open right now, 196 titles match at least one team alias — and 36 of those are not baseball at all. Eighteen are Kalshi's Miami high-temperature markets, which say "Miami" and therefore resolve to the Marlins; eighteen are the Philadelphia ones, which resolve to the Phillies.
This is not a theoretical collision. Calling the gate directly, with a perfectly ordinary side: "both", max_era: 3.50 config, on the real market title "Will the high temp in Philadelphia be >91° on Aug 12, 2026?" returns True — because Wheeler (2.69) and Leahy (3.45) are both under 3.50 in a baseball game happening in the same city. A pitching filter has just approved a question about the temperature.
The bound on the damage is worth stating too, because it is smaller than it first looks: only cities that are themselves team aliases collide. "Chicago" is safe — the module lists Chicago Cubs and Chicago White Sox but never bare "Chicago" — and so are Austin, Denver and New York. Miami and Philadelphia are the two that bite today, and both happen to be Kalshi weather cities.
Read that alongside injury_anchor, which has the same disease from a completely separate alias table written at a different time. Two independent implementations of "find the team in the title", two independent trips into the Miami weather board. That is the shape of a design problem rather than a typo: a title scan with no notion of what kind of market it is looking at. A ticker-prefix check — KXMLB — would end it in one line.
3. 🟠 The gate's clock is UTC; baseball's is Eastern
The lookup is hardcoded to datetime.now(timezone.utc).strftime("%Y-%m-%d"). MLB's schedule endpoint keys games by the US calendar date. Those two agree for most of the day and then stop: at 8:00 pm Eastern, UTC has already rolled over to tomorrow, so from 8 pm until midnight ET the gate looks up the next day's slate — during the four hours when the largest number of baseball markets are live and the games it is being asked about are actually being played.
The module's own docstring is honest that at_iso is "reserved for future historical replay" and that it "always look[s] at today's row". The problem is that "today" is being answered by the wrong calendar.
And the next day's rows are the weak ones. Right now the table holds 30 rows for tonight's 15 games, and 18 rows for tomorrow's 9 — because probable pitchers are announced progressively, so tomorrow is always half-written. A missing pitcher fails closed. The window in which this gate is most likely to refuse is the window in which you would most want it.
4. 🟠 One timeout at MLB erases an ERA, and the gate then skips that game for six hours
_fetch_pitcher_stats() wraps its whole HTTP call in try/except Exception and returns an empty dict on any failure. The caller does not check. The write is INSERT OR REPLACE on (game_pk, side), so the empty result is written over the row that was already there — ERA, innings and games started all become NULL. And starter_ok() fails closed on a NULL.
A transient blip at the vendor is therefore converted into a permanent-until-next-poll refusal to trade. That is a fail-closed direction, which is the safer of the two — but it is silent: nothing logs that a good value was destroyed, and the row still carries the pitcher's name, so the table looks populated.
It happened this morning. The 06:27 run logged no failures and wrote the same 48 rows it always writes. The 12:27 run hit four HTTP 503s ("Backend is unhealthy") and five read timeouts — nine failed stat fetches. Those nine pitcher ids are now, exactly and without a single extra, the nine rows in tomorrow's slate that carry a pitcher's name and no ERA: Kevin Gausman, Aaron Nola, Davis Martin, Logan Gilbert, Cade Cavalli, Braxton Ashcraft, Walbert Ureña, Parker Messick and Payton Tolle. (Thirteen of tomorrow's 18 rows have no ERA; the other four are games where no probable has been announced yet.) Every one of those games is now unjudgeable by this gate until 18:27 UTC.
In fairness to the vendor: that was nine failures in 604 date-polls, and all nine landed in the same run. MLB's API is not the weak link. Keeping the old value when the new fetch fails would have cost one if.
5. 🟠 "Probable" is not "confirmed", and we look four times a day
The field we read is called probablePitcher, and the name is accurate. Rotations move — a scratch, an illness, a rain delay, a bullpen game called that afternoon. The market reprices immediately when a starter is scratched; that is precisely when starting-pitcher information is worth something.
We poll every six hours. A change announced ninety minutes before first pitch is invisible to this gate until the next cycle, by which time the market has had it for hours. So the signal is structurally late on the only event that makes it valuable.
Worse, we cannot measure our own exposure to this. Because the poller overwrites in place, the table keeps no record of a probable ever changing — the same blindness that stops the injury_anchor page from measuring anything about injury news. I would like to tell you how often our stored probable turned out to be wrong. The honest answer is that the data to check it was overwritten each time it happened.
6. 🟡 Season ERA is the blunt end of the tool
ERA is runs allowed per nine innings, and it includes a great deal that is not the pitcher: the defence behind him, whether his hits arrived in a clump or spread out, how many fly balls happened to clear a fence, and the park he threw in — a start in Denver and a start in San Diego are not the same number and this gate treats them as if they were.
It is also slow by construction. A season-to-date average moves very little on any one night, so a pitcher who has been excellent for six weeks after a bad April still carries April around. And min_innings: 20.0 is a low bar for calling something a season — that is roughly four starts.
The measures that strip that noise out are well established and none of them are here. Two of the ingredients, though, are: the poller already stores strikeouts and walks for every pitcher it writes, and the gate reads neither. Strikeouts minus walks per batter faced is the single most stable thing about a pitcher over a short season, and it is sitting in our table, unread.
7. 🟡 The backtester cannot replay it
lineup_anchor is in _UNSUPPORTED_ENTRY in quants/backtest.py. The reason, verbatim and dated 2026-05-28: "only current state stored (today's probable pitchers). Live engine works; backtest replay needs forward-only probable_pitchers_history snapshots."
The consequence for you is the same as on nine other pages in this dictionary, and it deserves the blunt version: a backtest that includes this rule runs without it and still hands you a curve. The run is flagged unsupported_rules — it is not silent — but the equity line you are looking at was produced by a strategy that never once consulted a pitcher.
The fix is named in the comment and is not hard: write each poll to a history table instead of overwriting. That single change would also cure catch #5's blindness, since a history of probables is a record of scratches.
8. 🟡 No bot has ever used it — and we poll it anyway
0 of 76 strategy configurations (21 live, 15 out-of-sample, 36 archived, 4 draft) and 0 of 88 saved versions mention lineup_anchor. Not live, not out-of-sample, not archived, not once.
Meanwhile lineup_poller has run 302 times since 29 May, four times a day, two dates a run, fetching a season line for every scheduled starter in Major League Baseball. It is the best-behaved poller we own and nothing downstream has ever read a row it wrote.
The case for the defence
Five things that are genuinely right
- All thirty team ids are correct. Checked live against statsapi.mlb.com/api/v1/teams?sportId=1 while writing this page — HTTP 200, thirty teams, and every one of our thirty entries names the club MLB says it does. That is worth dwelling on, because the sibling signal injury_anchor gets 14 of its 60 wrong. Whoever wrote this table checked it. (It even survives a real-world edit: MLB now returns team 133's name as simply "Athletics", and our alias list carries OAK, Athletics and Oakland, so the scan still resolves it.)
- The pipeline is the healthiest in this dictionary. The official source, free and keyless. 302 runs, none missed since 29 May. Zero database-lock failures — the only poller documented here with a clean sheet, against 61 for injury_anchor and a two-day outage for funding_anchor. Nine vendor failures in 604 polls. If every signal's plumbing looked like this the rest of this dictionary would be much shorter.
- The defaults show someone thought about baseball. min_starts: 3 and min_innings: 20.0 exist specifically to stop a reliever's small-sample ERA masquerading as a starter's, and tonight they did exactly that job twice — excluding a 3.83 earned over 49 relief innings and zero starts, and a flattering 2.45 with a single start behind it. validate_config also refuses a config with neither an upper nor a lower bound, so you cannot build a lineup_anchor that is permanently open. Several signals in this library can be configured into a constant; this one cannot.
- It genuinely discriminates. On tonight's fifteen games the pass count moves cleanly with the setting — 10, 7, 5, 2, 0 as you tighten. That sounds like faint praise until you read the btc_dominance_anchor and onchain_fees_anchor pages, where every threshold we ship is effectively a constant. This gate is a real dial with a real range.
- The registry gets this signal completely right — source, poller, cron, the one real table, not replayable, zero bots. After the phantom tables on eco_anchor, the invented vendor on injury_anchor and the blank on onchain_fees_anchor, it is worth recording when the machinery works. And the defects on this page are all in the gate — the data underneath it is clean.
Has it ever made any money?
No, and it has never had the opportunity. Zero bots use lineup_anchor and zero ever have — 0 of 76 strategies, 0 of 88 stored versions. No trades, no fills, no P&L, nothing to report and nothing invented to fill the gap.
The sports shelf as a whole is the emptiest in the library: this signal, injury_anchor, mlb_conditions_anchor and sports_anchor between them have one archived bot and three pollers running every six hours. We collect starting pitchers, injury reports and bullpen usage four times a day for an audience of nobody.
How to actually use it
Unlike most of the signals documented here, the honest answer is not "don't". The data is real, current and correct. The advice is about which questions this gate can actually answer.
- Use it for totals, not moneylines. This is the whole point. A total is a property of the game, and this gate is a game filter — the shapes match. "Both starters under 3.50, take the under" is a thesis lineup_anchor can express correctly. "Back the team with the ace" is one it cannot express at all, and the shipped recipe that tries is wrong half the time.
- Prefer side: "both". Not because it fixes catch #1 — nothing in the config can — but because "both starters clear the bar" is a claim about the game, so the fact that you get a game-level answer stops being a mismatch. "either" is where the inversion lives.
- Expect it to go quiet in the evening. Between 8 pm and midnight Eastern it is reading tomorrow's half-announced slate. If your bot's cadence runs into the night, that is not a bug you will see in a log — it will simply look like a gate that stopped opening.
- Don't let a backtest tell you anything about it. The rule is dropped and the curve you get is a different strategy's.
- And check the title is baseball. Until the ticker-prefix check in catch #2 exists, a bot with a wide market universe and this gate can find itself trading the weather in Miami.
The larger design point, which applies to the rebuilt version rather than this one. A gate that asks "is the listed starter good?" is asking a question the market answered hours ago — probable pitchers are public, published days ahead, and priced. Starting-pitcher information becomes worth money at the moment it changes: a scratch, a bullpen game, an unexpected call-up. That is an event, and events are exactly what INSERT OR REPLACE destroys. The history table named in catch #7 is the first move for anyone who wants this signal to be more than a tidy way of describing the board.
Notes on the registry, and one small thing in the code
For once there is nothing to correct. The registry resolves lineup_anchor to the MLB feed, lineup_poller, cron 27 */6 * * *, the single real table mlb_probable_pitchers, replayable: false and live_bots: 0 — and every one of those is right. The docstring is complete, names its data source explicitly, states its fail-closed semantics, and even gives three worked examples of the theses it is meant to express. It is among the best-documented modules in the library, and this page was easier to write because of it.
Two cosmetic things, recorded because someone will eventually read that file. The team table opens with a comment reading # AL East above a block that is not the AL East — the ids run straight through both leagues in numeric order, and no other division comment follows. And the docstring opens by cross-referencing [[injury-anchor-signal-may28]], a wiki-style link to a note that does not exist in this repository. Neither affects behaviour.
Our own backlog described this signal as "MLB probable pitchers, every 6h. 0 bots." All three parts of that are true, which — after eco_anchor, where the backlog's "NO DOCSTRING" warning was wrong — is worth noting as well.
Every figure on this page was measured on the production box on 12 August 2026 and traces to one of:
MLB's live Stats API (statsapi.mlb.com, called while writing), the
mlb_probable_pitchers table in predictive.db (1,976 rows), the
kalshi_markets table (8,678 markets open at the time of writing), the poller log at
/var/log/tinycorp/lineup_poller.log (302 runs), the strategies and
strategy_versions tables in quants.db, or the source of
quants/lineup_anchor.py, quants/backtest.py, static/builder-signals.js
and predictive/lineup_poller.py. The gate outcomes quoted — including the weather-market and
own-ace/opponent-ace results — come from calling lineup_anchor_satisfied() directly against the
production database with the configs named in the text. Where a claim could not be checked against a primary
source, this page says so rather than estimating. No performance figures appear here because none exist.
All TinyCorp bots trade simulated money.