Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

roll-yield

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.


What it found

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.


How the numbers are built

The curve

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 realised drag

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.


What this cannot do

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.


Running it

Two independent apps. The frontend talks to the backend over HTTP and shares nothing else with it.

Backend

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 src

API on http://127.0.0.1:8000, interactive docs at /docs.

pytest

128 tests, fully offline — the market data layer is injected, so nothing in the suite touches the network.

Frontend

cd frontend
npm install
npm run dev

On http://localhost:5173. If the backend is somewhere else, point at it:

VITE_API_BASE=http://127.0.0.1:8001 npm run dev

Layout

backend/
  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.


Data sources

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.

About

Futures contango/backwardation calculator: measures the roll cost buried inside commodity ETFs like USO and UNG, from free exchange data.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages