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 × 365qty 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 / expiryWeekday | 3 / Friday UTC |
minDaysAhead / maxDaysAhead | 3 / 30 |
minAbsDelta / maxAbsDelta | 0.15 / 0.60 — on the absolute delta |
minApr | 0.20 |
minMarkRatio | 0.85 |
refreshIntervalMs / errorBackoffMs | 5 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-bid → net-premium-not-positive → no-mark-price →
bid-below-mark-ratio → apr-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 overWsAdapter, no socket.io (strategies/strategies.gateway.ts:42)
Neither polls the exchange; both hand back what the background loop already computed.
| Situation | Result |
|---|---|
| First snapshot not built yet | SnapshotNotReadyError → HTTP 503 MARKET_DATA_UNAVAILABLE; on the socket, an error frame, but the subscription stays alive and the snapshot arrives on its own |
| Unknown asset | UnsupportedAssetError → 404 ASSET_NOT_SUPPORTED |
| A background pass fails | The 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:
USDCshort-circuits to'1'without touching the snapshot.- Anything else is looked up as
indexPriceon 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
SnapshotNotReadyErrorstraight 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 inACCEPTED_ASSETS(treasury/deposit-policy.ts:208)coveredCallAvailable— equal todepositableby 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.