ensemble_anchor
ensemble_anchor is a master switch wired to how much the weather models disagree with each other. A modern forecast isn't one run of one model — it's a crowd. The American GFS is run about thirty times at once from slightly different starting conditions, and the thirty answers you get back are the honest measure of how knowable the day is. When they cluster within a degree, tomorrow's high is close to settled. When they scatter across fifteen degrees, the single confident number on the weather app is hiding a coin flip. Every three hours we ask for those thirty members in twenty American cities, take the widest-minus-narrowest gap in each city, and store the national average. You pick a level off it — "only when the average gap is above 5 °F" — and while it holds your bot trades. While it doesn't, the bot places no trades at all that cycle, in any market.
- Bots using it
- 0 (none live, none out-of-sample, none ever)
- Data source
- Open-Meteo /v1/ensemble — the registry says “undocumented”
- Refresh
- Every 3 hours (41 */3 * * *)
- Rows stored
- 243 readings 30 days — the rest is deleted
- Backtest-replayable
- No — and here that's genuinely unavoidable, see catch #6
- Anchor module
- quants/ensemble_anchor.py
The idea is good, and unlike most of the signals in this dictionary it isn't folklore: ensemble spread is the standard way operational meteorology expresses forecast confidence, and "the models disagree, so the temperature bracket is closer to a coin flip than it looks" is a sound reason to want to trade. This page is not going to tell you the premise is wrong — only that we have never tested it, which is a different complaint and is dealt with at the end.
It is going to tell you that the number we built does not mostly measure the weather. Across all 243 readings we hold, 70.1% of the variation in this signal is explained by the time of day it was taken, and only 16.3% by which day it was. The reason is almost funny: we ask the model for today's high in each city's own local time, and two of our eight daily polls land in the American evening — so we are asking a thirty-member forecast ensemble to predict a temperature that has already happened. Measured against our own 430,000 weather observations, at the 00:41 UTC poll the high being "forecast" was already in the past for 94.1% of city-days, and at the 03:41 UTC poll for 100.0% of them. The models agree, because there is nothing left to disagree about.
That single design choice runs straight through everything else. The level this site recommends — above 5 °F, which is the builder's default, the builder's one-click recipe and the starter bot we ship, all three — turns out to be open 16% of the time at those two evening polls and 100.0% of the time at all six others. It is not a weather gate. It is a clock.
Where the number actually comes from
One free, keyless HTTP request, every three hours, to ensemble-api.open-meteo.com/v1/ensemble. Open-Meteo is a European weather API that republishes national weather services' raw model output; the model we ask for is gfs025 — NOAA's Global Forecast System ensemble on a quarter-degree grid. We ask for one field, temperature_2m_max, for one day (forecast_days=1), in Fahrenheit, at twenty airport coordinates in a single batched call. Checked live from the production host while writing this: HTTP 200 in 0.67 seconds, 30 members returned.
A small precision worth having: the response actually carries thirty-one forecasts — the thirty perturbed members our poller reads, plus the unsuffixed temperature_2m_max, which is the control run. We ignore the control. On the day this was checked that cost nothing — the control (89.4 °F) sat inside the members' 87.8–90.5 °F band, so the stored range was unchanged — but there is no reason it always will, and nothing checks.
Those twenty cities are not arbitrary — they are the twenty stations Kalshi writes its daily temperature contracts on, from KATL to KSFO. The poller, predictive/ensemble_poller.py, runs on cron 41 */3 * * * — eight times a day, at 41 minutes past every third hour. For each city it takes the warmest member minus the coldest, then writes one row into ensemble_spread in predictive.db holding just two national numbers: mean_spread, the average of the twenty gaps, and max_spread, the widest single city. The table is pruned to 30 days and currently holds 243 rows, 13 July to 12 August 2026.
Two things worth recording about how this signal is described elsewhere on our own box. Our signal registry reports the source as "undocumented"; it isn't, but the registry only reads the anchor's docstring and Open-Meteo is named in the poller's, one directory over — the same artefact that hid Launch Library from the launch_anchor page. And our own backlog had this signal marked "no docstring — read the code"; it has a 24-line docstring. That annotation has now been wrong on every signal it was applied to.
One claim in the code checks out exactly: both docstrings say this is the same data behind the "Ensemble agreement" card on /signals/weather. It is — same twenty cities, same URL, same member-parsing, same minimum of five members, verified line by line. The card shows you each city; the gate only ever sees the average.
What it does to your bot
It is a global switch, not a filter. Most entry conditions narrow which markets you'll look at. This one is checked before the market list is even built: in quants/engine.py, if the gate is shut the strategy returns an empty list and places no trades that cycle — not in weather, not in anything. A bot with this anchor and a crypto category filter will sit out because the models agree about Oklahoma City.
It fails closed, and it fails closed correctly, which is rarer here than it should be. If the table is empty, if the newest value is null, if the timestamp won't parse, or if the newest row is more than six hours old, the answer is no. Six hours against a three-hour cadence is the right ratio: one missed run tolerated, two not.
The catch
1. 🔴 The number is mostly a clock
Take all 243 readings and ask what explains their variation. The hour of day explains 70.1% of it. The calendar day — the weather — explains 16.3%. The eight daily poll slots barely overlap: the 00Z and 03Z polls average 4.73 and 4.74 °F and have never once exceeded 5.8, while the 09Z poll averages 7.03 and has never gone below 5.5.
This is not a subtle statistical point, because it decides the gate. above 5 is open 16% and 17% of the time at the two evening polls and 100.0% of the time at all six others — 182 readings out of 182, no exceptions. And there is no threshold that escapes it: anything at 6.0 or higher is structurally impossible at 00Z and 03Z (0% and 0%), and anything at 5.0 or lower is locked open at the other six. Whatever level you choose, the first thing your bot is reacting to is what time it is.
2. 🔴 Two of the eight polls forecast the past
Here is why. The request uses forecast_days=1 with timezone=auto, which means "today's high, in each city's own local time". At 00:41 UTC every one of the twenty American cities is still on the previous local evening — 8:41 pm in New York, 5:41 pm in Los Angeles. So the day being forecast is a day that is nearly over.
We can measure exactly how over. Our own wx_nws_observations table holds 430,346 real station readings for these same twenty airports. Line the observed daily highs up against the poll times and the answer is stark: at the 00:41 UTC poll the high being "forecast" had already been recorded for 94.1% of city-days; at 03:41 UTC, for 100.0% of them. The median daily high lands at 4:54 pm local, so by those two polls it is long past. At the 09Z poll, by contrast, only 0.5% of the days have peaked.
The internal consistency is its own proof: at 06Z the figure is 27.7%, and the five cities still on the previous local day at 06:41 UTC are the five at UTC−7 — a quarter of twenty. The pattern is the time zones, exactly.
Which points at the deeper version of the problem: a single poll is not a single lead time. Because "today" is resolved per city, one row of this table blends Eastern cities whose day is nearly finished with Pacific cities whose day has barely begun — four different amounts of forecasting in one average. Ensemble disagreement grows with how far ahead you are looking, so averaging across cities that are at different points in their own day mixes the quantity we want with an artefact of the map.
To be fair to the model: an ensemble converging on a day that has already happened is correct behaviour. Nothing is broken upstream. The defect is entirely ours — we file that converged number in the same column as a genuine day-ahead spread, and then compare both to a single threshold as if they meant the same thing.
3. 🔴 We ship one number in three places, and the module's own example has never been able to open
Every recommendation this site makes for this signal is the same: above 5. It is the builder field's default value, it is the one-click recipe ("Forecast-disagreement regime — only trade weather brackets when the models broadly disagree"), and it is the shipped starter bot ("Forecast-Disagreement Watch", weather markets, 30–60¢). Over the full record that setting is open 79.01% of the time, which per catch #1 is really "is it not the evening in America".
Worse, the anchor's own docstring offers three examples, and the third — {"metric":"mean","below":3}, "only when the forecast is broadly near-decided" — is open 0.00% of the time across all 243 readings. The national average spread has never been below 3.8 °F in the thirty days we hold. It is a documented example that cannot fire. The second example, {"metric":"max","above":10}, is open 76.54% — the same near-always-open problem in the other metric.
And the starter bot's blurb invites you to "flip it to below to trade only on near-decided days". below 5 is open 19.34% of the time — which is, once again, almost exactly the two evening polls.
4. 🔴 Eight polls a day, five forecasts — and the table can't tell you which is which
GFS runs four times a day. We poll eight times. The result is that 37.2% of consecutive rows are byte-identical on both numbers, and the duplication is perfectly structured: 00Z→03Z repeated on 30 of 30 days, 12Z→15Z on 30 of 30, 18Z→21Z on 29 of 30. Every single one of the 28 complete days we hold contains exactly five distinct readings.
That would be harmless bookkeeping if the table said so. It doesn't. The as_of column is documented in the schema as the forecast issue time, and it is written as the fetch time — identical to fetched_at in 243 of 243 rows. So the six-hour freshness check proves the poller ran recently; it says nothing at all about how old the underlying forecast is, which can be nine hours or more. A stale model run and a fresh one look exactly alike to the gate.
Partly not the poller's fault: I checked the response — Open-Meteo returns generationtime_ms, utc_offset_seconds, elevation and so on, but no model-run field at all. There is nothing obvious to put in that column. The defect is the label, and the fact that nothing anywhere deduplicates.
5. 🔴 "Spread" here is not what "spread" means in weather, and it's about five times bigger
We store range: the warmest member minus the coldest. In operational meteorology ensemble spread conventionally means the standard deviation of the members. These are very different numbers. Measured live for New York while writing this page: the range was 6.0 °F and the standard deviation 1.22 °F — a factor of about five.
So if you read a forecast discussion that calls a 5 °F ensemble spread large and you set our gate to above 5, you have not demanded an unusually uncertain day. You have described a calm one — our thirty-day average range is 6.1 °F. The error runs in the direction that makes you think the gate is strict when it is loose.
Range also has a known statistical weakness: it is an extreme-order statistic. One rogue member sets it, it takes no account of how the other twenty-nine are distributed, and it grows with the number of members — so it isn't comparable across models with different ensemble sizes either. Nothing on the page or in the builder says the unit is a range.
6. 🔴 Not backtest-replayable — and this is the first time the standard excuse survives checking
ensemble_anchor appears nowhere in quants/backtest.py — not in _SUPPORTED_ENTRY, not even in the documentary _UNSUPPORTED_ENTRY list, which stops at arb_anchor. It is caught only by the catch-all, so a backtest that includes this signal runs without the gate and still returns a curve, flagging unsupported_rules. Whatever that curve shows, it is not a test of this signal.
The code's standing justification for this whole family is that they "need a forward-captured series we don't have retroactively". On this dictionary's last few pages that excuse has been refuted — a free four-year archive existed for btc_dominance_anchor, and forty-four years of weekly data for energy_anchor. So I went looking again, and this time — with one genuinely interesting wrinkle — it holds up. Verified live from production:
- the ensemble endpoint with past_days=14 returns HTTP 200 but carries actual members for only the last five days — ten of the fourteen come back entirely null;
- an explicit June date range returns HTTP 200 with every member null;
- previous-runs-api.open-meteo.com works, but serves the deterministic forecast, not ensemble members;
- archive-api.open-meteo.com is ERA5 reanalysis — what actually happened, with no ensemble at all.
The wrinkle: there is one archived series with "spread" in the name. Ask the same host for models=ncep_gefs025_ensemble_mean and the variable temperature_2m_spread, and it will happily serve you hourly values for 1 June 2026 — 48 of 48 non-null. For about ten minutes that looked like the excuse collapsing again.
It isn't the same quantity, and the numbers say so plainly. Pull that series for one location across a fortnight of past days and a fortnight of future ones, and the past sits flat at 0.23–0.63 K with no trend, while the future climbs steadily with lead time: 0.97 K tomorrow, 1.53 K at three days, 1.95 K at five, 2.88 K at nine. Ensemble disagreement must grow with how far ahead you look, so a stored history that shows no such growth is not a record of what the ensemble was forecasting — it is the spread at almost zero lead, which is close to a measurement. Rebuilding this signal from it would understate day-ahead disagreement by something like three to five times, in the direction that makes every past day look decided.
Same for the five-day member window, for the same reason: those past-day values come from the current run projected backwards — a hindcast, not the forecast that was on screen at the time. So the verdict stands, and it is the first time in this dictionary that it has: this history genuinely does not exist unless we record it ourselves. Which we do, eight times a day, and then delete after thirty days.
7. One city opens the gate and you never learn which one
The max metric is the widest of twenty cities, and the cities are not remotely alike. Live on 12 August: Chicago Midway 12.1 °F, Los Angeles 2.5 °F — the widest city is 4.8× the narrowest. The docstring's own {max, above 10} example was cleared that day by Chicago alone.
Nothing then stops the bot buying a Miami bracket. Miami was sitting at 2.7 °F, the second most near-decided city in the country — the exact opposite of the state the gate was written to detect. The poller computes all twenty per-city numbers on every run and throws nineteen of them away; the public card on /signals/weather shows them, and the gate is the one thing that can't see them.
The same objection applies more gently to mean, and with a twist: the twenty cities are not twenty independent readings. One weather pattern moves a dozen of them at once, so averaging them together steadies the number far less than a count of twenty suggests. The average looks more precise than it is.
One last mismatch worth naming, because it affects the trade rather than the statistics: the ensemble forecasts a quarter-degree grid cell, roughly fifteen miles across, while the contracts this signal is meant to inform settle on a specific airport's official observation. Grid disagreement and station-forecast uncertainty are related but not the same thing, and the gap is largest exactly where terrain and coastline are — which, looking at the panel above, is where several of our widest cities sit.
8. Two smaller things
{"metric":"mean","above":8,"below":4} passes validation and renders in plain English as "only when average forecast disagreement > 8°F and average forecast disagreement < 4°F" — a bot that can never trade, described as though it were fine. {"above":100} and {"below":0} also validate. Same validate-anything hole as finance_anchor.
And both comparisons are strict — above 5 excludes a reading of exactly 5.0. With values stored to one decimal that is a real, if small, boundary: it costs you the days that land precisely on your number.
The case for the defence
- The premise is real meteorology. Ensemble spread genuinely is how forecast confidence is expressed, and "the models disagree, so this bracket is closer to a coin flip than the single published number suggests" is a sound trading thought. Nothing here was invented to sound clever. Every complaint on this page is about sampling, units and aggregation — not about the idea.
- This is the healthiest feed in the dictionary. 464 runs, 459 successes, and — the part that stands out — ok: False has never once been logged, meaning the Open-Meteo call itself has not failed a single time since June. The five crashes were four database is locked and one disk I/O error: our box, not their API. Slot coverage is 98.4%. One batched request per run rather than twenty. Worth being precise about the four, though — they match the five missing rows exactly, three of them landing consecutively on 11 August, and they are the same shared-predictive.db contention that has now interrupted every poller written up in this dictionary. The vendor is faultless here; we are not.
- The freshness check is correctly written, and it has actually fired. It parses fetched_at into a real Python datetime and compares instants, rather than the string comparison that quietly defeats the same check on finance_anchor and tropical_anchor. A 12-hour gap on 11 August forced the gate shut for 6.0 hours — 0.81% of the record. That is the system working.
- There is a real weather signal in there. Hold the poll hour fixed and the day-to-day variation doesn't vanish: within a single slot the thirty-day range is still 2.0–2.8 °F, standard deviation 0.46–0.67. The thermometer works. It is being read through a 4:1 layer of clock noise, which is a fixable framing problem rather than a dead signal.
- Validation is stricter than most. It rejects negative thresholds, a boolean pretending to be a number, an unknown metric, and a config with no condition at all. Catch #8 is what gets past it — a shorter list than several signals written up here.
- Nobody has been hurt. No bot has ever used this, so none of the above has ever cost anyone a cent.
The real record
There isn't one. No bot has ever used this signal — not live, not out-of-sample, not archived, not a draft. A search of all 76 strategy configs on the box (21 live, 15 out-of-sample, 36 archived, 4 drafts) and all 88 saved strategy versions returns zero matches for ensemble_anchor. No trades, no fills, no evaluations, no profit or loss — and this page will not manufacture any.
Both files have sat unmodified since 15 June 2026, 58 days, writing a number to disk eight times a day that nothing reads and deleting it a month later.
What this page does not tell you
Whether wide ensemble spread actually predicts mispriced temperature brackets. That is the question the signal exists to answer, and answering it needs two things we don't have: a stored series of what the spread was at the moment each contract was priced, and the contract prices to match. We hold thirty days of the first and delete it, and the backtester can't read it anyway. Nothing on this page should be taken as evidence the idea works — only as evidence about what the number currently measures.
There is a prior step we can't check either, and it deserves stating rather than glossing. The whole premise assumes spread predicts error — that a wide-disagreement day really does produce a bigger forecast miss. That relationship is well known to be imperfect for near-term surface temperature, where ensembles are often under-dispersive: they can agree closely and still be collectively wrong. We cannot test it here, because we store only the two national aggregates and throw away the per-city forecast that would have to be scored against the observation. It is testable — the observations are sitting in wx_nws_observations — but nobody has done it, and until somebody does, "wide spread means a coin flip" is a reasonable belief rather than a measured fact.
How to actually use it
Compare like with like, and know the gate can't. The anchor always reads the newest row, so the same threshold means one thing at breakfast and another at bedtime. Until it can condition on the poll slot, treat any level you set as a joint statement about the weather and the time of day.
If you want a genuine day-ahead read, 09Z is the only honest poll. It is the one where the forecast day hasn't started — 0.5% of city-days have peaked by then, against 100% at 03Z. At that slot the median is about 7.0 °F, so above 7 splits it roughly in half. That is a real threshold on a real distribution, and it is nowhere in the product.
Don't lift a number from a weather source. Ours is a range; theirs is a standard deviation. Divide by roughly five, or better, set the level against the reading the builder shows you.
Avoid max unless you're happy being switched on by a city you can't name. On a normal day it is one of twenty, it is usually the continental interior, and it tells you nothing about the market you are actually buying.
Don't read a quiet run as a verdict. A backtest containing this signal ran without it. A live bot that never trades might be a shut gate, an empty market list or a stale table, and from the outside those look identical.
Where to go next
wx_anchor is the one to read beside this. It is the only weather signal with live bots, and it trades exactly the temperature brackets this signal is supposed to tell you are mispriced — so between them you have the opinion and the instrument, currently not connected to each other. degree_days_anchor is the direct sibling: same vendor, same three-hourly rhythm, same finding in a different costume — there 96% of the number was the calendar, here 70% of it is the clock. btc_dominance_anchor and energy_anchor are the instructive contrast on catch #6: on both of those the "we can't get the history" claim collapsed under a single HTTP request. This is the one where it held.
The remaining weather signals on this same rhythm — heat_index_anchor, qpf_anchor, wind_anchor, sst_anchor and aqi_anchor — have no profile pages yet, and no bots either. All five are built on the same Open-Meteo plumbing, so it is worth knowing whether they inherit the local-day problem in catch #2 before trusting any of them.
Written 12 August 2026. Every number on this page was read from production that day — the live host at /var/www/tinycorp.ai, not a local mirror: the output of quants/signal_registry.py, the source of quants/ensemble_anchor.py, predictive/ensemble_poller.py, quants/engine.py, quants/backtest.py, app.py, static/builder-signals.js and static/starters.js, the live crontab, the poller's log at data/ensemble-poller.log, read-only queries against ensemble_spread and wx_nws_observations in predictive.db and all 76 strategy configs and 88 strategy versions in quants.db, and live calls to Open-Meteo's ensemble, ensemble-mean, previous-runs and archive endpoints — all made directly from that same host. The variance split is a one-way decomposition over 243 readings; the "high already happened" figures come from 600 well-sampled station-days matched to each poll timestamp in each city's own time zone; the lead-time figures in catch #6 are the hourly temperature_2m_spread series for one location over 14 past and 10 future days. The per-city panel is one live snapshot and will move. No performance figures appear on this page because no bot has ever used this signal. All signals →