Oracle and MarketCalendar

Documentation snapshot 18 October 2026. Deployment · Audit Status
On this page

The calendar identifies an effective market close; the oracle selects a qualifying price for it. Unavailable or conflicting prices can defer an observation and delay settlement.

Dependencies

fact The protocol depends on exactly three external contract types: the Stock Token ERC-20s, one Chainlink-style AggregatorV3Interface per underlying (optionally a backup), and USDG. There are no other protocol integrations in v1. Two oracle sources exist on Robinhood Chain and both are supported: Chainlink Data Feeds (push, the primary per underlying) and Pyth Pro (pull, wrapped by PythProFeed into the same aggregator interface and used as the backup).

MarketCalendar

MarketCalendar is a governed contract answering one question: is a timestamp exactly the official close of a US equity trading day?

fact MarketCalendar
RuleValue
Regular close20:00 UTC during US daylight saving time; 21:00 UTC otherwise (16:00 New York)
DaysMonday to Friday
HolidaysRegistered by governance per year: setHoliday(dayIndex, bool)
Early closesRegistered per year: setEarlyClose(dayIndex, closeTs), typically 17:00 UTC (DST) or 18:00 UTC (13:00 New York)
DST startSecond Sunday in March, computed on-chain by a pure function
DST endFirst Sunday in November

fact MarketCalendar Calculation

isOfficialClose(ts) = officialCloseOf(ts / 86400) == ts officialCloseOf(day) = 0 if weekend or isHoliday(day) = earlyClose(day) if registered = day × 86400 + 20h if isDST(day) = day × 86400 + 21h otherwise

DST boundaries for reference (US rule): 2026: 8 Mar to 1 Nov. 2027: 14 Mar to 7 Nov. 2028: 12 Mar to 5 Nov. 2029: 11 Mar to 4 Nov. 2030: 10 Mar to 3 Nov. Unit tests pin these dates.

Market Hours

The calendar also answers whether the market is open at a timestamp and where the surrounding closes are. The regular session opens at 13:30 UTC during US daylight saving time and 14:30 UTC otherwise (09:30 New York); an early-close day still opens at the regular time.

fact Market Hours Reference
ViewMeaning
sessionOpenOf(day)Regular open of day, 0 when it is not a trading day
isTradingDay(day)officialCloseOf(day) != 0
lastCloseAtOrBefore(ts)Most recent official close at or before ts, walking back at most 14 calendar days
nextCloseAfter(ts)First official close strictly after ts, walking forward at most 14 calendar days
marketState(ts)(open, lastClose, nextOpen, nextClose): open is true between the session open (inclusive) and the official close (exclusive) of a trading day; nextOpen is the open of the session that ends at nextClose, or the following session while the market is open

Both walks revert NoTradingDayInRange(ts) when no trading day exists within 14 days, which cannot happen on the US calendar unless many consecutive weekdays are registered as holidays.

fact A series records official-close timestamps at creation. A later holiday registration can move an unresolved observation to the next official close, within eight calendar days and strictly before the next scheduled observation’s day. An early close on the same day uses that earlier time. If no eligible close exists inside those bounds, the scheduled timestamp remains and the deferral path applies. See Following Business Day.

OracleAdapter

