Trade Bot Dictionary · Sports

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:

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 APIstatsapi.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:

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-Meteoapi.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.

How mlb_conditions_anchor pulls reliever pitch counts from MLB's Stats API and ballpark weather from Open-Meteo into one row per game, why the bullpen number is mostly a record of the schedule rather than of fatigue, why the day after the All-Star break shut the gate on every game, why more than a quarter of these ballparks have a roof over them, and the forty-eight hours in August when the poller could not write at all The first band traces the data path. Two source boxes on the left — MLB's Stats API, supplying the schedule and per-game boxscores, and Open-Meteo, supplying hourly temperature, wind and gust for every ballpark — merge into a single poller, mlb_bullpen_poller, running four times a day on cron minute 41. It writes the table mlb_game_conditions, 701 rows across 53 game-dates, one row per game and never pruned. That feeds the gate, which reads the market title, resolves a team, finds today's game and compares one metric to your bound, and finally the bot, of which there are none. Notes record 226 poller runs with 217 successes and 9 failures, all nine consecutive, and zero failures at either vendor. The second band measures what the bullpen number actually tracks. Two horizontal bars on a pitch-count axis: teams that played on both of the previous two days average 126.5 reliever pitches across 952 observations, while teams that played only one of the two average 69.3 across 335. A dashed vertical line marks the shipped default of 90 pitches, which falls between them: 81.4 per cent of the played-both group clears it against 20.3 per cent of the played-one group. A summary strip states that knowing only whether a team played both days predicts whether it clears 90 correctly 81.0 per cent of the time. To the right, a six-box timeline of the All-Star break: 12 July had fifteen games, 13 July none, 14 July the All-Star Game alone, 15 July none, 16 July a single game, and 17 July a full slate of fifteen. A bracket marks the two-day window the 17 July rows read — 15 and 16 July, containing one game in forty-eight hours — and a panel records that all fourteen games stored on 17 July had both bullpen values null, so the gate was shut on every one of them in both directions. The third band is about the weather. A stacked bar splits all 701 games by MLB's own roof-type field: 509 in open air, 166 at parks with a retractable roof, and 26 at Tropicana Field, a fixed dome. A note records that tonight we store 98.7 degrees Fahrenheit for Chase Field in Phoenix. Beside it a compass rose shows today's wind at Busch Stadium coming from 256 degrees, with a list of the four numbers available on that single response: the gust of 15.0 miles per hour which we store and read, the sustained wind of 10.2 which we store and never read, and the direction which we never request. The fourth band is a timeline of every scheduled poller run from 9 to 12 August 2026, fifteen marks in all. The first four, on 9 August, succeeded. Then a dashed marker records a new ticker_index being deployed at 22:18 on 9 August, after which nine consecutive runs failed with a database-is-locked error, spanning the whole of 10 and 11 August and the first run of 12 August. A second dashed marker records a lock fix at 02:47 on 12 August, after which the next two runs succeeded. Two panels below record the consequence — zero rows for game-dates 10 and 11 August, so every MLB market was skipped for forty-eight hours — and the mechanism: the backtest ticker index refreshes on cron minutes 1, 11, 21, 31, 41 and 51, and this poller's slot is minute 41, so every single run collides with it. 1 · THE PATH — TWO VENDORS, ONE ROW PER GAME MLB Stats API statsapi.mlb.com schedule + boxscores Open-Meteo api.open-meteo.com temp · wind · gust, per park mlb_bullpen_poller 41 */6 * * * 4 × a day · today's slate only mlb_game_conditions 701 rows · 53 game-dates 1 row per game · never pruned the gate title → team → today's game → one metric vs your bound your bot 0 bots, ever free · keyless · official 0 vendor failures in 226 runs 226 runs · 217 wrote rows 9 died on a locked DB → band 4 bullpen · gust · temp · sustained wind 40 of 1,402 bullpen slots empty fails closed on anything missing no freshness check — none needed 0 of 76 strategies 0 of 88 saved versions 2 · THE FATIGUE NUMBER IS MOSTLY A CALENDAR LOOKUP Reliever pitches over the trailing 2 days 1,288 team-games where we hold both prior slates 0 50 100 150 200 played BOTH prior days n = 952 126.5 avg played ONE of the two n = 335 69.3 avg played NEITHER no value at all — stored as NULL the shipped default: 90 81.4% clear it 20.3% clear it 0% can clear it Knowing only whether the team played on both of the last two days predicts whether it clears 90 pitches correctly 81.0% of the time. That is the signal, mostly. The day the league came back rested games on the MLB schedule, around the 2026 All-Star break 12 Jul 15 13 Jul 0 14 Jul ASG only 15 Jul 0 16 Jul 1 17 Jul 15 the 48 h the 17 Jul rows read one game in it, between two teams who were not on the 17 Jul slate 17 July: 14 games stored · 28 of 28 bullpen values NULL The gate was shut on every one of them — above and below alike. A fully rested bullpen and a missing bullpen are the same value. 3 · THE WEATHER IT DESCRIBES IS THE WEATHER OUTSIDE Where the thermometer actually is all 701 stored games, split by MLB's own fieldInfo.roofType 509 open air 166 open air — 509 games (72.6%) — the reading is real retractable roof — 166 games (23.7%) — we have no idea whether it was shut fixed dome, Tropicana Field — 26 games (3.7%) — the reading is never right Tonight at Chase Field, Phoenix, we store 98.7 °F. The roof type comes back on the same schedule endpoint the poller already calls — we ask for venue(location) and stop. Which way it is blowing the thesis is “blowing out”; the measurement is a magnitude N E S W from 256° — Busch Stadium, today on that one response: gust 15.0 mph stored · read sustained 10.2 mph stored · never read direction 256° never requested Direction alone would not finish the job: “out” also needs each park's plate-to-centre bearing, which we do not hold anywhere. 4 · FORTY-EIGHT HOURS DARK — AND OUR OWN BACKTEST INDEX DID IT every scheduled run of mlb_bullpen_poller, 9–12 August 2026 · four a day at 00:41, 06:41, 12:41, 18:41 UTC 9 Aug 22:18 — new ticker_index deployed 12 Aug 02:47 — lock fix deployed 9 Aug 10 Aug 11 Aug 12 Aug 9 consecutive runs died on sqlite3.OperationalError: database is locked — 9 of 226 lifetime, and all 9 are here What it cost game_date 2026-08-10 → 0 rows game_date 2026-08-11 → 0 rows every MLB market skipped, both days Why this poller and not the other two ticker_index refreshes on minutes 1, 11, 21, 31, 41, 51. This poller's slot is :41. Every run collides, by construction. Since the fix, a refresh holds the write lock 0.12–22.06 s (mean 3.35) of a ~129 s run. The poller's busy timeout is 20 s — so 1 of the 89 runs since would still have broken it. Reduced, not removed. Every number above was measured on the production box on 12 August 2026. Sources: statsapi.mlb.com and api.open-meteo.com (both called live), predictive.db, quants.db, the poller logs, and quants/mlb_conditions_anchor.py. No performance figures appear anywhere on this page, because no bot has ever used this signal. All TinyCorp bots trade simulated money.
The whole signal in one picture. Band 1 is the pipeline — two free vendors, one row per game, and nothing downstream. Band 2 is the defect that matters most: the bullpen number mostly records the schedule, and the one state it cannot represent at all is rested. Band 3 is the weather — a quarter of these games are played under a roof, and the wind direction that the whole "blowing out" thesis depends on is on the response we already fetch and is thrown away. Band 4 is the forty-eight hours in August when the table was empty, and the reason it was our fault rather than the vendors'.

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:

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".

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.