mlb_conditions_anchor
mlb_conditions_anchor reads the market's title, works out which baseball teams it names, finds that team's game today, and lets your bot trade only if one of three conditions at that game clears a bar you set: how many pitches the bullpen has thrown in the last two days, the wind gust at the ballpark, or the temperature at the ballpark. "Only buy the over when a bullpen has thrown 90 pitches in two days." It is the only signal in this dictionary that pulls from two vendors, and the only one that offers three genuinely different measurements behind one name.
It is also the one where the gap between the label and the measurement is widest. Take the headline metric. A bullpen's two-day pitch count sounds like a fatigue reading — but overwhelmingly what it records is whether the team happened to play on both of the previous two days. Teams that did average 126.5 reliever pitches. Teams that had an off day average 69.3. Knowing nothing but the schedule predicts whether a team clears the default threshold 81.0% of the time. We scrape thirty boxscores four times a day to compute something you could mostly read off a calendar.
And the setting we ship on top of it barely narrows anything. The builder's field default is 90, the recipe called "Gassed bullpen" is 90, and the module's own worked example is 90 — all three with side: "either", which takes the larger of the two teams' totals. Across all 701 games we have stored that combination passes 79.2% of them; on tonight's board it admits 132 of the 160 open MLB game markets. There is a real filter inside this signal — the same metric at both ≥ 160 passes 2.9% — but nothing we ship points at it.
The sharpest illustration is a single day. On 17 July 2026 — the first full slate after the All-Star break, when every bullpen in baseball was as rested as it gets — the gate was shut on all fourteen games we recorded. Not because anything was broken. Because a bullpen that has not pitched produces no row to sum, the poller writes NULL rather than zero, and the gate correctly refuses to guess. Maximum rest and missing data are the same value.
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 17 June regardless.
- Bots using it
- 0 none live, none out-of-sample, none archived, none ever
- Data sources
- MLB Stats API + Open-Meteo — both free, both keyless
- Refresh
- Every 6 h 41 */6 * * *
- Rows stored
- 701 — 53 game-dates, since 17 Jun, never pruned
- Backtest-replayable
- No — see catch #10, where the usual excuse fails
- Anchor module
- quants/mlb_conditions_anchor.py
What it actually does
Most signals in this dictionary are master switches: one number for the whole world — is the VIX below 18, is Bitcoin dominance above 55 — and the answer is identical for every market on the board. mlb_conditions_anchor is one of the three that are per-market, driven by the words in the market's own title. It is the sibling of lineup_anchor and injury_anchor, and it borrows lineup_anchor's thirty-team title scanner outright, which is a good decision with one inherited consequence (catch #8).
Four steps. It scans the title for a team name — PHI, Phillies and Philadelphia all resolve to team 143. It looks up today's row in mlb_game_conditions for any game involving that team. It pulls one number out of that row, chosen by your metric. And it compares that number to your bound. Anything missing at any step — no team in the title, no game today, no value in the column — returns false and the market is skipped.
The configuration is four fields, all four exposed in the builder:
- metric — "bullpen" (default), "park_wind" or "park_temp". Three quite different signals wearing one name.
- side — "either" (default), "both", "home" or "away". Bullpen only. "either" takes the larger of the two teams' totals, "both" takes the smaller. If either team's total is unknown, both of those fail closed on purpose — the code says why, and it is right to.
- above / below — at least one is required. A config with neither is rejected. A config with above: 0 is not (catch #11).
One thing worth flagging before the data section, because it will otherwise read as a typo later: metric: "park_wind" returns the wind gust, not the sustained wind. The docstring says so plainly. The sustained wind is fetched and stored in the same row and nothing ever reads it.
Where the data actually comes from
Two vendors, both free, both keyless, and the registry only knows about one of them.
1 · MLB's own Stats API — statsapi.mlb.com. No key, no registration, no quota, no documented rate limit; the sport's source of truth, given away. Two endpoints, used three times per run:
- /api/v1/schedule?sportId=1&date=YYYY-MM-DD — called once for each of the two previous calendar days, to find which games finished.
- /api/v1/game/<gamePk>/boxscore — called once per completed game. The boxscore's pitchers array is in order of appearance, so index 0 is the starter and pitchers[1:] is the bullpen. Their numberOfPitches are summed per team.
- /api/v1/schedule?...&hydrate=venue(location) — called once for today, to get the slate and each ballpark's latitude and longitude.
That middle step is the genuinely fiddly piece of engineering here, and it is done correctly. Assembling two-day reliever pitch counts for thirty clubs by hand would take a person an hour a day.
2 · Open-Meteo — api.open-meteo.com/v1/forecast, the same keyless weather feed our other weather pollers use. One batched call carries every venue's coordinates at once and returns hourly temperature, wind speed and wind gust on a UTC grid; the poller finds the hour matching each game's scheduled first pitch and reads all three arrays at that index. Open-Meteo appears nowhere in the signal registry, which reports this signal's source as "MLB feeds" — see the registry note at the bottom. The builder's own description is more accurate than the registry: "Free MLB StatsAPI + Open-Meteo."
The fetch is predictive/mlb_bullpen_poller.py on cron 41 */6 * * * — four times a day at 00:41, 06:41, 12:41 and 18:41 UTC. It writes mlb_game_conditions in predictive.db, one row per game, keyed on game_pk, using INSERT OR REPLACE. Because the key is the game rather than the team, and because there is no DELETE anywhere in the file, the table is a genuine archive: 701 rows across 53 game-dates from 17 June to today, and growing. That accident matters twice on this page — once in catch #10 and once in the defence.
To be unambiguous about the denominators used throughout this page: one row is one game, carrying a home column and an away column, so 701 rows are 701 games and 1,402 team-slots. What the numbers look like across them: reliever pitches run 4 to 323 with a mean of 111.0 per team; ballpark gusts run 1.8 to 38.9 mph, mean 12.2; sustained wind 0.7 to 22.9, mean 7.9; temperatures 58.7 °F to 113.1 °F, mean 82.8. Forty of the 1,402 team-slots are empty. Those forty are catch #2.
What it does for your bot
In principle it narrows your bot to games where something physical is unusual. The pen is spent, so late runs are likelier — take the over, or fade the team that has to protect a lead with tired arms. The wind is up at a park where that matters. It is ninety-eight degrees and the ball will fly. Those are real, legible baseball theses, and they are exactly the kind of thing a casual bettor does not sit down and compute.
In practice, be precise about what each of the three metrics is:
- bullpen is a two-day team pitch total with no denominator. It does not know how many games produced it, how many arms threw them, or who is unavailable tonight. Mostly it knows the schedule.
- park_wind is the forecast gust at the park at first pitch, in mph, with no direction and no knowledge of whether there is a roof over it.
- park_temp is the same, for temperature.
And the shape of the gate is a game filter, not a team filter — the same shape as lineup_anchor, with the same consequence (catch #7). For a total, that is the right shape and the module's own worked examples are all totals. For a moneyline it is not, and the shipped copy advertises a moneyline use it cannot express.
The catch
1. 🔴 The fatigue number is mostly a calendar lookup — and the setting we ship passes four games in five
Two problems that compound. First the measurement. A team's two-day reliever pitch count has no denominator: a club that played Monday and Tuesday is being compared with a club that had Monday off. Across the 1,288 team-games where we hold both prior slates, teams that played both days average 126.5 pitches and teams that played one average 69.3 — a ratio of 1.83, very close to the 2:1 in games. Knowing nothing but the schedule predicts whether a team clears the default 90 correctly 81.0% of the time (81.4% of the played-both group clear it, 20.3% of the played-one group).
Then the threshold. The shipped recipe "Gassed bullpen" — "trade games where a bullpen has thrown a lot of pitches over the last 2 days" — is {metric: bullpen, side: either, above: 90}, and it is also the builder's field default and the docstring's own example. "either" takes the larger of the two teams' totals, which is a maximum of two draws from a distribution whose mean is 111. The result: 555 of 701 stored games pass — 79.2%, and 132 of the 160 open MLB game markets today.
The fair counter-case, which is stronger than it first looks. For a gate, calendar load genuinely is the first-order fatigue signal. The dominant reason a bullpen threw 126 pitches instead of 69 is that it worked two nights instead of one, and that is not a confound so much as the mechanism doing its job. The metric is not wrong about baseball. Two things follow anyway, and they are what makes this a catch rather than an observation:
- The expensive part earns almost nothing. Walking thirty boxscores four times a day to recover a number that is 81% predictable from one bit — did they play twice — is a lot of machinery for a schedule lookup. Whatever edge exists must live in the other 19%, and nothing in the config isolates it.
- The interesting case is exactly the one it cannot express. Ninety pitches from a single fourteen-inning night is a different animal from forty-five and forty-five, and a bettor cares about the difference. Both produce the same number here, and there is no field that separates them.
The dial itself is not broken; it is pointed at the wrong end of its own range. both rather than either at the same 90 cuts to 47.2%, at 120 to 19.7%, at 160 to 2.9%. So this is a bad default on a real metric, not a useless metric — and the way to read the 79.2% is "this recipe is on by default", not "bullpen fatigue is junk".
2. 🔴 A fully rested bullpen is stored as “no data”, so the gate cannot see it
_bullpen_fatigue() builds a dictionary of team → pitches from the games that finished in the window. A team that did not play appears in no game, so it is absent from the dictionary, so fatigue.get(team_id) returns None and NULL goes into the row. The gate then fails closed. Zero pitches and no information are written as the same thing.
That matters most in the direction the signal is quietest about. above is the "gassed pen, buy the over" thesis. below is the opposite one — a fresh pen, so the late innings are safe — and the freshest possible bullpen is precisely the one the gate cannot admit, because two days off is indistinguishable from a dead feed.
The cleanest demonstration is 17 July 2026, the first full slate after the All-Star break. MLB played 15 games that day; we stored 14 (the fifteenth, Pittsburgh at Cleveland, was postponed and turned up as one half of a doubleheader the next day). The trailing window was 15 and 16 July, which between them held one game in the whole of Major League Baseball — Mets at Phillies — and neither of those clubs was on the 17 July slate. So all 28 bullpen values across all 14 games were NULL, and the gate was shut on every game on the board, in both directions, on the one day of the season when "every bullpen in baseball is rested" was unarguably true.
The distinction worth holding on to: failing closed on an outage is right. This is failing closed on a defined zero — a value the world actually produced and the poller declined to write. The defect is upstream, in writing NULL where it could write 0 for a team it can see on the schedule and can see did not play.
3. 🔴 More than a quarter of these games are played indoors
park_temp and park_wind are the outdoor conditions at the stadium's coordinates. Checked against MLB's own fieldInfo.roofType for every venue in our table: of the 701 stored games, 509 were in open air, 166 at parks with a retractable roof, and 26 at Tropicana Field, which has a fixed dome. That is 27.4% where the number we store may describe the sky above a closed roof, and 3.7% where it certainly does.
Tonight makes the point without any need for statistics. We have 98.7 °F stored for the Rockies at the Diamondbacks. Chase Field has a retractable roof; Phoenix in August does not play with it open. A {metric: park_temp, above: 95} gate — "heat helps the ball carry" — will admit that game on a reading taken outside an air-conditioned building. Rogers Centre and loanDepot park are on tonight's slate too, at 71.5 °F / 15.7 mph and 86.6 °F / 14.1 mph respectively.
Be precise about what is and is not fixable here, because they are two different things. Roof type is free and we ignore it: MLB returns fieldInfo.roofType on the very endpoint the poller already calls — it asks for hydrate=venue(location) and could ask for the field info in the same request. Storing that alone would let a bot exclude the eight parks where the question is meaningless. Roof state — open or shut for this particular game — is the thing you actually want, and we do not ingest it from anywhere. This page is not going to guess at it, so every retractable game in the table stays in the "unknown" column above rather than being quietly assigned to one side.
4. 🔴 The copy promises direction; the code measures magnitude
Worth separating the two claims, because only one of them is broken. The code's thesis is defensible: a windy park is a windier park, and gust speed is a crude but genuine proxy for conditions that move fly balls, pop-ups and command in either direction. The copy's thesis is not measurable at all. The docstring says a gust matters because "wind blowing out inflates scoring / home runs"; the builder's novice explanation says "wind blowing hard at the park pushes more balls out". Both describe direction, and the gate reads a scalar. A 20 mph wind blowing straight in from centre field — which suppresses scoring — is indistinguishable here from 20 mph blowing straight out.
Open-Meteo returns wind_direction_10m on the identical call, free. Verified while writing this: the same request that gave us Busch Stadium's gust today also carries 256°, a wind from the west-south-west. We do not ask for it.
Being fair about the size of the fix, because it is bigger than one parameter: direction alone would not finish the job. Turning "from 256°" into "blowing out" needs each ballpark's home-plate-to-centre-field bearing, and we hold that nowhere — not in the poller, not in the schedule payload, not in any table. So this is a two-part change: fetch a field we are already entitled to, and build a thirty-row table of park orientations. Neither is hard. Neither exists.
Related, and not merely cosmetic: metric: "park_wind" returns the gust. Sustained wind is fetched, stored in park_wind_mph on all 701 rows, selected by the gate's own SQL, and then never read by anything. The docstring is honest about this; the field name is not, and the gap between the two numbers is large enough to matter. Gust runs a mean 4.3 mph above sustained (median 3.5, max 18.9), so a user who sets park_wind above 15 picturing a steady fifteen-mile-an-hour breeze gets a gate that opens on 28.4% of games instead of the 3.7% sustained wind would have given them — nearly eight times as often as intended, silently.
5. 🟠 Forty-eight hours with no data at all — caused by our own backtest index
The poller has run 226 times. 217 wrote rows. Nine died on sqlite3.OperationalError: database is locked at the INSERT OR REPLACE, and all nine are consecutive: every scheduled run from 00:41 on 10 August to 00:41 on 12 August. Nothing was written for two whole days. game_date = '2026-08-10' and '2026-08-11' hold zero rows, so for forty-eight hours the gate returned false for every MLB market on either book.
The cause is bracketed cleanly at both ends. predictive/ticker_index.py — the index that made backtests 47× faster — was replaced at 22:18 UTC on 9 August. The poller's next scheduled run, 00:41 on the 10th, was the first failure, and it never recovered while that version was live. A fix named lockfix landed at 02:47 UTC on 12 August. The poller's very next run, 06:41, succeeded, and every run since has. Nine failures inside the window; zero in the 217 runs outside it.
The mechanism is a scheduling collision that is not bad luck but arithmetic: ticker_index refreshes on cron minutes 1, 11, 21, 31, 41 and 51, and this poller's slot is minute 41. Every single run of this poller starts in the same second as an index refresh. That was survivable for eight weeks. It stopped being survivable when the refresh started running 99–124 seconds while holding the write lock, against this poller's SQLite busy timeout of 20.0 seconds. The fix moved the scan out from under the lock: post-fix the log reports the write lock held 0.12 to 22.06 seconds (mean 3.35) inside a run that still takes about 129 seconds.
Two honest qualifications. The pre-fix log recorded only a total elapsed_s, not a lock duration — the write_lock_s field is the fix — so the evidence for the mechanism is the bracket and the collision, not a measured pre-fix lock time. And 1 of the 89 runs since the fix still held the lock for 22.06 s, which is longer than this poller's timeout. The risk is much reduced; it is not gone.
This also settles a question left open on the injury_anchor page, which wondered whether a poller's start-minute offset predicts whether it survives. It does not. lineup_poller at :27 has never failed; this one at :41 collides on every run and also never failed, for eight weeks. What decides it is how long the lock is held versus the client's busy timeout — a duration, not an offset.
6. 🟠 The gate's clock is UTC. Baseball's day is not.
The lookup is datetime.now(timezone.utc).strftime("%Y-%m-%d") against a game_date that MLB assigns on local time. Those two agree through the afternoon and part company for the best part of the evening. The clean, assumption-free number: 176 of 701 games — 25.1%, the west-coast nights — have not thrown a first pitch until the UTC date has already rolled over. The wider figure is 65.2% still in progress after UTC midnight, though that one rests on an assumed 3 h 05 m game, so treat it as an estimate and the 25.1% as the fact.
What the gate does in that window is the part that deserves the emphasis, and it is not "fails closed". The poller writes the new UTC date's slate at 00:41, so from 00:41 UTC (8:41 pm Eastern) the table has rows for the date the gate is asking about — they are just tomorrow's games. The gate does not fail. It answers confidently about a different game, at a different park, with a different bullpen. And because this table is never pruned, tonight's rows are still sitting right there, one date value away: the data is not missing, the query is looking past it. In the 41 minutes before the poll it is worse only in the sense of being honest — no rows for the new date, so nothing trades.
This is the same disease as lineup_anchor catch #3, and the two pollers differ in a way that matters: lineup_poller fetches two dates a run, today and tomorrow, so at least its roll-over lands on rows it deliberately prefetched. This one fetches today only. And on 10 and 11 August the two faults stacked — the clock had rolled onto a date the poller had also failed to write, so the gate was not looking at the wrong slate, it was looking at nothing at all.
7. 🟠 “side: home” does not mean the team you asked about
That a per-game condition returns one answer per game is half a tautology, so take it as read: "Will the Phillies win?" and "Will the Cardinals win?" get the identical verdict, always. The footgun is the field that looks like it fixes that and does not. side reads as "the team in the title"; it means the home or away team of that game. Tonight, a bot asking about the Phillies with side: "home" is handed St. Louis's bullpen total of 132, not Philadelphia's 104 — verified by calling the gate directly on both titles with all three settings. There is no value of any field that says "the team I am betting on".
For a total that is fine — a total is a property of the game, and the module's three worked examples are all totals. The problem is the copy. to_novice_english tells the user a gassed pen is "useful for OVER / fade-the-favorite plays", and fade-the-favorite is a side thesis this gate has no way to express. Cross-referenced on the lineup_anchor page, where the same shape costs the shipped recipe half its trades.
8. 🟠 A weather market, judged on another city's weather
The title scanner is imported from lineup_anchor, so it inherits that module's city aliases, so it inherits the collision documented there and on injury_anchor. Of 10,242 open Kalshi markets right now, 196 titles match an MLB team and 36 are not baseball — 18 KXHIGHMIA and 18 KXHIGHPHIL, high-temperature markets for Miami and Philadelphia.
On the other two sports signals that is merely wrong. Here it is wrong in a way worth photographing. Verified live, by calling the gate directly: "Will the high temp in Philadelphia be >91° on Aug 12?" passes {metric: park_temp, above: 95} — because the Phillies are away tonight, at Busch Stadium, where the forecast reads 97.7 °F. A weather signal answering a weather question with the wrong city's weather. Philadelphia's own forecast is never consulted.
Under today's shipped default the same collision admits all 18 Philadelphia markets and none of the 18 Miami ones — not because anything protects Miami, but because the Marlins' game happens to read 40 pitches and fails the threshold. Those 36 are the complete list, not a sample: every one of the 10,242 open markets was scanned. The collision is bounded by an accident of the calendar — it can only fire in a city that is both a team alias and a city Kalshi runs a weather market in, which today is Miami and Philadelphia and nowhere else. Chicago, New York, Los Angeles and Denver are all team aliases and all currently safe, because no matching weather market is open. That bound is luck, not design.
It is latent rather than live: the engine applies its category filter in SQL before this gate ever runs, and all 76 strategies declare a category, so nothing routes weather markets here today. The gate offers no protection of its own; the surrounding query does. A ticker-prefix check would end it in one line — the same one-liner lineup_anchor needs, in a second module, for the third time.
9. 🟡 Doubleheaders: whichever row the database hands back first
The lookup ends in LIMIT 1 with no ORDER BY. When a team plays twice on a date — 22 team-days in our record — which row you get is undefined. Not random: SQLite will hand back whatever the scan reaches first, stably, which is the worst kind of undefined because it looks reliable. On 22 July the Orioles and Red Sox played a doubleheader and the query returns the 23:10 UTC game at 75.2 °F rather than the 17:35 UTC one at 81.2 °F. For the Pirates and Yankees the same day it returns the earlier game. Same query, opposite choices.
The bullpen figures are identical for both halves, so this only bites the two weather metrics — but six degrees is the difference between passing and failing a sensible threshold. And underneath the matching bug is a missing product decision that nobody has made: when a team plays twice, which game is the market about? Nothing in the config can express an answer, and nothing anywhere records which one you were given.
10. 🟡 The backtester skips it — and here the standing reason does not apply
mlb_conditions_anchor appears nowhere in quants/backtest.py — not in the supported set, not even in the documentary _UNSUPPORTED_ENTRY list. It is caught only by the catch-all that flags any entry key the replayer does not know. So a backtest that includes this rule runs without it, returns a curve for a different strategy, and flags unsupported_rules — a lie by omission rather than a lie, but the curve still looks like an answer.
The reason given in that file for the whole family of sports anchors is "only current state stored". For lineup_anchor and injury_anchor that is true, because their INSERT OR REPLACE is keyed on a pitcher or a player and overwrites history in place. Here it is keyed on game_pk, and a game happens once. Nothing is overwritten across days and there is no prune, so the table already holds 53 game-dates of real per-game history — and our Kalshi price tape covers 22 June onward, giving 48 overlapping dates and 1,370 MLB tickers with price snapshots. Both halves of a replay are sitting on the disk.
Be careful about how much that proves. It kills "only current state stored" as a reason this gate cannot be replayed; it does not by itself make a replay sound, and there are three real obstacles left. Only the last value written on a game's date survives, so what we hold is an end-of-day snapshot rather than an as-of-decision-time series. 152 of the 701 games start before the 18:41 write, so for those the stored weather was recorded after first pitch — an archive, but not a lookahead-free one. And of 1,896 MLB market rows only 130 currently carry a settlement result, so the settled sample is far smaller than 796,000 price snapshots makes it sound. The honest summary is that this is the only one of the four sports signals where the missing piece is work rather than data.
11. 🟡 Configurations that validate and cannot work
validate_config requires at least one bound, which stops the empty config. It does nothing about the values. {above: 0} validates and opens on every game with a number in it — so unlike lineup_anchor, which cannot be built into a constant, this one can. {above: 200, below: 10} validates and renders, in the builder's own English, as "either team's bullpen has thrown ≥ 200 pitches over the last 2 days and ≤ 10 pitches over the last 2 days". {metric: park_temp, above: 500} validates. This is the same unsatisfiable-range hole documented on finance_anchor and btc_dominance_anchor; no warning is issued anywhere.
In the same family, and gentler: the docstring's third worked example is "buy total UNDER only when the ballpark temp is < 50 °F", and no game in our record has ever been below 58.7 °F. That is not really the example's fault — our table starts on 17 June and baseball in June, July and August is warm. It is worth stating only because the table is never pruned, so the example that is dead today will come alive on its own next April. This is one of the few signals here where "wait" is a real answer.
12. 🟡 On an opener day, the first reliever is counted as the starter
"Relievers are every pitcher after the starter" is implemented as pitchers[1:], on the correct observation that the boxscore array is in order of appearance. That is right for a conventional start and wrong for an opener, where the man in slot 0 is a reliever and his pitches are silently excluded from the bullpen total.
This was an assumption on this page until we measured it. Across the 90 team-games on 7–9 August, 8 — one in eleven, 8.9% — had a slot-0 pitcher who threw fewer than 40 pitches: Erik Miller 17, Casey Legumina 13, JT Brubaker 16, Noah Schultz 23, Davis Martin 23, Daniel Lynch IV 35, Joey Cantillo 31, Chase Petty 38. Every one of those is bullpen work that the fatigue number does not see. The error is one-sided and modest — a 13-to-38-pitch undercount on roughly 9% of team-games — but it lands exactly where the metric is already weakest, and it will grow if openers do.
The case for the defence
Five things that are genuinely right
-
It never invents a value. A missing row, a missing column or one unknown side of an
either comparison all return false; nothing is defaulted to
0 pitches or 72 °F to keep a bot firing. Compare
launch_anchor, where stale
future-dated rows hold the gate open on a rocket that has already flown, and
injury_anchor, where an unpruned
table gets more permissive with age. Filtering on
game_date = today also means the gate needs no freshness check, because a
stale table simply stops matching.
But do not let us sell you the 10–11 August blackout as the design working. Two days in which every MLB market on both books was rejected is an outage, not a safety feature, and the cause was our own database rather than a dead vendor. And on this page "fails closed" turns out to be the wrong answer three times over: on the All-Star return it rejected a defined zero (catch #2), after 00:41 UTC it is not fail-closed at all but a clock reading the wrong slate while the right rows sit in the table (catch #6), and during the blackout it rejected everything for a reason that had nothing to do with bullpens. The virtue is narrow and real: missing ⇒ false. It is not a general licence. - The table is a real archive, and almost nothing else here is. Keyed on the game rather than on a person, and never pruned: 701 rows, 53 game-dates, one permanent record per game. That is what retires the "only current state stored" objection in catch #10, and it is the single most useful thing about this signal. Whoever chose game_pk as the primary key may not have been thinking about backtests, but they made one possible.
- Both vendors are impeccable and neither has ever failed us. MLB's Stats API and Open-Meteo, free, keyless, no quota. Across 226 runs the logs record zero schedule fetch failures, zero boxscore failures and zero Open-Meteo failures. Every failure this pipeline has suffered was our own database. And the weather is one batched call for all fifteen parks rather than fifteen calls, which is the right way to use that endpoint.
- The hard part is done, and done nearly right. Assembling two-day reliever pitch counts means walking the schedule, filtering to games actually Final, pulling each boxscore, and knowing that the pitchers array is in order of appearance. That is real, fiddly work and it holds up in about 91% of team-games (catch #12 is the rest). The claim we are not going to make is the usual one about casual bettors: sportsbooks price bullpen usage perfectly well, and any edge here would have to be against other prediction-market participants, not against a professional. What is true is narrower — this is a number nobody is going to assemble by hand in the ninety minutes before a first pitch.
- The set-logic is right, and the product copy is more honest than the registry. "either" as max(home, away) and "both" as min(home, away) are the correct readings of those two English words, and the deliberate refusal to evaluate either of them when one side is unknown carries a comment saying why rather than leaving you to guess. The builder exposes all four config fields — injury_anchor hides the one that matters — and its own description names "Free MLB StatsAPI + Open-Meteo", which is a more accurate account of this signal's sources than the signal registry manages. Most of the damage on this page is in what the numbers mean, not in how the gate handles them.
Has it ever made any money?
No, and it has never had the chance. Zero bots use mlb_conditions_anchor and zero ever have — 0 of 76 strategies (21 live, 15 out-of-sample, 36 archived, 4 draft) and 0 of 88 saved versions. No trades, no fills, no evaluations, no P&L. Nothing is reported here because there is nothing, and nothing has been invented to fill the space.
The sports shelf remains the emptiest in the library. This signal, lineup_anchor, injury_anchor and sports_anchor between them account for 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.
And one thing this page deliberately does not tell you. The question a trader actually wants answered is whether the gate has any predictive relationship with anything — whether games it admits score more runs, whether its openings line up with moves in the closing price. We have not measured that, so we are not going to imply it in either direction. Everything above is about what the number is and what the code does with it. Whether it predicts baseball is a study nobody has run, and catch #10 explains why it would have to be run by hand rather than through the backtester.
How to actually use it
The data is real and the plumbing is sound, so the honest answer is not "don't" — it is "be much more specific than the defaults are".
- Use both, never either. either takes the maximum of two teams and is therefore high almost all of the time — 79.2% of games at the shipped 90. both is where the dial has travel: 47.2% at 90, 19.7% at 120, 2.9% at 160.
- Set the bullpen threshold high enough to survive the schedule confound. You cannot ask this gate for pitches-per-game-played; it does not compute it. The blunt workaround is to pick a number that only a genuinely heavy two-game workload reaches. both ≥ 160 is roughly one game in thirty-five.
- Do not use the weather metrics without deciding what to do about roofs. Until roof type is stored, a temperature or gust gate is silently meaningless at eight parks — Tropicana Field, Chase Field, American Family Field, Daikin Park, Globe Life Field, Rogers Centre, T-Mobile Park and loanDepot park.
- Totals, not moneylines — see catch #7, and the same advice on the lineup_anchor page for the same reason.
- Expect it to answer about the wrong day after 8:41 pm Eastern, and to be shut for the 41 minutes before that.
- Ignore any backtest that includes it. The rule is dropped and the curve is a different strategy's.
The design point, for whoever rebuilds it. Bullpen usage is only worth money as a surprise, and a team total is the least surprising form of it. Last night's box score is public, and the number of innings a pen threw is the first thing anyone looking at this market reads. What is not in the price is availability: which specific arms are unavailable tonight because they went back-to-back, who threw forty pitches on Sunday, whether the closer is down. That information is already inside the data we fetch — _bullpen_pitches_for_date reads numberOfPitches for every individual reliever and then immediately sums them into one number per team. The per-pitcher detail is downloaded four times a day and thrown away before it touches the disk. A version of this signal that kept it would be answering a question the market has not already answered.
Notes on the registry, and what the backlog got right
The registry resolves this signal to poller mlb_bullpen_poller, cron 41 */6 * * *, one table — mlb_game_conditions, which is real and is the only one — replayable: false and live_bots: 0. All correct, and here the zero really does mean zero rather than the "out-of-sample plus live" count that has caught six earlier pages out.
Two things it gets wrong, both instructive. The source resolves to "MLB feeds", which is half the truth and hides the other half. The anchor's docstring names no vendor at all — it says only that its data comes from mlb_game_conditions, populated by the poller. The registry's SOURCE_HINTS table then matches the bare string "MLB" appearing in ordinary sentences like "gate MLB markets", and reports a vendor the module never claimed. Open-Meteo — which supplies two of the three metrics — is not in SOURCE_HINTS at all, so it cannot be detected no matter what any docstring says. The same list produced the confident wrong vendor on injury_anchor; this is the variant where it is right about one vendor and blind to the second.
And the summary is truncated mid-sentence — "gate MLB markets on the actual game's" — because _summary() takes only the first physical line of the docstring and this one's opening sentence wraps. Cosmetic, but it is the string the dictionary index would print if nobody wrote a description by hand.
Our own backlog described this signal as "bullpen fatigue + park weather, every 6h. 0 bots." All three parts are true, which makes two Tier-2 lines in a row that have held up after injury_anchor's described the wrong vendor and the wrong sport.
None of the defects above has been fixed. This loop writes dictionary pages and does not touch pollers, anchors or bot configuration. Nothing here is losing money — no bot uses the signal — but catches #1, #2, #3 and #4 are live behaviour in quants/mlb_conditions_anchor.py and predictive/mlb_bullpen_poller.py, and the smallest useful repairs are all one-liners: write 0 rather than NULL for a scheduled team that did not play, add fieldInfo to the hydrate, add wind_direction_10m to the Open-Meteo variable list, and point the shipped recipe at both.
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 — the schedule and the venue roof types, both called
while writing), Open-Meteo (api.open-meteo.com, called while writing), the
mlb_game_conditions table in predictive.db (701 rows), the
kalshi_markets and market_price_snapshots tables, the poller log at
data/mlb-bullpen-poller.log (226 runs) and data/ticker-index.log, the
strategies and strategy_versions tables in quants.db, or the source of
quants/mlb_conditions_anchor.py, quants/backtest.py,
quants/engine.py, static/builder-signals.js and
predictive/mlb_bullpen_poller.py. The gate outcomes quoted — including the Philadelphia
weather-market result and the identical verdict on both sides of a game — come from calling
mlb_conditions_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.