observe(feed, closeTs) -> (price, ok) implements the read. Round selection uses price timestamps and the configured bounded lookup. Once resolved, the price is pinned; before resolution, a late call can exceed the lookup bound and defer.

  1. The call is accepted only once the close grace has elapsed: observe reverts TooEarly before closeTs and GraceNotElapsed until closeTs + closeGrace has passed (resolvableAt(closeTs) = closeTs + closeGrace + 1 is the first accepted second). Posted-round feeds must have submitted a qualifying print by the grace deadline.
  2. Call latestRoundData. Walk back rounds with getRoundData while the round’s updatedAt is later than closeTs, for at most maxRoundWalk rounds. For feeds that expose postedAt(roundId) (IPostedRoundFeed, implemented by PythProFeed) the walk also skips rounds posted on chain after closeTs + closeGrace, so a print appended after the grace end is walked over even if it carries an earlier publish time. Support for postedAt is detected once on the latest round. Chainlink proxy round ids are (phaseId << 64) | aggregatorRoundId; when the walk reaches aggregator round 1 of a phase above the first, it continues from latestRound() of phaseAggregators(phaseId − 1), so a proxy phase change does not cut the history. The last round whose price time is at or before closeTs is the candidate: a print stamped after the close, whether an after-hours print or the next session’s first print, is never the close.
  3. Require updatedAt >= closeTs − maxPreCloseLag, a non-zero timestamp, a positive answer and answeredInRound >= roundId. At least one selected round must also be within closingFreshness of the close. A round beyond the walk bound is unknown, not evidence that a feed is absent.
  4. If a backup is configured, resolve both feeds. If either walk is cut short, defer. When both return valid prices, they must agree within maxDeviationBps before freshness chooses the pinned price. The backup replaces an older primary only when it is fresh and inside the closing-print window; freshness does not bypass disagreement.
  5. If no qualifying closing-fresh price exists, defer. Anyone may retry. A temporarily unreachable feed can recover; a missing eligible print or an exhausted lookup bound can require the documented governance fallback.
  6. After deferralWindow, governance may proposeForceObserve(feed, closeTs, price) (event ForceObserveProposed), executable by anyone via executeForceObserve after forceDelay, revocable by owner or guardian in the meantime (revokeForceObserve); or cancelObservation(feed, closeTs). executeForceObserve first retries the natural read: if the feeds can resolve the close by then, the observation is pinned at the natural price and the proposed price is discarded (ForceObserveSuperseded); only otherwise is the proposed price used. A successful read or force emits ObservationResolved.
fact OracleAdapter Reference
ParameterDefaultRoleLabel
closeGrace5 minutes (hard maximum 1 hour, must be below settlementWindow)Posting deadline: a PythProFeed round must be on chain by closeTs + closeGrace, and observe waits until thenfact
maxPreCloseLag30 minutes (1 second to 24 hours)Oldest acceptable closing print relative to the closefact
settlementWindow2 hoursKeeper deadline for off-chain operations; does not affect which round is selectedfact
maxLookback26 hoursStaleness bound for the reference price when no calendar is configured on the adapterfact
liveMaxAge1 hourA print at most this old is a live reference price (setLiveMaxAge)fact
MAX_CLOSED_GAP7 days (constant)When the market has been closed for longer than this and no live print exists, the reference price is unavailablefact
maxDeviationBps300 (3%)Primary/backup disagreement threshold. The comparison is abs(primary − backup) > primary × maxDeviationBps / 10 000, evaluated with full-width multiplication, so an absurd answer on either feed (up to the full int256 range) produces a deferral rather than an arithmetic failurefact
deferralWindow72 hours (hard minimum MIN_DEFERRAL_WINDOW = 24 hours)Earliest governance force or cancel after a deferral. setParams rejects a shorter window, so a deferred observation always has at least one full day in which the feeds can still resolve naturally before a forced or cancelled outcome becomes possiblefact
forceDelay24 hours (hard minimum 24 hours)Delay between a force proposal and its executionfact
maxRoundWalk64 rounds (hard maximum MAX_ROUND_WALK_LIMIT = 500)Bound on the walk back from the latest roundfact
freshLag15 minutes in the contract, 6 hours in the deploy configuration (1 second to below maxPreCloseLag)A closing print at most this far before the close is fresh; when exactly one of primary/backup is fresh it is used and no deferral occurs. Set after the close window, since it must stay below maxPreCloseLagfact

closeGrace and maxPreCloseLag are set together by setCloseWindow(closeGrace, maxPreCloseLag) (owner only, CloseWindowUpdated); the other seven by setParams(settlementWindow, maxLookback, maxDeviationBps, deferralWindow, forceDelay, maxRoundWalk, freshLag) (owner only, ParamsUpdated). Backup feeds are registered with setBackupFeed(primary, backup); a backup equal to the primary reverts SameFeed, and a backup whose decimals() differs from the primary’s reverts DecimalsMismatch(primaryDecimals, backupDecimals), so the deviation check always compares like with like.

Every feed read (latestRoundData, getRoundData, postedAt, phaseAggregators, latestRound) is a guarded static call whose return data is length-checked, so a feed that reverts, returns malformed data or has no code never makes observe, peek or readAt revert; the read simply yields no result and the round walk ends with ok == false.

