How I Pull Free GFS Ensemble Data with the Open-Meteo API in Python
TL;DR / Key Takeaways
- Open-Meteo's ensemble API is free, requires no authentication, and serves GFS HGEFS data with up to 62 ensemble members per location per forecast cycle — usable directly in production.
- The API returns per-member temperature arrays indexed by time step. Spreading across members requires a transpose operation before any statistical calculation happens.
- Ensemble spread (standard deviation across members) is the primary signal for whether a market is worth scoring at all. Wide spread means high uncertainty. High uncertainty means the ensemble disagrees — and disagreement is where mispricing lives.
- Treat the API response as untrusted data. Validate member count, timestamp alignment, and value ranges before the output touches your scoring engine.
The entire edge detection model in Predict & Profit rests on one data source: GFS ensemble model output. Specifically, it rests on having all 31 to 62 ensemble members for a given location, not just the ensemble mean. The mean is what you see on a weather forecast. The individual members are where the information lives.
Getting that data for free, reliably, in Python, without managing any GRIB2 downloads yourself — that is what Open-Meteo solves.
What Open-Meteo Is
Open-Meteo is an open-source weather API project funded by Copernicus, the EU's earth observation program. It serves processed numerical weather prediction data — including GFS, ECMWF, and several regional models — via a REST API with no API key required for non-commercial use.
For commercial use, their pricing is reasonable and the data quality is identical. I run the bot commercially and pay for the plan that removes rate limits.
The ensemble endpoint is the relevant one. It gives you per-member forecast data rather than the blended mean output you get from the standard forecast endpoint.
The API Structure
A basic ensemble request for Memphis, Tennessee looks like this:
# predictandprofit.io
import httpx
BASE_URL = "https://ensemble-api.open-meteo.com/v1/ensemble"
def build_ensemble_params(lat: float, lon: float, forecast_days: int = 3) -> dict:
return {
"latitude": lat,
"longitude": lon,
"hourly": "temperature_2m",
"models": "gfs_seamless",
"forecast_days": forecast_days,
}
def fetch_ensemble(lat: float, lon: float) -> dict:
params = build_ensemble_params(lat, lon)
response = httpx.get(BASE_URL, params=params, timeout=30.0)
response.raise_for_status()
return response.json()
The response shape is worth understanding before you write any parsing logic. At the top level you get hourly, which is a dictionary. Inside it, the keys are structured as temperature_2m_member{N} where N is the member index starting at zero. With the GFS HGEFS model enabled, you get members 0 through 30 for 31-member output, or 0 through 61 for 62-member output depending on the model configuration.
{
"latitude": 35.1495,
"longitude": -90.0490,
"hourly": {
"time": ["2026-05-05T00:00", "2026-05-05T01:00", ...],
"temperature_2m_member00": [18.2, 18.0, 17.8, ...],
"temperature_2m_member01": [18.5, 18.3, 18.1, ...],
"temperature_2m_member02": [17.9, 17.7, 17.5, ...],
...
"temperature_2m_member30": [19.1, 18.9, 18.7, ...]
}
}
Each member array has one value per hour. For a 3-day forecast at hourly resolution, that is 72 values per member. 31 members means 31 arrays of 72 values each. The parsing job is to reshape that into a structure your scoring functions can actually use.
Parsing and Reshaping
The raw response is member-first: you have one array per member. For most calculations you want time-first: for each hour, what did all members forecast?
# predictandprofit.io
import re
import numpy as np
from typing import Optional
def parse_ensemble_response(data: dict, variable: str = "temperature_2m") -> dict:
"""
Extract ensemble members from Open-Meteo response.
Returns a dict with 'times' and 'members' (shape: n_times x n_members).
"""
hourly = data.get("hourly")
if not hourly:
raise ValueError("Response missing 'hourly' key — check model availability")
times = hourly.get("time")
if not times:
raise ValueError("Response missing time array")
member_pattern = re.compile(rf"^{re.escape(variable)}_member(\d+)$")
members_raw = {}
for key, values in hourly.items():
match = member_pattern.match(key)
if match:
member_index = int(match.group(1))
members_raw[member_index] = values
if not members_raw:
raise ValueError(f"No ensemble members found for variable '{variable}'")
n_members = max(members_raw.keys()) + 1
n_times = len(times)
# Build (n_times x n_members) array
member_matrix = np.full((n_times, n_members), np.nan)
for idx, values in members_raw.items():
if len(values) != n_times:
raise ValueError(f"Member {idx} has {len(values)} values, expected {n_times}")
member_matrix[:, idx] = values
return {
"times": times,
"members": member_matrix,
"n_members": n_members,
"n_times": n_times,
}
The regex approach handles member indices of any width (member00, member01, ..., member61) without hardcoding. The explicit NaN fill on the matrix construction means that if a member is missing from the response — which occasionally happens — the gap is visible rather than silently shifting your member indices.
Calculating Spread, Mean, and Confidence
With the time-first matrix, the calculations are straightforward.
# predictandprofit.io
def calculate_ensemble_stats(
member_matrix: np.ndarray,
target_temp_c: float,
direction: str, # "above" or "below"
) -> dict:
"""
Calculate ensemble statistics for a Kalshi temperature market.
target_temp_c: the temperature threshold in the market contract
direction: whether the market resolves YES if temp is above or below threshold
"""
# Per-time-step statistics across all members
ensemble_mean = np.nanmean(member_matrix, axis=1)
ensemble_spread = np.nanstd(member_matrix, axis=1)
# Fraction of members predicting the resolution direction
if direction == "above":
member_votes = (member_matrix > target_temp_c).astype(float)
elif direction == "below":
member_votes = (member_matrix < target_temp_c).astype(float)
else:
raise ValueError(f"Unknown direction: {direction}")
ensemble_probability = np.nanmean(member_votes, axis=1)
# Confidence: how far is the probability from 50/50?
ensemble_confidence = np.abs(ensemble_probability - 0.5)
return {
"mean": ensemble_mean,
"spread": ensemble_spread,
"probability": ensemble_probability,
"confidence": ensemble_confidence,
}
These four arrays are the inputs to the edge scoring pipeline. Spread and confidence are the two most important. Spread tells you whether the ensemble is in agreement. Confidence tells you whether the agreement favors one side. You want both high: the ensemble agrees, and it agrees strongly in one direction.
The relationship between them is not always obvious. High spread with high confidence sounds contradictory, but it happens when the members disagree on magnitude but not direction. Eleven members might forecast Memphis at 94°F and twenty members might forecast it at 97°F, but all 31 are above the 90°F market threshold. That is a high-spread, high-confidence signal. The market at 0.78 for YES looks reasonable. The bot probably agrees.
Targeting the Right Forecast Hour
Kalshi temperature markets settle on a single daily high or low measurement. That means you do not want to average the ensemble stats across all 72 hours. You want the stats at the specific hour or window the market references.
The resolution time for each market is available from the Kalshi API in the market details. I match it against the Open-Meteo time array to pull the correct hour window.
# predictandprofit.io
from datetime import datetime, timezone
def get_target_hour_index(times: list[str], target_dt: datetime) -> Optional[int]:
"""
Find the index in the Open-Meteo time array closest to the target datetime.
Times are ISO strings in UTC. target_dt must be UTC-aware.
"""
target_str = target_dt.strftime("%Y-%m-%dT%H:%M")
try:
return times.index(target_str)
except ValueError:
# Find nearest if exact match is unavailable
parsed = [datetime.fromisoformat(t).replace(tzinfo=timezone.utc) for t in times]
deltas = [abs((dt - target_dt).total_seconds()) for dt in parsed]
nearest_idx = deltas.index(min(deltas))
if deltas[nearest_idx] > 3600:
return None # more than one hour off — do not use
return nearest_idx
Returning None when the nearest timestep is more than an hour away from the target is a deliberate choice. If the forecast cycle has not been updated yet or the API is returning stale data, it is better to skip the market than to score it on the wrong hour's ensemble output.
Retry Logic and Caching
The Open-Meteo API is generally reliable but not immune to transient failures. For a bot that runs on a schedule every six hours, a single retry with exponential backoff is sufficient.
# predictandprofit.io
import time
import logging
logger = logging.getLogger(__name__)
def fetch_ensemble_with_retry(
lat: float,
lon: float,
max_attempts: int = 3,
base_delay: float = 2.0,
) -> dict:
last_error = None
for attempt in range(max_attempts):
try:
return fetch_ensemble(lat, lon)
except httpx.HTTPStatusError as e:
last_error = e
if e.response.status_code == 429:
# Rate limited — wait longer
delay = base_delay * (4 ** attempt)
else:
delay = base_delay * (2 ** attempt)
logger.warning(f"Open-Meteo attempt {attempt + 1} failed: {e}. Retrying in {delay}s")
time.sleep(delay)
except httpx.RequestError as e:
last_error = e
delay = base_delay * (2 ** attempt)
logger.warning(f"Open-Meteo network error attempt {attempt + 1}: {e}. Retrying in {delay}s")
time.sleep(delay)
raise RuntimeError(f"Open-Meteo fetch failed after {max_attempts} attempts") from last_error
For caching I use a simple dict keyed on (lat, lon, forecast_cycle_hour). Each GFS run covers a six-hour window. Within a single scoring pass, I may need ensemble data for a dozen cities. Without a cache, that is twelve API calls. With the cache, the first city fetch populates it and the remaining eleven hit the local dict. The cache is not persisted between bot runs — it only exists for the duration of a single scoring cycle.
Validation Before Scoring
One thing I learned the hard way: validate the response before you score anything from it. Specifically:
# predictandprofit.io
def validate_ensemble_output(parsed: dict, min_members: int = 25) -> None:
"""
Raise if the ensemble data looks wrong before it reaches the scoring engine.
"""
if parsed["n_members"] < min_members:
raise ValueError(
f"Only {parsed['n_members']} ensemble members returned, need at least {min_members}"
)
nan_fraction = np.isnan(parsed["members"]).mean()
if nan_fraction > 0.05:
raise ValueError(
f"Ensemble matrix is {nan_fraction:.1%} NaN — data quality problem"
)
# Temperature sanity check (degrees Celsius)
valid_values = parsed["members"][~np.isnan(parsed["members"])]
if valid_values.min() < -60 or valid_values.max() > 60:
raise ValueError(
f"Ensemble temperatures out of physical range: "
f"min={valid_values.min():.1f}°C, max={valid_values.max():.1f}°C"
)
The member count check matters because Open-Meteo occasionally returns fewer members than expected during model transition periods. Scoring on 12 members instead of 31 with a threshold calibrated for 31 members silently degrades your edge detection. The NaN check catches partial data delivery. The temperature range check sounds paranoid but has caught unit conversion errors during testing where Kelvin values leaked into the Celsius arrays.
Why This Data Source Works
The practical advantages of Open-Meteo over direct NCEP GRIB2 downloads come down to three things: it handles the file fetching, format conversion, and temporal alignment for you. Parsing raw GRIB2 files requires cfgrib or eccodes, dealing with coordinate reference systems, and managing the download of multi-gigabyte files per run. Open-Meteo does all of that on their end and serves clean JSON. For an individual bot operator, that trade is correct.
The data is the same underlying GFS model data. It is just more accessible.
The limitation worth knowing: the ensemble API endpoint uses the GFS seamless model, which blends GFS and GEFS outputs. For most temperature markets this is exactly what you want. If you need specific model cycle control or ECMWF ensemble access, you will need to look at their commercial tier or supplement with direct model downloads.
The full data pipeline — Open-Meteo integration, ensemble scoring, Kalshi API connection, and trade execution — is part of the Predict & Profit source code.