Oracle and MarketCalendar
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?
| Rule | Value |
|---|---|
| Regular close | 20:00 UTC during US daylight saving time; 21:00 UTC otherwise (16:00 New York) |
| Days | Monday to Friday |
| Holidays | Registered by governance per year: setHoliday(dayIndex, bool) |
| Early closes | Registered per year: setEarlyClose(dayIndex, closeTs), typically 17:00 UTC (DST) or 18:00 UTC (13:00 New York) |
| DST start | Second Sunday in March, computed on-chain by a pure function |
| DST end | First Sunday in November |
fact MarketCalendar Calculation
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.
| View | Meaning |
|---|---|
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.
- The call is accepted only once the close grace has elapsed:
observerevertsTooEarlybeforecloseTsandGraceNotElapseduntilcloseTs + closeGracehas passed (resolvableAt(closeTs) = closeTs + closeGrace + 1is the first accepted second). Posted-round feeds must have submitted a qualifying print by the grace deadline. - Call
latestRoundData. Walk back rounds withgetRoundDatawhile the round’supdatedAtis later thancloseTs, for at mostmaxRoundWalkrounds. For feeds that exposepostedAt(roundId)(IPostedRoundFeed, implemented byPythProFeed) the walk also skips rounds posted on chain aftercloseTs + closeGrace, so a print appended after the grace end is walked over even if it carries an earlier publish time. Support forpostedAtis 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 fromlatestRound()ofphaseAggregators(phaseId − 1), so a proxy phase change does not cut the history. The last round whose price time is at or beforecloseTsis the candidate: a print stamped after the close, whether an after-hours print or the next session’s first print, is never the close. - Require
updatedAt >= closeTs − maxPreCloseLag, a non-zero timestamp, a positive answer andansweredInRound >= roundId. At least one selected round must also be withinclosingFreshnessof the close. A round beyond the walk bound is unknown, not evidence that a feed is absent. - If a backup is configured, resolve both feeds. If either walk is cut short, defer. When both return valid prices, they must agree within
maxDeviationBpsbefore 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. - 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.
- After
deferralWindow, governance mayproposeForceObserve(feed, closeTs, price)(eventForceObserveProposed), executable by anyone viaexecuteForceObserveafterforceDelay, revocable by owner or guardian in the meantime (revokeForceObserve); orcancelObservation(feed, closeTs).executeForceObservefirst 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 emitsObservationResolved.
| Parameter | Default | Role | Label |
|---|---|---|---|
closeGrace | 5 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 then | fact |
maxPreCloseLag | 30 minutes (1 second to 24 hours) | Oldest acceptable closing print relative to the close | fact |
settlementWindow | 2 hours | Keeper deadline for off-chain operations; does not affect which round is selected | fact |
maxLookback | 26 hours | Staleness bound for the reference price when no calendar is configured on the adapter | fact |
liveMaxAge | 1 hour | A print at most this old is a live reference price (setLiveMaxAge) | fact |
MAX_CLOSED_GAP | 7 days (constant) | When the market has been closed for longer than this and no live print exists, the reference price is unavailable | fact |
maxDeviationBps | 300 (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 failure | fact |
deferralWindow | 72 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 possible | fact |
forceDelay | 24 hours (hard minimum 24 hours) | Delay between a force proposal and its execution | fact |
maxRoundWalk | 64 rounds (hard maximum MAX_ROUND_WALK_LIMIT = 500) | Bound on the walk back from the latest round | fact |
freshLag | 15 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 maxPreCloseLag | fact |
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.
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.
Close-window Coverage of the Chainlink Push Feeds
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.
- Live primary. If the latest print of the primary feed is valid and at most
liveMaxAgeold, it is returned withlive == trueandasOfequal to its timestamp. During the session a healthy feed always resolves here. - Live backup. Otherwise, if a backup feed is registered and its latest print is valid and at most
liveMaxAgeold, that print is returned withlive == 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. - Closed market. Otherwise
lastClose = lastCloseAtOrBefore(now)on the calendar configured on the adapter. If the market has been closed for more thanMAX_CLOSED_GAPsince that close the price is unavailable (StalePrice). Otherwise the official close is resolved on the primary with the same round walkobserveuses, and returned withasOf == lastCloseandlive == 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 betweenlastClose - maxPreCloseLagand now (a closing or post-close print the walk could not reach). - 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.
getSerieskeeps returning the scheduled timestamps;nextObservationTs,dueWorkandeffectiveObservationTs(seriesId, index)report the effective close.TooEarlyis 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 afterdeferralWindowit 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 bygetSeriesis unchanged; the deposit deadline ismin(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 andsweepableFeesnever 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
| Failure | Detection | Response |
|---|---|---|
| Feed did not update around the close | No round in [closeTs − maxPreCloseLag, closeTs] | Deferred; later rounds cannot qualify, so governance forces or cancels after 72 h |
| Feed unreachable when the keeper calls | latestRoundData/getRoundData revert | Deferred; 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-walk | Guarded read fails or _previousRound finds no earlier round | Walk 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 time | postedAt > closeTs + closeGrace | Walked over; never selected |
| Chainlink proxy phase change between the close and the call | Aggregator round 1 reached | Walk continues from the previous phase aggregator |
| Keeper calls before the grace has elapsed | GraceNotElapsed | Call rejected without state change; retry from resolvableAt(closeTs) |
More than maxRoundWalk rounds after the closing print before the call | Walk bound | Deferred; governance resolves (keepers should call at resolvableAt) |
| Feed stale but a backup exists | Primary fails, backup passes | Backup used |
| Scheduled observation day registered as a holiday after creation | officialCloseOf(day) == 0 | Observation rolls to the next official close within 8 days (ObservationRolled) |
| No trading day within 8 days of the scheduled close | effectiveCloseOf walk exhausted | Observation stays at the scheduled close and defers; force or cancel after deferralWindow |
| Holiday roll would reach the next scheduled observation day | Series-specific roll bound | Retain the scheduled timestamp and follow the deferral path |
| Strike rolls to a close before the subscription end | block.timestamp >= effectiveObservationTs(seriesId, 0) | Deposits revert SubscriptionClosed; strike crank proceeds at the effective close |
| Backup (or primary) returns an absurdly large answer | Deviation check evaluated with full-width arithmetic | Deferred, not an arithmetic failure; governance resolves after deferralWindow |
| Pyth Pro payload priced before a multiplier acknowledgement | priceTs < basisAcknowledgedAt | post reverts PayloadPredatesBasis; only post-acknowledgement payloads print on the new basis |
| Market closed (weekend, holiday) when SHIELD is valued | Latest print older than liveMaxAge | Reference price is the last official close, live == false |
| Primary is old, backup has a closing print | Both valid prices agree within the deviation bound | Backup may be selected under the freshness rules |
| Primary stalls during the session while the backup keeps printing | Primary older than liveMaxAge, backup within it | referencePrice returns the live backup print, not the primary's previous close |
| Pyth Pro print from the after-hours or overnight session | Session flag is not the regular session | post reverts NotRegularSession; the print never becomes a round |
Stock token uiMultiplier changes (split) | Multiplier differs from the last accepted round | post reverts MultiplierChanged until the owner acknowledges the new multiplier |
| Feeds disagree by more than 3% | Deviation check | Deferred; retried; governance resolves |
| Negative or zero answer | answer > 0 check | Round rejected |
| Governance cancels an intermediate observation | isCancelled | No coupon, no autocall, series continues (ObservationSkipped) |
| Governance cancels the maturity observation | isCancelled | Settles at lastPrice (the last good observation) |
| Governance cancels the strike | isCancelled | Series Cancelled, all deposits refundable |
See Oracle risk for the residual risks this design does not remove.
Keep exploring, or revisit this topic from the navigation.