Oracle observation window around the official close closeTs − maxPreCloseLag (30 min) closeTs (official close) closeTs + closeGrace (5 min) closing print never selected The last round updated no later than closeTs, and no earlier than closeTs − maxPreCloseLag, is the observed price.
fact Round selection. The observed price is the last round stamped at or before closeTs (for a Pyth round, posted by closeTs + closeGrace); later price timestamps are not eligible. A bounded lookup may still defer when history is unavailable or too distant. The pre-close lag tolerates feeds whose last print before the close is a few minutes old.

Data Sources on Robinhood Chain Mainnet

fact Chainlink publishes 57 push feeds for Robinhood Chain mainnet (chain id 4663), of which 35 track Robinhood stock tokens; all are 8-decimal proxies with a 24 h heartbeat and a 0.5 % deviation threshold, and the stock feeds already include the ERC-8056 uiMultiplier. Every proxy and every paired stock token was verified on chain on 2026-09-05 (description, decimals, fresh latestRoundData, uiMultiplier, oraclePaused == false); the joined registry is contracts/deploy/oracle/feeds.mainnet.json and the address table is on the Automation page. Robinhood lists 194 stock tokens on mainnet; only these 35 have a Chainlink feed, and there is no HOOD / USD Chainlink feed.

fact Pyth serves Robinhood Chain through Pyth Pro (pull oracle, verifier 0xACeA761c27A909d4D3895128EBe6370FDE2dF481 on mainnet and testnet, verification fee 1 wei). Pyth Core / Hermes is not deployed on the chain. Pyth Pro covers every one of the 35 Chainlink-listed stocks plus HOOD, USDG, ETH and BTC. Signed updates are fetched off chain by whoever wants to post them (Pyth Pro stream access is credentialed) and verified on chain permissionlessly; the protocol itself holds no credentials and runs no poster.

PythProFeed

PythProFeed is a pause-less contract that implements AggregatorV3Interface (8 decimals) on top of the Pyth Pro verifier. post(bytes update) is permissionless: it pays the verifier fee, verifies the Pyth signature on chain, parses the payload, and appends a round if the update is for the configured feed id, carries a positive price and an exponent in [−18, 0], is flagged as the regular session by Pyth’s market-session field (equity feeds; pre-market, after-hours and overnight prints are rejected), has a confidence interval within maxConfidenceBps, is no older than maxAge (60 s), is not from the future and is newer than the previous round.

Posting is also rejected while the stock token reports oraclePaused(). Prices are scaled to 8 decimals and multiplied by the stock token’s uiMultiplier, matching the Chainlink convention.

The multiplier is stored with each round; a change of more than multiplierToleranceBps against the last accepted round is rejected until the feed owner acknowledges it with acknowledgeMultiplier, so a split or reverse split cannot silently reprice a close. acknowledgeMultiplier also records basisAcknowledgedAt; a payload whose price time predates the acknowledgement is rejected with PayloadPredatesBasis(priceTs, basisAcknowledgedAt), so a print produced on the old share basis can never be appended on the new one, and the monotonic-time rule resumes from the first post-acknowledgement payload.

The corporate-action sequence is therefore: the stock token switches its uiMultiplier, the feed owner calls acknowledgeMultiplier, posting resumes with updates published after the acknowledgement. Malformed payloads, unknown property kinds, replays and untrusted governance all revert; excess fee is refunded.

Rounds must be at least 10 seconds apart, except inside the last 10 seconds before an official close: a print stamped in [close − 10 s, close] is admitted regardless of the spacing to the previous round, so a poster who lands an earlier pre-close print cannot shadow the print that is actually closest to the close, and the closing print itself can always be posted.

A round is stamped with the earlier of the update’s header time and the feed’s own update time when the payload carries one; a header more than 60 seconds newer than the feed update is rejected, so a stale feed value cannot be presented as a fresh close.

Registered with OracleAdapter.setBackupFeed(chainlinkProxy, pythProFeed), it lets any party place an exact Pyth closing print inside the observation window, which the adapter cross-checks against the Chainlink primary within maxDeviationBps. If nobody posts, the primary is used alone.

