degree_days_anchor
degree_days_anchor is a master switch wired to how much air-conditioning or heating the country is about to need. A degree day is simply how far a day's average temperature sits from 65 °F: five degrees above and you have five cooling degree days, five below and you have five heating degree days. Every three hours we fetch a seven-day forecast for sixteen big American metros, add up the degree days, weight the cities by population, and store one national number. You pick a condition off it — "only when cooling demand is above 40" — and while it holds the 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/forecast — not the EIA, see below
- Refresh
- Every 3 hours (23 */3 * * *)
- Rows stored
- 246 readings 30 days — the rest is deleted
- Backtest-replayable
- No — see catch #3
- Anchor module
- quants/degree_days_anchor.py
Start with the good news, because this is the rare signal in this dictionary whose premise survives being checked. Degree days really are what a natural-gas desk looks at first: air-conditioning load in summer and furnace load in winter are what burns the gas. So we tested it. Reconstruct five years of this exact index and line it up against 260 weeks of the EIA's own working-gas storage numbers, and the weekly change in American gas storage tracks it at r = −0.97. In winter, heating degree days alone explain the weekly draw at r = −0.96. The physics is not folklore, and it is not marketing — it is in the government's own data, and as far as we can tell nobody here had ever actually run the check.
Now the bad news, in the order it matters. One: the natural-gas markets this was built to trade do exist on Kalshi, and our own category rule can't see them, because it looks for a series called KXNATGAS and the real one is called KXNGAS. So the only markets the shipped starter bot can currently buy are crude oil and gold. Two: the number the gate reads is 96.3% calendar — August is hot because it is August — and what a gas desk actually trades is not the level but the surprise, which we explicitly decided not to compute. Three: the code insists this signal has no history and cannot be backtested. That is false, and disproving it is where every five-year figure on this page came from.
Where the number actually comes from
One HTTP request, to Open-Meteo — api.open-meteo.com/v1/forecast, free, no key, no account. The poller asks for temperature_2m_max and temperature_2m_min in Fahrenheit for the next seven days at sixteen metros, in one batched call rather than sixteen separate ones. For each city and day it takes the mean as (high + low) ÷ 2 — the actual NOAA convention for degree days, not something we invented — then cooling degree days as anything above 65 °F and heating degree days as anything below, weights the cities by metropolitan population, and sums across the seven days. One row lands in degree_days in predictive.db every three hours, on 23 */3 * * *, eight times a day.
The sixteen metros are New York, Los Angeles, Chicago, Dallas–Fort Worth, Houston, Washington DC, Philadelphia, Atlanta, Miami, Phoenix, Boston, San Francisco, Detroit, Seattle, Minneapolis and Denver: 111.9 million people, roughly a third of the country, weights hard-coded in the poller.
Our signal registry says the source is “US EIA”. That is wrong. No EIA endpoint is called anywhere on this path. The anchor's docstring mentions the EIA once, to make the point that the weekly storage report is lagging while this is leading, and the registry's source-matcher scooped the vendor name out of that sentence. Credit where it's due: the starter bot's own blurb in static/starters.js names Open-Meteo correctly, so the hand-written product copy is more accurate here than the machine-generated index.
The reading at 18:23 UTC on 12 August 2026, the newest row in the table: cooling demand 106.9, heating demand 0.7, covering 12–18 August. All 246 rows we hold are flagged dominant = cdd — which tells you how narrow a month of memory is.
What it does for your bot
It is a whole-bot switch, not a market filter. You choose cdd or hdd and a level, and on every cycle the engine looks at the newest row before it looks at any market. If your condition holds, the bot goes on to do whatever else it does. If it doesn't — or if the newest row is more than six hours old — the bot places no trades at all that cycle, including in markets that have nothing to do with the weather. Nothing ties it to gas, or to energy, or to commodities: that connection lives entirely in whatever category filter you pair it with.
The catch
1. The gas markets exist. A misspelt series name hides them
The starter bot we ship, “Cooling-Demand Watch”, filters on category: commodities. Our Kalshi categoriser assigns that category by ticker prefix: ^KX(BRENT|WTI|GOLD|SILVER|NATGAS|COPPER|OIL). Probed live against Kalshi's own API this afternoon:
- KXNGASMAX — “Natural gas price peak” — exists, with 7 open markets right now (“Will the price of natural gas get above $7.00…”, $6.50, $6.00, $5.50 and more).
- KXNGAS — “Natural gas price max and min monthly” — exists too.
- KXNATGAS, the string our rule actually looks for — HTTP 404. There is no such series.
So Kalshi's natural-gas markets can never be filed as commodities; they would land in other, and the starter bot filters on commodities. The signal and its markets are separated by one word. Three more names in that rule are equally fictional — KXBRENT, KXSILVER and KXCOPPER all return 404 — while KXWTI (20 open markets), KXWTIMAX (20) and KXGOLD are real and would be categorised correctly.
There is a second, independent lock. Our Kalshi ingest is deliberately curated — 40 named weather series plus the top 300 open events Kalshi promotes — and across 535,390 market rows and 49 distinct ticker prefixes it has never once returned a commodity market of any kind. Even the correctly-named KXWTI has never arrived. Fixing the typo alone changes nothing; fixing the ingest alone still leaves natural gas filed as other.
Which leaves Polymarket, and it doesn't rescue it either. 962 commodities markets seen all-time, but 927 are currently flagged dropped by our own ingest sweep, and the engine reads only status='active'. That leaves 33, of which 6 sit in the 30–60¢ band the starter buys in: three WTI crude and three gold. Zero natural gas — both open Polymarket gas markets are flagged dropped too. So the signal sold as the leading driver of natural-gas demand would, today, spend its money on crude oil and gold.
Three separate faults, and it is worth being precise that no single fix produces a gas trade: a series name that doesn't exist, an ingest that never looks, and a price band pointed at a bucket that is mostly oil and metals.
2. 96% of what the gate reads is the calendar — and the market trades the other bit
Reconstruct five years and fit nothing more sophisticated than "what is the average for this day of the year", and that alone explains 96.3% of the variance in the seven-day cooling figure. The number swings with a standard deviation of 33.9 degree days across the year; strip the season out and what remains — whether this particular week is hot for the time of year — has a standard deviation of 6.6, about a fifth as wide.
Put the shipped setting next to a rule with no data behind it at all — "is the month between May and September?" — and the two give the identical answer on 91.2% of days across five years. Of the 160 days they disagree, 151 are the gate being stricter than the calendar (111 in May, 32 in September) and 9 are October days warm enough to open it. So the honest reading is not "it's just a calendar": the signal earns its keep almost entirely in the shoulder months, and contributes nothing whatever in July, August, December or January.
Test it the other way and the same picture appears. On the 10% of weeks that were most abnormally hot for the date, cdd > 40 was open 78.0% of the time — and on the 10% that were most abnormally cool for the date it was still open 63.2% of the time. Mean anomaly on open days +1.18, on shut days −0.61, against an anomaly spread of 6.6.
This is the gap between the signal and the trade. Nobody trades gas on the news that August is warm; the August forecast is in the price already, drawn from the same public model runs we poll. What moves the number is the revision — the forecast coming in hotter than the desk assumed. Our gate can only see the level. The poller says as much, explicitly declining to assert a climatological baseline and calling a vs-normal anomaly "a future enhancement". That was an honest call when there was nothing to compute a normal from. Catch #3 removes the excuse: five years are free, and a normal is an average.
Two caveats against my own finding. The "normal" here is a five-year day-of-year mean of my own construction, not an official 30-year normal, and with five observations per calendar day it is noisy — but noise in the normal adds noise to the anomaly, which pushes the R² down, so 96.3% is a floor rather than a flattering number. And a season filter is a perfectly reasonable thing to want; the objection is not that it's seasonal, it's that you can have 91% of it from the month, for free, with no vendor and full backtest replay.
3. The code says this can't be backtested. The archive is free
The poller's own docstring: “Open-Meteo's free forecast is forward-only (no archive), so there's no backfill — the signal is live-only.” The backtester's comment on this whole family of anchors: “each needs a forward-captured series we don't have retroactively.” Checked from the production box rather than from memory, both are false here:
- Open-Meteo's archive API returns observed daily highs and lows back to 1940 — HTTP 200, free, keyless.
- Open-Meteo's historical-forecast API returns its own archived forecast runs back to 2021 — HTTP 200, free, keyless.
- And on every day I could compare them, the historical-forecast archive reproduces the live forecast endpoint exactly: across nine overlapping days on all sixteen metros the difference is 0.000. (That is strong evidence for the current model; it is not a promise that a 2021 archived run is blended identically, and this page won't claim that.)
The price side exists too: 624 Polymarket commodities tickers with 398,330 snapshots across 52 dates, 51 of them natural-gas markets. Both halves of a replay are already free or already on disk — which is not the same as a replay being done. Somebody still has to reconstruct the series, store it and wire it into the loop.
Meanwhile degree_days_anchor appears in neither of the backtester's lists, and is caught only by the catch-all that flags any unrecognised entry key — which is the designed behaviour, so that part is trivia. The consequence is not: a backtest containing this signal runs without the gate and still returns a curve, flagged unsupported_rules where most people won't look. Exactly as with arb_anchor and energy_anchor — and the "no retroactive series" excuse fails here for the same reason it failed there, where the EIA serves 40 years and the poller asks for eight weeks.
4. We ship the weaker half of the year
Having reconstructed the history, the premise can finally be tested — see the defence for the good news. The relevant part here is which season carries it. Against 260 weeks of the EIA's own working-gas storage series:
| weekly change in US gas storage vs… | weeks | correlation |
|---|---|---|
| winter heating degree days (Dec–Feb) | 65 | −0.957 |
| summer cooling degree days (Jun–Aug) | 64 | −0.786 |
| heating + cooling, all five years | 260 | −0.969 |
Heating is much the stronger half, and that is not a surprise: winter space heating burns gas directly, while summer cooling burns gas only where gas happens to be the marginal fuel in the power stack. Our own metro weights reflect it — New York and Chicago alone are 38.5% of our heating degree days, which is roughly where American furnace load lives, whereas our cooling side is 50.5% Miami, Houston, Dallas and Phoenix.
And every setting this site ships is the cooling one. The builder's field default, the builder's one-click recipe ("Cooling-demand regime") and the starter bot are all {metric: cdd, above: 40}. The hdd side — the half with the stronger relationship to the thing the copy talks about — is documented in the module and suggested nowhere in the product.
5. It is not the national number, even though it's called one
NOAA's Climate Prediction Center publishes the real thing: population-weighted degree days for the continental United States. Ours runs 1.38× the national cooling figure over the year (1.25× across summer) and 0.88× the heating figure (0.90× across winter). A mid-August seven-day window reads 97 on ours and about 79 on NOAA's.
The cause is the metro list. Miami, Houston, Dallas and Phoenix together produce 50.5% of all our cooling degree days while holding 23% of our population; San Francisco and Seattle contribute 0.7% between them on 8.7 million people; and there is no rural or interior coverage at all.
Does the series still track? Yes, and the honest measure is not the one that flatters it. Correlation of the levels with NOAA's is 0.986 — but two seasonal curves will always agree like that, so it proves little. Strip the season from both and the anomalies still correlate at 0.862. That is the number that says our index really is measuring day-to-day national weather rather than a calendar.
For a gate, then, this is mostly a labelling problem: calibrate against our own series and it doesn't matter that our 40 is NOAA's 32. It becomes a real problem the moment you type in a threshold you read somewhere else. And note it is not a clean rescaling — warm-biased in summer, cool-biased in winter — so no single conversion factor fixes it.
6. The seven days we measure are not the seven days the report covers
The EIA's weekly gas storage figure covers a week ending Friday and prints Thursday at 10:30 a.m. Eastern. Our window is "the next seven local days from whenever the cron happened to fire". Those never line up: a Wednesday reading straddles two report weeks, roughly half in the one about to print and half in the one after. For a signal whose stated job is to anticipate that specific number, the window is the wrong shape, and nothing in the config can change it — the horizon is hard-coded at forecast_days=7.
7. Thirty days of memory, for free, forever, deleted
The table is pruned to 30 days — actually 30.75, because the delete compares an ISO timestamp containing a T against SQLite's space-separated datetime() as text, and 'T' sorts after a space, so the cut-off day survives. The extra 18 hours are harmless. The 30 is not: it is the reason catch #8 has four weeks of evidence rather than four years. We throw away the only record of what we forecast, while the vendor keeps both the forecasts and the outcomes indefinitely and gives them away. A year of this signal is about 2,900 rows.
8. The forecast ran hot — in the only month we can check, where it couldn't matter
Comparing every seven-day window we hold against Open-Meteo's observed archive, our stored figure was +6.5 degree days too hot on average, and too hot on all 19 windows. Against the same model's own later analysis, +10.6, one-sided on all 25.
Do not read too much into that. These are seven-day windows shifted a day at a time, so they overlap six-sevenths — call it four independent weeks inside one hot summer month, which is closer to "we measured one heat wave nineteen times" than to a demonstrated bias. And it would have changed nothing: at the shipped threshold, a 6.5-degree-day error flips the gate on 0 of the 309 July and August days in five years. Across a full year it would flip 3.3% of days — every one of them in May, June, September or October. The bias is a shoulder-season problem, on a signal whose only real information is in the shoulder season, and we keep no shoulder-season data.
9. One of the eight daily runs quietly mixes two calendars
The request uses timezone=auto, so each city's daily series is in its own local calendar — and the code zips those arrays by index, labelling the window with whatever New York returned. At the 06:23 UTC run the four Pacific-time metros (Los Angeles, Phoenix, San Francisco, Seattle — 23.9% of the weight) are still on yesterday while the other twelve have rolled over, so the national total is a mix of two different weeks. It fires on exactly one of the eight daily runs.
Measured across 423 windows, it is worth 0.48 degree days on average (max 2.8), and it would flip a cdd > 40 gate once in 423. So: a real correctness bug, published with its size so nobody has to guess — and the size is "negligible".
10. A partial answer is stored as if it were a whole one
If Open-Meteo returns fewer than sixteen metros, or one with mismatched arrays, the code skips the missing cities and renormalises over whoever came back. The arithmetic is right; the record-keeping isn't. The stored row records neither the metro count nor the weight sum, so a reading assembled from twelve cities is indistinguishable afterwards from one assembled from sixteen. Losing Miami alone would take roughly a seventh off a summer figure and nothing would look unusual. There is no evidence this has ever happened — and no way to check, which is the defect.
11. Two smaller things
{"metric":"cdd","above":60,"below":40} passes validation and renders as “cooling demand > 60 and cooling demand < 40” — a bot that can never trade, described in plain English as if it were fine. Same validate-anything hole as finance_anchor.
And every row stores a dominant field saying whether cooling or heating is currently driving — which the gate never reads. So a hdd bot goes quiet all summer and a cdd bot goes quiet all winter, correctly, with nothing anywhere telling you that you just switched off half the year, and the one field that could say so sitting unread in the same row.
The case for the defence
- The premise is true, and now it's measured. This is the first signal in this dictionary where the underlying claim was checked against an independent official series and passed. Weekly US gas storage moves with this index at r = −0.969 over 260 weeks, and with winter heating degree days alone at −0.957. Whatever else is wrong here, nobody made the mechanism up, and the fix list is about targeting and framing rather than about a signal that doesn't work.
- It measures real weather, not just the season. Against NOAA's own national series the anomalies — season removed from both sides — still correlate at 0.862. The thermometer is good. The complaint in catch #2 is about what the gate can see, not about what the index knows.
- The shipped default is not a stuck constant. Worth saying because the last few pages here found exactly that. Over five years cdd > 40 is open 34.1% of the time and changes state about every 91 days. That is evidence the default isn't broken — not evidence that it's informative, which is a separate question catch #2 answers less kindly.
- This is the healthiest pipeline in the dictionary. One failure in 464 runs — 99.8% — and the single failure, at 21:23 UTC on 21 July 2026, was a disk I/O error rather than the database is locked epidemic that has hit seven other pollers written up here. One batched HTTP request per run, not sixteen. This is process quality; it does not rescue the targeting, but it is real and rare.
- The freshness check is correctly written — it parses fetched_at into a real Python datetime and compares actual instants, rather than the string comparison that quietly defeats the same check on finance_anchor and tropical_anchor. It fails closed on an unparseable timestamp, and six hours against a three-hour cadence is the right ratio: one missed run tolerated, two not.
- Validation is stricter than its closest sibling. It rejects negative thresholds, a boolean masquerading as a number, an unknown metric, and a config with no condition at all — energy_anchor, which does the same job, accepts both a boolean and a negative. Catch #11 is the one thing that gets past it.
- It refuses to invent a baseline. Both the poller and the public commodities card say outright that they do not assert a climatological normal. Given that nobody had computed one, declining to fake it was right — and it is why catch #2 describes a missing feature rather than a fabricated one.
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 degree_days_anchor. No trades, no fills, no evaluations, no profit or loss — and this page will not manufacture any. That is the expected state for this tier of the library, and it is worth noting that it also means nobody has been hurt by any of the above.
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
That weekly gas storage tracks degree days at −0.97 is a statement about realised weather and realised storage. It is not evidence of an edge, and the difference is the whole game: to trade the storage print you need the forecast to differ from what the market already expects, and we hold no consensus figure anywhere on this site — not for storage, not for degree days. Nor has anyone tested whether this index moves the price of any contract we can actually buy. The physics is confirmed. The edge is unexamined, and saying so is more useful than implying it.
How to actually use it
Use hdd, not cdd, if gas is the point. Heating is where the relationship lives (−0.957 against the weekly draw); cooling is the weaker, noisier half. Everything the product suggests points the other way.
Calibrate against our scale, not a published one. Our 40 is roughly NOAA's 32. A level lifted from a gas note will not mean what you think. The builder shows the current reading — set the level relative to that.
Know that you are buying a season, not a forecast. If what you want is "trade in cooling season", this is a robust way to say it and it adjusts itself to an early or late one — and it will agree with the calendar 91% of the time. If what you want is "trade when the weather surprises", this signal cannot express that yet.
Check there is something to buy. Before pairing it with categories: ['commodities'], look at what that filter actually returns today. On Kalshi the answer has always been nothing, and on Polymarket it is currently six markets, none of them gas.
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 — check the newest row in degree_days before concluding anything about the weather.
Where to go next
energy_anchor is the direct counterpart and the pair is the point: that one reads the EIA storage number this one is meant to anticipate, and its own catch is that the report reaches our table a day after the window its bot trades in. Read together they describe both ends of the same gas trade, and neither end currently connects. mlb_conditions_anchor is the closest cousin in kind — another signal that turns out to be largely a calendar lookup, measured the same way, and it shares the Open-Meteo vendor. btc_dominance_anchor is the instructive contrast on defaults: there a free archive proved the shipped threshold could not open for 800 days. Here the same method exonerated the threshold and convicted the framing instead. tropical_anchor is the other signal whose bot filters on a category nothing can reach.
The remaining weather signals on this same three-hourly rhythm — ensemble_anchor, heat_index_anchor, qpf_anchor, wind_anchor, sst_anchor and aqi_anchor — have no profile pages yet, and no bots either. ensemble_anchor is the interesting one after this page: it measures forecast disagreement, which is much closer to the thing catch #2 says is missing.
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/degree_days_anchor.py, predictive/degree_days_poller.py, quants/engine.py, quants/backtest.py, quants/energy_anchor.py, predictive/kalshi_poller.py, static/builder-signals.js and static/starters.js, the live crontab, the poller's log at data/degree-days-poller.log, read-only queries against degree_days, kalshi_markets, polymarket_markets and market_price_snapshots in predictive.db and all 76 strategy configs and 88 strategy versions in quants.db, and live calls to Open-Meteo's forecast, archive and historical-forecast endpoints, Kalshi's public series and markets endpoints, NOAA CPC's population-weighted degree-day files and the EIA's v2 natural-gas API — all fetched directly from that same host. The five-year figures are 1,825 seven-day windows reconstructed from Open-Meteo's archive using the poller's own arithmetic, and the storage correlations use 260 weeks of EIA series NW2_EPG0_SWO_R48_BCF; both are cited as corroboration, not as production data sources. Market counts are a single snapshot taken that afternoon and will move. No performance figures appear on this page because no bot has ever used this signal. All signals →