Skip to Content
EngineeringMarket data

Market data

Four things that all read from the exchange and none of which touch money: the option showcase, the spot-price relay, the chart, and the asset list. All four are public — no @Authenticated() anywhere in them, commented in each as “market data only, nothing user-specific”.

There are two unrelated “price sources” in this codebase and they are easy to confuse. The prices module is a spot-price WebSocket relay for the browser. The usdRateFor that the money paths depend on lives in strategies/index-price.source.ts. Looking for usdRateFor in prices/ finds nothing.

The showcase

ShowcaseRefresher (strategies/showcase-refresher.ts:115) keeps one snapshot per asset in memory and refreshes it on a loop. It pulls three things from Derive on different clocks: the instrument catalogue (get_instruments, TTL 10 min), the fee rates (get_ticker, TTL 5 min), and the prices (get_tickers batched per expiry, every 5 s).

The loop is a chain of setTimeout(...).unref(), not setInterval — the next pass is scheduled after the current one finishes, so a slow pass cannot stack.

Candidate construction

buildCandidates (strategies/candidate-builder.ts:138) is a pure function. The APR, at candidate-builder.ts:182-217:

notional = index × qty gross = bid × qty fee = FeeCalculator.optionFeeFromRates(...) // taker rate, from the live ticker net = gross − fee apr = net / notional / dte × 365

qty here is referenceQty — a fixed display size from the asset’s policy (ETH '0.5', BTC '0.01'), not a size computed against anyone’s balance. It is returned in the response so the frontend can say what the number refers to.

Expiries are weekly Fridays rather than the next N in sequence (selectExpiries, candidate-builder.ts:108), and the day count is calendar days in UTC, not fractional days (calendarDaysAheadUtc).

Policy

Every threshold is a constant in strategies/showcase-policy.ts, not an environment variable — deliberately, per the comment at the top of the file. Both assets currently use the same numbers:

expiryCount / expiryWeekday3 / Friday UTC
minDaysAhead / maxDaysAhead3 / 30
minAbsDelta / maxAbsDelta0.15 / 0.60 — on the absolute delta
minApr0.20
minMarkRatio0.85
refreshIntervalMs / errorBackoffMs5 s / 15 s

Both showcase specs are wrong about the threshold. They say rejected candidates are still returned, annotated with passesThreshold/rejectionReason. In the code the threshold filters: RejectionReason is a local type that never leaves buildCandidates (candidate-builder.ts:9), rejects continue at :236, and the contract has no such fields. The specs are also wrong that hasSignal is always false — it is computed through signals/signal-matcher.ts:28.

Rejection reasons are ordered from fundamental to soft, so that “there is no bid” is never reported as “APR below threshold”: no-bidnet-premium-not-positiveno-mark-pricebid-below-mark-ratioapr-below-minimum.

Why these thresholds

Expiries are weekly, not the next three in sequence. Measured 2026-08-18: both assets had 11 expiries distributed {Wed 1, Thu 1, Fri 8, Sat 1}. The weeklies are Fridays UTC; the non-Fridays appear only in the next few days and are dailies, whose books are two to three times thinner — 20–24 instruments against 40 and 64. Taking the next three in sequence would produce thin books and APRs inflated by a factor of thousands.

It takes the three nearest that exist, not the dates +7/+14/+21: in the live catalogue there were no weekly Fridays at all between 2026-09-04 and 2026-09-25.

minDaysAhead counts calendar days in UTC, not fractional ones: a measurement on 2026-08-18 at 10:06 UTC gave 2.91 days to the nearest Friday, and a fractional threshold would have discarded it — though Tuesday to Friday is three days. As a side effect this puts a floor under dte: a card with a four-digit APR on an option expiring “in an hour” cannot appear.

The delta corridor is on the absolute value. Puts have negative delta (verified: −1 to 0), so a corridor on the raw value would cut out every put, i.e. half the showcase. The client is still sent the signed delta — otherwise a put is indistinguishable from a call. The corridor is deliberately wider than the working range so the user can see the neighbours of their choice.

The fee uses the taker rate because the card shows the result of immediate execution. The maker rate is cheaper, but getting it is not guaranteed, and showing an unreachable number is not allowed.

referenceQty is returned in the response because base_fee is a flat $0.5 per trade, so APR is not a property of the instrument: on 0.5 ETH the fee eats 26% of a $3 premium; on 5 ETH, 2.6%. Silently fixing a size would hand back a number that cannot be interpreted.

errorBackoffMs is longer than the normal pause: if the exchange is throttling — and 35.7 s against 280 ms on the same call looks like it — making requests more often is pointless.

Why not “on demand plus a cache”: measured 2026-08-17, get_instruments took 35.7 s and a per-instrument get_ticker 10.8 s. At a 5 s TTL a cache miss is the norm, which would mean ~50 seconds per frontend request.

On a live board two or three instruments out of 586 pass the threshold. That is a normal state of the market, not a breakage.

The refresher

Three getTickersByExpiry batches run in parallel: three sequential ones at 2–4 seconds each would make a pass longer than the refresh interval.