fact Deviation-triggered feeds do not print at the close by construction. Sampling the last 16 rounds of all 35 equity feeds around the 2026-09-04 close (20:00 UTC), 19 had a round inside the default window [closeTs − 30 min, closeTs + 5 min] and 16 did not (NVDA, TSLA, MSFT, META, GOOGL, SPY, QQQ, BABA, GME, SLV, USO, RKLB, PLTR, CRCL, TSM, SGOV). The production configuration is therefore maxPreCloseLag = 24 h (the contract maximum and the feed heartbeat) and maxRoundWalk = 500, applied at deployment on testnet and by the first governance proposal on mainnet (the adapter is governance-owned from its first block): a wider lookup window can improve history availability, but does not assure a qualifying closing-fresh print or bound the difference from the true close. Sample data: contracts/deploy/oracle/close-window-sample.2026-09-04.json.

A posted backup can supply a closing print, but an observation still requires the configured validity, closing-freshness and deviation checks. Neither feed availability nor the true market close is assured by the lookup rule.

Reference Price

referencePrice(feed) returns (price, asOf, live) and is the single valuation rule used outside observations: SHIELD stock during Subscription (prefund requirement and pro-rata bookkeeping), the conservative live mark, and the freshness check of the roll policy. latestPrice(feed) returns the price component and keeps its signature. Neither affects settlement, which reads only official closes through observe.

  1. Live primary. If the latest print of the primary feed is valid and at most liveMaxAge old, it is returned with live == true and asOf equal to its timestamp. During the session a healthy feed always resolves here.
  2. Live backup. Otherwise, if a backup feed is registered and its latest print is valid and at most liveMaxAge old, that print is returned with live == true. A fresh print on either feed always outranks a previous close: a stalled primary during the session does not mark SHIELD stock at yesterday’s close while the backup is printing.
  3. Closed market. Otherwise lastClose = lastCloseAtOrBefore(now) on the calendar configured on the adapter. If the market has been closed for more than MAX_CLOSED_GAP since that close the price is unavailable (StalePrice). Otherwise the official close is resolved on the primary with the same round walk observe uses, and returned with asOf == lastClose and live == false. This is an indicative valuation: over a weekend or a holiday the reference is Friday’s close, not a stale intraday tick. If the walk cannot select a closing print, the latest print counts when it was stamped between lastClose - maxPreCloseLag and now (a closing or post-close print the walk could not reach).
  4. Backup close. If nothing resolves on the primary, step 3 is repeated on the backup feed. If still nothing, StalePrice(feed).

Without a calendar configured on the adapter the flat rule applies: the latest valid print no older than maxLookback, live false unless it is within liveMaxAge.

A valid print has answer > 0, updatedAt != 0 and answeredInRound >= roundId; a feed that cannot be read yields no print.

Observation Dates: Following Business Day

Every observation timestamp is an official close at creation. If a later holiday registration removes that close, the next eligible close is used: effectiveCloseOf(scheduled) walks forward day by day, at most MAX_OBSERVATION_ROLL_DAYS = 8 calendar days, and returns the first official close it finds. For a series observation, _closeOf additionally requires the rolled day to precede the next scheduled observation’s day.

An early close registered afterwards for the scheduled day is not a roll: the day still has an official close, and the observation is taken at that earlier time.

  • getSeries keeps returning the scheduled timestamps; nextObservationTs, dueWork and effectiveObservationTs(seriesId, index) report the effective close. TooEarly is measured against the effective close.
  • The subscription end is never rolled.
  • When an observation resolves, skips or cancels at a close different from its scheduled one, ObservationRolled(seriesId, index, scheduled, effective) is emitted alongside the usual event.
  • Disruption fallback. If no trading day exists within the eight-day bound, the observation stays at its scheduled timestamp and follows the existing deferral path: without a qualifying print it is Deferred, and after deferralWindow it is forced with a stated price or cancelled, exactly as for any other disrupted close.
  • An observation that has already resolved is never revisited: registering a holiday afterwards changes nothing.
  • One close per observation. A roll that would reach the next scheduled observation’s day is not applied. The current observation retains its scheduled timestamp and follows the deferral path; it is not silently merged with the next index.
  • Strike moved into the subscription window. If the strike observation rolls to a close that is earlier than the recorded subscription end, deposits are refused from that effective close onward (SubscriptionClosed), so no ticket can enter a book whose strike price is already fixed. The subscription end shown by getSeries is unchanged; the deposit deadline is min(subscriptionEnd, effectiveObservationTs(seriesId, 0)).
  • Keeper reserve. keeperReserve(seriesId) sums the keeper reward at the effective close of every remaining observation, so a roll that lands inside a window with a higher reward is fully reserved and sweepableFees never releases fees the crank still needs.

