Expiry and the roll
Five things get called expiry. Only three need machinery, and none of them may break a market.
Most pairables are perpetual, and that is the right default: you do not want a
market's denominator to run out from under it. All 111 entries in
pairables/seed.json carry expiresAt: 0 today.
Dated pairables are for things whose definition expires rather than whose feed
goes quiet: a futures contract month, a crop year, a valuation "as of" some
tender. The crop-year pattern (PEAR-CY26 onto PEAR-CY27) is exercised in
tests/peard.ts rather than seeded, because shipping a pairable that expires
in 2027 with a successor nobody maintains is a liability rather than a feature.
Five things get called expiry
| What it is | Handling | |
|---|---|---|
| Feed staleness | hours; the feed should be updating and is not | built; temporary, blocks settlement, resumes |
| Contract expiry | the instrument itself ceases | front-month resolution, or Roll |
| Reference expiry | the crop year, the tender | expires_at + Settle / Roll |
| Feed sunset | the series is discontinued for good | settle at the last print, then governance |
| Request expiry | an unfulfilled shipment | expire_fulfillment, permissionless after a TTL |
Expiry never breaks a market
Past expires_at, push_price is refused. Refusing every later push is exactly
what makes the last TWAP the final print, so there is no separate field for it.
pub fn is_stale(&self, now: i64) -> bool {
if self.params.max_price_age_secs == 0 || self.is_expired(now) {
return false;
}
self.price_last_ts == 0 || now - self.price_last_ts > self.params.max_price_age_secs as i64
}
An expired price is final, not stale. is_stale returns false past expiry,
because ageing out a settlement value would break every market quoted against it
for no reason. Accrual, claims, coverage and fulfilment all carry on, and a
settled market simply degrades into a fixed-conversion dollar market wearing a
historical label.
The roll is value-neutral at the instant it happens
Roll names a successor, and roll_market is permissionless, because the
whole thing is determined by the two pairables. There is nothing to decide and
no reason to make holders wait on an operator.
Alice owed 10 units of PEAR-CY26, final print $0.60 -> $6.00
PEAR-CY27 opens at $0.75
factor = 0.60 / 0.75 = 0.8
Alice now owed 8 units -> $6.00 unchanged
Coverage cannot move either, because units * price is invariant across the
change: both sides of the ratio scale together.
What does change is what a unit means from here on, which is the entire point
of a roll and a thing the UI must say out loud rather than bury. The
RolledOver event carries both prices and the factor so the discontinuity is on
the record.
It is one number, applied lazily
Mechanically it is one u128 on the market plus a snapshot on each position,
applied in accrue(). This is the same trick floorlaunch uses for
funding_index to reprice every CDP with one number, and the same trick
peard_perps uses for cumulative_funding. A roll is O(1) rather than a walk over
every holder.
Both units_owed and acc_snapshot move, because a snapshot left in the old
denomination would credit the difference as if it were newly earned.
The guards
The successor must be live, unfrozen, priced, fresh and not itself expired, and
the factor must sit inside MAX_ROLL_FACTOR of 100x either way.
A manipulated successor price cannot actually steal anything, since
owed = units * price is invariant, but it would change what holders are
exposed to next, so it stops for a human.
Rolling a futures reference
A front-month series is discontinuous by construction. The day the reference switches, the printed price jumps by the calendar spread, and that jump is not a market move: it trips the breaker, draws a candle nobody traded, and settles anything quoted against it at the wrong number. WTI sat at a $2.62 spread between September and October while this was written, so the naive switch prints +3.1% out of nowhere.
relayer/src/roll.ts blends instead of switching, on the S&P GSCI window:
business days 5 to 9 of the month, 80/20 through 0/100. Pure and offline-tested,
because the interesting failures are arithmetic and calendar.
The reference month comes from the calendar, not from whichever contract is
nearest and still publishing. That distinction was a live bug:
pythFrontMonth("WTI") was returning WTIQ6 32 days after it settled,
because an expired contract keeps publishing a frozen last print with a
perfectly valid publish_time, and the old check only tested that the
timestamp was nonzero. With maxSourceAgeSecs disabled by default, that would
have gone on chain as a live oil price. Contracts are now rejected past four
days, which survives a long weekend and nothing more.
Continuity across the month boundary is the property worth testing, and it is: the last day of August is fully October, and the first day of September is October again at full weight, with no step.
Only WTI can actually use this today
Checked against Hermes rather than assumed:
| Pyth strip | Rollable | |
|---|---|---|
| WTI | Commodities.WTI{U,V,X}6 live | yes |
| Brent | BRENTH6 frozen since Jan, everything later 0.000 | no |
| Nat gas | Commodities.NGD*, 7 contracts, all dead | no |
| Copper | Commodities.CC*, 10 contracts, all dead | no |
Copper's ticker is CC, not HG, and a full 2026 strip does exist in
Pyth's catalog. It publishes nothing. That is worth stating precisely, because
"the feed does not exist" and "the feed exists and is dead" fail identically
today but not tomorrow: the second one starts working the day Pyth turns it on,
with no change here.
Copper and nat gas are sourced from FRED and EIA cash series, which have no contract months to roll between. The roll is written generically, so all three pick it up if those strips ever come alive.
The business-day count
relayer/src/holidays.ts computes US futures market closures rather than
listing them, because a pasted table of dates silently expires. Weekday-rule
holidays are derived, fixed-date ones shift to the nearest weekday, and Good
Friday comes from the Gregorian Easter algorithm since it is the one closure
with neither a fixed date nor a weekday rule.
Holidays matter even when they fall outside the window, which is the non-obvious part: one landing before it shifts every later count by a day, so the blend would run out of step with the exchange for that entire roll. January is the clearest case, where New Year's Day pushes the whole month.
The edge case with teeth is a New Year's Day that observes backwards. 1 January 2028 is a Saturday, so the market closes on Friday 31 December 2027, and that closure belongs to 2027's calendar. Getting it wrong drops a day from both years at once. 2027 therefore has eleven closures and 2028 has nine, and both are asserted in the tests.
Perps settle rather than trap
A perpetual on something that stopped existing is a contradiction. Without a plan the market simply halts: the index ages out, every path that needs it refuses, and collateral is stuck forever.
So peard_perps has settle_market. Once the pairable is past its expiry, the
market settles at the underlying's final print, which is final by construction
rather than by decree, because the registry refuses pushes past expiry. Everyone
leaves at the same number, nobody races the curve on the way out, and there is
no claim deadline to miss.
peard