asOf is the moment the pass started, not when it ended, so a snapshot ages conservatively and never looks fresher than it is.

The contract is applied on the producer’s side: a malformed shape fails the pass and leaves the previous snapshot standing, rather than reaching the client.

Sorting is deterministic and not by APR: by expiryAt, then strike. Ranking by APR would always surface the strike nearest the money — see Risks §3.1.

Assets

SHOWCASE_POLICIES is the single source of truth for which assets exist — the chart, the fee endpoint and GET /assets all read it rather than keeping their own lists. Today that is ETH and BTC.

XAUT, HYPE and XRP were removed: Derive’s bridge has no ERC20 collateral for them, so a covered call is impossible, and “half a circle is not a product” (showcase-policy.ts:115).

Serving it

Two transports over the same in-memory snapshot:

  • GET /assets/:asset/strategies (strategies/strategies.controller.ts:70)
  • WS /ws/strategies — a plain {event, data} protocol over WsAdapter, no socket.io (strategies/strategies.gateway.ts:42)

Neither polls the exchange; both hand back what the background loop already computed.

SituationResult
First snapshot not built yetSnapshotNotReadyError → HTTP 503 MARKET_DATA_UNAVAILABLE; on the socket, an error frame, but the subscription stays alive and the snapshot arrives on its own
Unknown assetUnsupportedAssetError404 ASSET_NOT_SUPPORTED
A background pass failsThe previous snapshot keeps being served, with its original asOf; the pause lengthens to errorBackoffMs

Multi-asset support is a façade over a Map<asset, ShowcaseRefresher> (strategies/multi-asset-snapshot-source.ts:12), with per-asset start offsets from a deterministic hash of the asset name (refresher-offset.ts:16) — deterministic rather than random so tests are reproducible.

On the frontend, REST and socket both write into the same React Query key, and snapshot.ts:fresher applies whichever snapshot has the later asOf — otherwise a socket frame that predates the first REST response would overwrite it.

Still not done: subscribing to Derive’s live ticker_slim stream. The snapshot is still assembled by polling every 5 seconds, exactly as showcase-streaming-how-it-works.md describes as future work.

Index price — what the money paths use

IndexPriceSource.usdRateFor(currency) (strategies/index-price.source.ts:3), with one implementation, SnapshotIndexPriceSource:

  • USDC short-circuits to '1' without touching the snapshot.
  • Anything else is looked up as indexPrice on that asset’s showcase snapshot — no network call of its own. The rate is exactly as fresh as the snapshot.
  • No snapshot for that asset → it throws SnapshotNotReadyError straight through.

Seven consumers: treasury/deposit-watcher.ts:102, treasury/gas-funder.ts:128, treasury/withdrawer.ts:650, treasury/withdrawals.service.ts:250, swap/swap-watcher.ts:337, swap/price-sanity.guard.ts:36 and portfolio/portfolio.service.ts:148 (inside valuate).

Because it treats a currency code as a showcase asset code, it knows ETH and BTC and nothing else — which is the mechanism behind the WETH trap on the Traps page.

A thrown SnapshotNotReadyError in a money path is a normal case, not a bug: the tick dies and retries later. That is documented in the code at treasury/withdrawer.ts:630 and swap/swap-watcher.ts:236, and it is the reason both FX rates in settle() are read before the first append — see Withdrawal.

Spot prices

prices/derive-spot-stream.ts:22 opens a WebSocket straight to wss://api.lyra.finance/ws (Lyra is Derive’s former name) and subscribes to spot_feed.BTC and spot_feed.ETH, reconnecting with exponential backoff from 1 s to 30 s. prices/prices.gateway.ts:14 relays that to the browser on /ws/prices as {event: 'price', data: {asset, price}}.

Nothing in the money paths reads it. It exists so the UI can show a live price.

Chart

GET /assets/:asset/chart?from=&to= (unix seconds, from < to, else 400). Data comes straight from Derive’s public/get_tradingview_chart_data for <ASSET>-PERP — the perpetual future, not a spot instrument.

Candle period is chosen from the window length: ≤7 days → 1 h, ≤30 days → 4 h, otherwise 1 d.

On a network error the service logs a warning and returns [] rather than throwing: the controller has already rejected unsupported assets, so an empty array reads unambiguously as “no data for that window”.

chartSupports reads SHOWCASE_POLICIES (chart.service.ts:17). It used to keep its own instrument map, which silently drifted after XAUT/HYPE/XRP were removed — the chart went on serving assets that no longer existed as products.

Asset list

GET /assets (assets/assets.controller.ts:30). The module has no providers at all: the answer is assembled from two constant policy tables.

Per asset it returns two flags:

  • depositable — whether a deposit route exists in ACCEPTED_ASSETS (treasury/deposit-policy.ts:208)
  • coveredCallAvailable — equal to depositable by construction: you can hold an asset under a sold call exactly when you were able to bring it in as collateral

The endpoint exists to kill a hardcoded list on the frontend. That list is gone; apps/web/src/shared/lib/tradable-assets.ts:22 now reads these flags.