ERC-8056 Multiplier

fact Stock Token feeds already include the uiMultiplier (Robinhood building guide). The adapter reads the feed as-is and the protocol never multiplies by uiMultiplier. Test mocks expose uiMultiplier and balanceOfUI while keeping raw ERC-20 balances, so tests would catch any accidental double application.

Failure Modes and Responses

fact Failure Modes and Responses Reference
FailureDetectionResponse
Feed did not update around the closeNo round in [closeTs − maxPreCloseLag, closeTs]Deferred; later rounds cannot qualify, so governance forces or cancels after 72 h
Feed unreachable when the keeper callslatestRoundData/getRoundData revertDeferred; retry, the closing print still qualifies once the feed is readable again
Feed returns malformed data, has no code, or its round history ends mid-walkGuarded read fails or _previousRound finds no earlier roundWalk ends with no result, observe does not revert; Deferred, then backup or governance
Print appended to a PythProFeed after the grace end with an earlier publish timepostedAt > closeTs + closeGraceWalked over; never selected
Chainlink proxy phase change between the close and the callAggregator round 1 reachedWalk continues from the previous phase aggregator
Keeper calls before the grace has elapsedGraceNotElapsedCall rejected without state change; retry from resolvableAt(closeTs)
More than maxRoundWalk rounds after the closing print before the callWalk boundDeferred; governance resolves (keepers should call at resolvableAt)
Feed stale but a backup existsPrimary fails, backup passesBackup used
Scheduled observation day registered as a holiday after creationofficialCloseOf(day) == 0Observation rolls to the next official close within 8 days (ObservationRolled)
No trading day within 8 days of the scheduled closeeffectiveCloseOf walk exhaustedObservation stays at the scheduled close and defers; force or cancel after deferralWindow
Holiday roll would reach the next scheduled observation daySeries-specific roll boundRetain the scheduled timestamp and follow the deferral path
Strike rolls to a close before the subscription endblock.timestamp >= effectiveObservationTs(seriesId, 0)Deposits revert SubscriptionClosed; strike crank proceeds at the effective close
Backup (or primary) returns an absurdly large answerDeviation check evaluated with full-width arithmeticDeferred, not an arithmetic failure; governance resolves after deferralWindow
Pyth Pro payload priced before a multiplier acknowledgementpriceTs < basisAcknowledgedAtpost reverts PayloadPredatesBasis; only post-acknowledgement payloads print on the new basis
Market closed (weekend, holiday) when SHIELD is valuedLatest print older than liveMaxAgeReference price is the last official close, live == false
Primary is old, backup has a closing printBoth valid prices agree within the deviation boundBackup may be selected under the freshness rules
Primary stalls during the session while the backup keeps printingPrimary older than liveMaxAge, backup within itreferencePrice returns the live backup print, not the primary's previous close
Pyth Pro print from the after-hours or overnight sessionSession flag is not the regular sessionpost reverts NotRegularSession; the print never becomes a round
Stock token uiMultiplier changes (split)Multiplier differs from the last accepted roundpost reverts MultiplierChanged until the owner acknowledges the new multiplier
Feeds disagree by more than 3%Deviation checkDeferred; retried; governance resolves
Negative or zero answeranswer > 0 checkRound rejected
Governance cancels an intermediate observationisCancelledNo coupon, no autocall, series continues (ObservationSkipped)
Governance cancels the maturity observationisCancelledSettles at lastPrice (the last good observation)
Governance cancels the strikeisCancelledSeries Cancelled, all deposits refundable

See Oracle risk for the residual risks this design does not remove.

A little more clarity?

Keep exploring, or revisit this topic from the navigation.