Every Wednesday at 10:30 a.m. Eastern, the US Energy Information Administration (EIA) publishes its Weekly Petroleum Status Report. Oil traders watch one number in it more than any other: the change in commercial crude oil inventories. A surprise draw can move crude a dollar or two in minutes.
The good news for developers is that the raw data is free and has a clean JSON API. In this post I'll pull the weekly series with Python, compute the week-over-week change, compare the current level against a five-year range, and plot it. The whole script is about 60 lines.
1. Get an API key
Register at the EIA open data page (it only asks for an email address) and you'll get a key by email in a minute or so. Keep it out of your code:
export EIA_API_KEY="your-key-here"
2. Know which series you want
EIA identifies each data series with an ID. The three I use most for crude stocks:
| Series ID | What it is | Unit |
|---|---|---|
WCESTUS1 |
US commercial crude stocks, excluding the SPR | thousand barrels |
WCSSTUS1 |
Strategic Petroleum Reserve stocks | thousand barrels |
W_EPC0_SAX_YCUOK_MBBL |
Cushing, Oklahoma stocks (the WTI delivery hub) | thousand barrels |
The headline "crude inventories rose 2.1 million barrels" number is the weekly change in WCESTUS1. Note the unit: the API returns thousands of barrels, so 2,100 in the data means 2.1 million barrels.
3. Pull the data
The v2 API organizes data by route. Weekly stocks live under petroleum/stoc/wstk:
import os
import requests
import pandas as pd
API = "https://api.eia.gov/v2/petroleum/stoc/wstk/data/"
def fetch_series(series_id: str, start: str = "2018-01-01") -> pd.Series:
params = {
"api_key": os.environ["EIA_API_KEY"],
"frequency": "weekly",
"data[0]": "value",
"facets[series][]": series_id,
"start": start,
"sort[0][column]": "period",
"sort[0][direction]": "asc",
"length": 5000,
}
r = requests.get(API, params=params, timeout=30)
r.raise_for_status()
rows = r.json()["response"]["data"]
df = pd.DataFrame(rows)
df["period"] = pd.to_datetime(df["period"])
df["value"] = pd.to_numeric(df["value"])
return df.set_index("period")["value"].rename(series_id)
stocks = fetch_series("WCESTUS1")
print(stocks.tail())
Two details that tripped me up the first time:
-
valuesometimes comes back as a string, so convert it withpd.to_numericbefore doing math. - The API caps a single response at 5,000 rows. Weekly data since 2018 is only around 400 rows, so one call is plenty, but if you pull daily series you'll need to page with the
offsetparameter.
4. Week-over-week change
change = stocks.diff() # thousand barrels
latest = stocks.index[-1]
print(f"Week ending {latest:%Y-%m-%d}: "
f"{stocks.iloc[-1]/1000:,.1f}M bbl, "
f"change {change.iloc[-1]/1000:+.2f}M bbl")
That last number is what headlines report. On its own it's noisy: refinery runs, imports and exports swing a lot from week to week, so a single draw or build rarely means much. That's why the next step matters more.
5. Compare against the five-year range
Inventories are seasonal. Stocks usually build in early spring, when refineries go into maintenance, and draw in summer during driving season. So "is 420 million barrels a lot?" depends on the week of the year. The standard answer is to compare the current week against the min, max and average for the same week over the previous five years.
df = stocks.to_frame("stocks")
df["year"] = df.index.isocalendar().year
df["week"] = df.index.isocalendar().week
this_year = df["year"].max()
hist = df[(df["year"] >= this_year - 5) & (df["year"] < this_year)]
band = hist.groupby("week")["stocks"].agg(["min", "max", "mean"])
current = df[df["year"] == this_year].set_index("week")["stocks"]
compare = band.join(current.rename("current"), how="left")
compare["vs_avg_pct"] = (compare["current"] / compare["mean"] - 1) * 100
print(compare.dropna().tail(4).round(1))
A reading a few percent below the five-year average for the same week usually goes with a tighter market and firmer prices. A reading near the top of the band suggests the opposite. It's not a trading signal by itself, but it puts the weekly change in context.
One caveat: week 53 only shows up in some years, so the band for that week will be thin. I just drop it.
6. Plot it
import matplotlib.pyplot as plt
ax = compare[["min", "max"]].plot(color="lightgray", legend=False)
ax.fill_between(compare.index, compare["min"], compare["max"],
color="lightgray", alpha=0.5, label="5-year range")
compare["mean"].plot(ax=ax, linestyle="--", color="gray", label="5-year avg")
compare["current"].plot(ax=ax, color="black", linewidth=2, label=str(this_year))
ax.set_xlabel("ISO week")
ax.set_ylabel("Thousand barrels")
ax.set_title("US commercial crude inventories vs 5-year range")
ax.legend()
plt.tight_layout()
plt.savefig("crude_stocks.png", dpi=150)
If you just want to eyeball the latest numbers without running anything, I cross-check my output against the inventory charts on CommoditiesChart, which plot the same EIA weekly series alongside production and refinery data. It's a quick way to catch a unit mistake (thousands vs millions of barrels is the classic one).
7. Add Cushing and the SPR
Swap in the other series IDs to see the rest of the picture:
cushing = fetch_series("W_EPC0_SAX_YCUOK_MBBL")
spr = fetch_series("WCSSTUS1")
combined = pd.concat([stocks, cushing, spr], axis=1).dropna()
combined.columns = ["commercial", "cushing", "spr"]
print((combined.iloc[-1] / 1000).round(1)) # million barrels
Cushing matters because it's the delivery point for the NYMEX WTI contract. When Cushing stocks get very low, the front of the futures curve tends to tighten even if national stocks look comfortable. The SPR is a policy lever rather than a market signal, so I track it separately and don't add it to commercial stocks.
Things to keep in mind
- Release timing. Data is for the week ending the previous Friday. Holidays push the release back by a day.
- API vs EIA. The American Petroleum Institute publishes its own estimate on Tuesday evenings. It's a survey and often differs from EIA's number, so don't mix the two in one series.
- Revisions. Weekly figures are estimates. The monthly Petroleum Supply Monthly can revise them later, so treat old weekly values as provisional.
- Cache your pulls. Save the DataFrame to Parquet after each fetch so you're not hitting the API on every notebook run.
Wrap-up
With one free API key and pandas you can rebuild the inventory chart that oil desks look at every Wednesday: the weekly change, the seasonal five-year band and the Cushing hub level. From here it's easy to add gasoline (WGTSTUS1) and distillate (WDISTUS1) stocks, which use the same route and the same code.
If you build something on top of this, I'd like to hear what series you ended up tracking.
Top comments (0)