Chicago Is Not O'Hare: The Settlement Station Mapping Mistake
TL;DR / Key Takeaways
- Kalshi settles Chicago temperature contracts on KMDW (Midway), not KORD (O'Hare)
- Houston settles on KHOU (Hobby), not KIAH (Bush Intercontinental)
- The Kalshi public metadata API tells you exactly which station every contract uses
- Guessing at contract specifications is a fast way to misprice every trade you make
The Assumption That Costs You Money
When I started building the Weather Bot, I needed to map city names to weather stations. Chicago means O'Hare. Everyone knows that. O'Hare is one of the busiest airports in the country. KORD is the ICAO code. Done.
Except Kalshi doesn't use O'Hare.
Chicago temperature contracts on Kalshi settle against KMDW — Midway airport. Midway is on the southwest side of the city, about 10 miles from downtown. O'Hare is northwest, another 7 miles past that. On a given day, those two stations can read 3 to 5 degrees Fahrenheit apart.
I didn't guess wrong because I'm careless. I guessed wrong because it was a reasonable assumption and I didn't verify it. That's the expensive kind of mistake.
How I Found It
I found the Midway issue the same way I find most of my bugs: by refusing to accept "probably fine" as an answer.
The bot had been running for a few weeks. I was auditing the trade database and noticed that Chicago win rates looked off relative to my model's confidence levels. Not dramatically wrong. Just... soft. The kind of soft that makes you squint at a chart.
I started pulling the actual settlement data from completed Chicago trades and cross-referencing against my forecast. The forecast was built on KORD observations. The settlements were resolving against KMDW readings. Different stations. Different microclimates. My model was forecasting one airport and the contract was settling on another.
That's not a signal problem. That's a specification problem. And specification problems don't get better with more data.
The Right Way to Do This
Kalshi has a public metadata API. It tells you exactly what each contract settles on. There is no reason to guess.
Here's the call:
import requests
def get_market_metadata(ticker: str) -> dict:
"""
Fetch market metadata from Kalshi's public API.
No authentication required for market data.
"""
url = f"https://api.kalshi.com/trade-api/v2/markets/{ticker}"
headers = {"accept": "application/json"}
response = requests.get(url, headers=headers)
response.raise_for_status()
return response.json().get("market", {})
Call it for a Chicago temperature market and you get back something like this (abbreviated for clarity):
{
"ticker": "HIGHNY-26AUG12-T78",
"title": "Chicago high temperature on August 12",
"subtitle": "Will the high temperature in Chicago be above 78°F?",
"rules_primary": "This market will resolve Yes if the official high temperature recorded at Chicago Midway International Airport (KMDW) ...",
"settlement_source": "NOAA",
"category": "Climate and Weather"
}
The resolution rules are right there in rules_primary. KMDW. Not KORD.
If you want to pull this for a batch of markets and extract the station codes programmatically, here's how I do it:
import re
import requests
from typing import Optional
ICAO_PATTERN = re.compile(r'\b(K[A-Z]{3})\b')
def extract_settlement_station(ticker: str) -> Optional[str]:
"""
Pull the settlement ICAO station code from a Kalshi market's rules text.
Returns the first ICAO code found (K + 3 uppercase letters).
Returns None if no code is found or the request fails.
"""
url = f"https://api.kalshi.com/trade-api/v2/markets/{ticker}"
headers = {"accept": "application/json"}
try:
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
market = response.json().get("market", {})
rules_text = market.get("rules_primary", "")
match = ICAO_PATTERN.search(rules_text)
if match:
return match.group(1)
return None
except requests.RequestException as e:
print(f"Failed to fetch metadata for {ticker}: {e}")
return None
def verify_station_mapping(city_tickers: dict[str, str]) -> dict[str, str]:
"""
Given a dict of {city_name: example_ticker}, return {city_name: icao_code}.
Use one representative ticker per city to pull the settlement station.
"""
verified = {}
for city, ticker in city_tickers.items():
station = extract_settlement_station(ticker)
if station:
verified[city] = station
print(f"{city}: {station}")
else:
print(f"{city}: STATION NOT FOUND — review manually")
return verified
# Example usage
city_tickers = {
"Chicago": "HIGHCHI-26AUG12-T78",
"Houston": "HIGHHOU-26AUG12-T95",
"Atlanta": "HIGHATL-26AUG12-T88",
"New York": "HIGHNYC-26AUG12-T82",
}
station_map = verify_station_mapping(city_tickers)
You run this once per city, pin the results, and write a test that fails if the mapping ever changes. That's the entire fix.
The Full Station Correction List
After the Midway discovery, I went back and verified every city the bot trades. I wasn't going to find one wrong station and assume the rest were fine.
Here's what the audit turned up:
| City | Wrong Assumption | Correct Station | |------|-----------------|-----------------| | Chicago | KORD (O'Hare) | KMDW (Midway) | | Houston | KIAH (Bush Intercontinental) | KHOU (Hobby) | | New York | KJFK (JFK) | KNYC (Central Park) | | Los Angeles | KLAX (LAX) | KSMO (Santa Monica) |
New York was the one that surprised me most. Central Park. Not an airport at all. Kalshi settles New York temperature contracts against the NOAA station at Central Park, which is a legitimate official ASOS observation point but not the first place your brain goes when you think "New York weather station."
Los Angeles uses Santa Monica Municipal, not LAX. LAX is coastal and sits in a marine layer that pulls temps down. Santa Monica is close but reads differently enough to matter.
Every one of these was in the contract rules. I just hadn't read them.
Why This Is Hard to Notice
The frustrating thing about this class of bug is that it doesn't break anything. The bot runs. Trades fire. The database fills up. Everything looks healthy on the dashboard.
You're just forecasting the wrong location.
If KORD and KMDW read identically every day, you'd never know. But they don't. Chicago's microclimate gradient is real. The southwest side of the city runs warmer in summer than the northwest corridor near O'Hare, partly because of the urban heat island and partly because Midway is surrounded by dense residential neighborhoods while O'Hare has more open land. On a calm summer afternoon you can see 4°F separating them.
Four degrees Fahrenheit is the difference between a contract settling Yes and No on a tight market. If your model is forecasting KORD and the contract settles on KMDW, you are systematically mispriced in a way that has nothing to do with your forecasting model and everything to do with a wrong station code.
This is why the v2.1 audit looked at specification correctness as a separate concern from model accuracy. Wrong station isn't model error. It's infrastructure error. Fixing the model wouldn't have helped.
The Test That Pins This Forever
After I corrected the station map, I wrote a test that calls the Kalshi API for one representative ticker per city and asserts the extracted station code matches the expected value. The test runs on every CI push.
import pytest
from bot.kalshi_metadata import extract_settlement_station
# Pinned settlement stations, verified against Kalshi API on 2026-07-15
# Update these only when Kalshi publishes a formal change to contract specs
EXPECTED_STATIONS = {
"HIGHCHI-26AUG01-T78": "KMDW", # Chicago: Midway, NOT O'Hare
"HIGHHOU-26AUG01-T95": "KHOU", # Houston: Hobby, NOT Bush
"HIGHNYC-26AUG01-T82": "KNYC", # New York: Central Park
"HIGHATL-26AUG01-T88": "KATL", # Atlanta: Hartsfield (this one is right)
}
@pytest.mark.parametrize("ticker,expected_station", EXPECTED_STATIONS.items())
def test_settlement_station_mapping(ticker, expected_station):
"""
Verify that our station map matches Kalshi's actual contract settlement rules.
If this test fails, a contract spec has changed or our mapping is wrong.
Either way, stop and investigate before trading.
"""
actual_station = extract_settlement_station(ticker)
assert actual_station == expected_station, (
f"Station mismatch for {ticker}: "
f"expected {expected_station}, got {actual_station}. "
f"Verify against Kalshi contract rules before resuming."
)
Atlanta, for what it's worth, actually does use Hartsfield-Jackson. That one I had right. But I know it's right now because I checked, not because I assumed.
The Broader Principle
Every contract-settled instrument has a specification document. The specification tells you exactly what has to be true for the contract to resolve Yes or No. If you're building a system that trades these contracts automatically, you need to read the specification, extract the relevant parameters, and verify them programmatically.
Trusting your intuition about what "Chicago temperature" means is not a strategy. It's wishful thinking with a buy button attached.
The Kalshi metadata API is free, public, and doesn't require authentication for basic market data. There's no excuse for not using it. I didn't use it initially because I was moving fast and the assumption felt safe. Assumptions that feel safe are the ones that hurt you.
Read the contract. Verify the station. Pin it in a test. If Kalshi ever changes which airport a contract settles on, your test will catch it before your bot does.
The 5 minutes it takes to write that test is cheaper than finding the bug in a trade audit four months later.
The bot now has verified station mappings for every city it trades, all pulled directly from Kalshi's own metadata and locked in the test suite. It doesn't trust me to remember which airport is which. That's the right call. I clearly can't be trusted with that.
If you're building anything that settles against a specific physical measurement — temperature, precipitation, whatever — start with the contract specification, not your best guess. The specification is the ground truth. Everything else is noise.