peard
The registry side

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 isHandling
Feed stalenesshours; the feed should be updating and is notbuilt; temporary, blocks settlement, resumes
Contract expirythe instrument itself ceasesfront-month resolution, or Roll
Reference expirythe crop year, the tenderexpires_at + Settle / Roll
Feed sunsetthe series is discontinued for goodsettle at the last print, then governance
Request expiryan unfulfilled shipmentexpire_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 stripRollable
WTICommodities.WTI{U,V,X}6 liveyes
BrentBRENTH6 frozen since Jan, everything later 0.000no
Nat gasCommodities.NGD*, 7 contracts, all deadno
CopperCommodities.CC*, 10 contracts, all deadno

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.