Measures the roll cost buried inside commodity ETFs.
A fund that tracks a commodity with futures cannot simply hold them. Contracts expire, so every month it sells what it owns and buys the next contract out. When the next one costs more than the one being sold, that difference is a real loss, and it repeats every month whether the commodity goes up, down, or nowhere at all.
Most people holding USO or UNG have never seen that number. This makes it visible, from live exchange data, with the arithmetic shown in place.
roll_yield = (front_price - back_price) / front_price * (365 / days_between_expiries)
Positive means backwardation — the roll is a tailwind. Negative means contango — the roll is a drag.
Measured over the five years to 2026-08-28, annualised:
| Fund | Backing | Fund vs. front month | Collateral | Fee | Implied roll + tracking |
|---|---|---|---|---|---|
| UNG (nat gas) | futures | −23.92% | +3.69% | −1.17% | −26.43% |
| USO (crude) | futures | +17.34% | +3.69% | −0.86% | +14.52% |
| CPER (copper) | futures | −0.63% | +3.69% | −0.88% | −3.43% |
| GLD (gold) | physical | −0.73% | 0.00% | −0.40% | −0.33% |
| SLV (silver) | physical | −0.96% | 0.00% | −0.50% | −0.46% |
Two things in that table are worth more than the headline.
The tool is not just "ETFs are bad." USO beat front-month crude by 17%/yr, because oil has spent most of this window in backwardation. Roll yield is a two-way street, and a tool that only ever reported a cost would be measuring its own assumptions.
The bottom two rows are a control group. GLD and SLV hold metal in a vault and never roll a futures contract, so their implied roll term should be approximately zero. It comes out at −0.33% and −0.46%/yr, against −26.43% for UNG. That is the check that makes the other numbers believable: a method that reported a large roll cost for GLD would be broken, and this is how you would catch it.
Curve shape cannot be read from a continuous series like CL=F — you need two
points on the curve at the same instant, which means pricing individual delivery
months (CLV26.NYM, CLX26.NYM, …). Expiry spacing is the denominator of the
formula, so it uses the real exchange termination date published per contract
rather than an approximation. A rule-based estimator exists as a fallback, and
every contract reports which source its expiry came from.
The forward curve says what the roll costs today. The comparison view answers what a holder actually cares about: over the last N years, how far did the fund drift from the commodity, and why?
Two observable series, aligned on common trading days:
- spot proxy — the unadjusted front-month contract. Not back-adjusted, so it does not absorb the roll gaps. That is the point: the gap it refuses to carry is the gap the fund is forced to pay.
- fund — the ETF's adjusted close, i.e. total return, with fees already inside it because they come out of NAV daily.
The gap is compounded, not subtracted — (1+fund)/(1+spot) - 1 — then
annualised and decomposed against the identity:
excess = roll_yield + collateral_yield - expense_ratio + tracking_error
collateral_yield and expense_ratio are observable, so the remainder is
solved for. Roll and tracking error cannot be separated with free end-of-day
data, and the field is named implied_roll_and_tracking because of it.
The collateral term is the one most write-ups omit: a futures-backed fund posts T-bills against margin and earns interest on them, worth ~3.7%/yr in this window. Ignore it and you overstate the roll cost by that much.
There is no historical curve-slope chart, and that is a data limitation, not
an oversight. Reconstructing the front/next slope on a past date needs prices
for the contracts that were front and next on that date — contracts that have
since expired. Yahoo delists expired contracts and drops their history within
months (verified: CLM25.NYM and CLZ25.NYM both return empty). The
currently-listed contracts do carry years of history, but on a past date every
one of them was far from expiry, so their slope measures the deferred curve,
which is a different quantity and would be misleading under a "roll yield"
label.
The options were: substitute deferred-month slopes (wrong), synthesise the series (forbidden), or measure realised drag from series that do exist and say so plainly. This does the third. Closing the gap properly means ingesting CME settlement files into a local archive that accumulates over time.
The other limitations — the spot proxy's splice artefacts, holiday gaps in the
fallback expiry rule, funds that don't hold the front month — are listed in
backend/API.md and rendered in the UI itself, served from
/api/methodology so the caveats a user reads cannot drift from the code that
produced the numbers.
This is a measurement tool, not a trading signal. A curve slope observed today says nothing about where prices go next.
Two independent apps. The frontend talks to the backend over HTTP and shares nothing else with it.
cd backend
python -m venv .venv
.venv/Scripts/activate # Windows; use source .venv/bin/activate on macOS/Linux
pip install -e ".[dev]"
uvicorn rollyield.api:app --reload --app-dir srcAPI on http://127.0.0.1:8000, interactive docs at /docs.
pytest128 tests, fully offline — the market data layer is injected, so nothing in the suite touches the network.
cd frontend
npm install
npm run devOn http://localhost:5173. If the backend is somewhere else, point at it:
VITE_API_BASE=http://127.0.0.1:8001 npm run devbackend/
src/rollyield/
rollmath.py pure math, no I/O — the sign convention lives here
universe.py commodity + ETF definitions (declarative, nothing derived)
contracts.py symbol construction, expiry rules
market.py the only module that imports yfinance
cache.py TTL file cache
curve.py forward curve assembly
drag.py realised ETF vs. spot decomposition
methodology.py formulas + limitations, served to the UI
api.py FastAPI routes
tests/ 128 tests, offline
API.md the API contract
frontend/
src/api/ typed client mirroring API.md
src/components/ curve chart, drag chart, decomposition, methodology
src/lib/format.ts the one place decimals become percentages
rollmath.py is deliberately free of I/O and pandas. It is the single place the
sign convention lives, and its tests assert against hand-computable values
written as explicit arithmetic rather than decimals copied from a previous run —
because a flipped sign there would invert the meaning of the entire project.
All free. No paid vendors, no API keys, nothing behind an auth wall.
Yahoo Finance via yfinance for individual contract prices, contract expiry
metadata, continuous front-month series, ETF adjusted closes, published expense
ratios, and ^IRX for the T-bill collateral proxy. CME Group contract
specifications for delivery cycles and the fallback expiry rules.
Expense ratios are fetched rather than hard-coded, so no fee in the UI is a stale constant someone has to remember to update.
Nothing here is investment advice.