RFC: Intraday Funding for Select US Stock Perps

Date: 2026-06-25 (rev. 2026-07-03) Status: Draft for review Author: (you)

Summary

Replace the single once-daily funding settlement on a hand-picked subset of US stock perps with 13 intraday fundings every 30 minutes from 10:00 to 16:00 US/Eastern inclusive. Each funding is 1/slot_count (1/13 on a full day) of the usual premium formula, computed from a 30-minute full-interval TWAP of our perp mark against a 30-minute TWAP of the Databento consolidated feed, and capped at ±8 bps per interval. Ship a daily funding bar in the GUI plus an API surface that exposes the day's 13 slots (realized + projected). Goal: make funding predictable and tractable small, frequent, bounded increments instead of one opaque end-of-day charge.

This is an experiment, scoped to a designated set of instruments, fully reversible by flipping those instruments back to the existing daily schedule.

Background

How funding works today

US equity perps settle funding once per day at 16:00 ET. The mechanics (mapped in detail during research):

What's missing / wrong for this experiment

  1. Daily close can't anchor 13 intraday fundings. Today's benchmark is a single 16:00 print. Intraday funding needs a live intraday benchmark.
  2. No intraday Databento ingestion exists RESOLVED (#2872, A-3257). underlying-publisher now runs a Databento EQUS.MINI live source (rs/underlying-publisher/src/databento.rs): consolidated ohlcv-1m bars for every equities / energy_etfs perp, written to ClickHouse underlying_prices with source = "DATABENTO", calendar-gated to the tape's sessions (04:0020:00 ET), with per-source staleness health. See Dependencies for the one delta vs. this RFC's original sketch (bar closes vs. BBO mid).
  3. No funding visualization or schedule API. Clients can read realized funding_rates and the single estimated rate, but there's nothing that presents "here are today's 13 fundings, here's the running total."

Goals & non-goals

Goals

Non-goals

Resolved design decisions

Question Decision Rationale
Averaging window 30-min full-interval TWAP Tiles 10:0016:00 with no gaps/overlap; every trade counts exactly once; max manipulation-resistance.
Sampling method Simple time-weighted TWAP Fully reproducible client-side; "tractable." Not EWMA.
Mark ("settlement price") Mean of per-minute marks over the 30-min window; each = 0.3 × mid + 0.7 × minute trade VWAP Exactly today's mark machinery (mark_price blend + compute_settlement_price mean), just over 30 min instead of 5. Trade-heavy blend resists quote-only manipulation.
Benchmark ("which market") Databento EQUS.MINI consolidated feed, 30-min TWAP of 1-min bar closes Live intraday, direct-prop-feed consolidated tape; zero license fees + free redistribution, so it's safe to display to clients. Ingestion already merged (#2872) see Dependencies.
Cadence Every 30 min, 10:0016:00 US/Eastern inclusive = 13 fundings/day Per spec. All 13 slots sit inside the regular session (first window opens at the 09:30 bell, last closes at the 16:00 close).
Per-interval scaling × 1/slot_count (1/13 full day, 1/7 on a 13:00 half-day, ) Day sums to one basis charge on every day, including half-days, matching today's once-daily semantics.
Cap ±8 bps per interval Daily bound ±104 bps today's ±1% daily cap (proportionally smaller on half-days).
First slot Included window (09:30, 10:00] from the opening bell Full-session coverage; opening-auction noise is damped by the 30-min mean and the trade-heavy mark blend.
Benchmark sample floor 20 of 30 one-minute bars, else skip the slot Don't settle cash on a stale/gappy benchmark (halts, thin names).

Proposed mechanism

Schedule

Per enrolled instrument, set funding_schedule to 13 times in America/New_York:

{
  "timezone": "America/New_York",
  "times": [
    { "days_of_week": [1,2,3,4,5], "time_of_day": {"hours": 10, "minutes":  0, "seconds": 0} },
    { "days_of_week": [1,2,3,4,5], "time_of_day": {"hours": 10, "minutes": 30, "seconds": 0} },
    "... 10:00, 10:30, 11:00, …, 15:30, 16:00 (13 entries) ..."
  ],
  "exceptions": [ /* equity holidays + half-days, reused from existing calendar */ ]
}

next_funding_time() already returns the earliest future time across all entries, so no schedule-engine change is required. Half-days collapse to fewer entries via FundingException.times.

Per-interval rate

For funding interval i (closing at time tᵢ, opening at tᵢ = tᵢ 30m):

markTWAP_i  = mean of per-minute marks over (t_{i-1}, t_i]
              // per-minute mark = 0.3 × book mid + 0.7 × that-minute trade VWAP
              // (existing mark_price blend + compute_settlement_price mean,
              //  window widened 5 min → 30 min)
idxTWAP_i   = mean of Databento consolidated 1-min bar closes over (t_{i-1}, t_i]

N = slot_count for the day   // 13 full day; fewer on half-days

premium_i      = (markTWAP_i − idxTWAP_i) / idxTWAP_i        // dimensionless basis
funding_rate_i = clamp( premium_i × (1/N),  −0.0008, +0.0008 )   // ±8 bps
funding_amount_i = funding_rate_i × idxTWAP_i                 // cash, per unit

Application sign convention is unchanged (common.rs:58): long pays on positive funding, short receives, etc. Both sides of the basis are means of up-to-30 per-minute observations over the same interval symmetric and fully reproducible from public data (our published book/trades and vendor 1-min bars).

Why 1/N. premium_i is an instantaneous-style basis, not time-integrated. Charging the full basis N×/day would be ~N× today's once-daily charge. Scaling by 1/slot_count makes the day's fundings sum to one basis charge on every day, including half-days, preserving current economics while spreading it into small steps.

Why the cap rarely binds. With the 1/13 scale, the ±8 bps cap only activates when |premium_i| > 104 bps on a single 30-min interval a genuine dislocation. In normal conditions each interval is a few bps or less, and the day's total lands within ±104 bps, matching the existing ±1%/day envelope. The cap is a tail guard, not a normal-state clamp.

Worked example (one enrolled symbol, one interval)

Designating the "select" markets

Enrollment must be explicit, queryable, and reversible. Options considered:

Decision: (B). Add funding_variant to the instruments table (db/postgres/1.sql) and DbInstrument (rs/sdk-internal/db/src/entities.rs). Enrollment is a one-row update per symbol; the experiment's instrument set is whatever has funding_variant = 'intraday_twap'.

Dependencies & component changes

1. Databento EQUS.MINI intraday feed MERGED (#2872, A-3257)

Dataset: EQUS.MINI. Databento's consolidated, real-time US equities feed, blended from direct prop feeds across NMS exchanges + ATSs (not the SIP). Critically it ships with zero license fees, free redistribution rights, and full exchange compliance / no per-user fees, which is what makes it safe to surface as a client-visible benchmark on the funding bar and API.

What landed (rs/underlying-publisher/src/databento.rs):

What was the long pole is now done; remaining feed work is nil unless we reverse the bar-close decision (Open Questions).

2. Settlement runner: IntradayEquitiesRunner

3. Estimated funding

EstimatedFundingPublisher stays on Pyth for v1 (decided 2026-07-03) no benchmark switch for enrolled symbols yet. Consequence: "projected" bar cells are driven by a Pyth-based estimate while realized slots settle against the Databento TWAP, so small projected-vs-realized discrepancies are expected; acceptable for the experiment. A later iteration can switch the read at estimated_funding.rs:503 (currently hardcoded ChUnderlyingPriceSource::Pyth) to Databento for enrolled symbols and align the window.

Scaling gap (identified during PR 4, 2026-07-06): the publisher computes clamp(full premium, caps) with no 1/slot_count scaling. Enrollment sets the caps to the per-slot ±8 bps, so for an enrolled symbol /estimated-funding-rate (REST and the order-gateway WS request) would report the unscaled premium clamped to the per-slot cap neither the next-slot rate nor the daily total, pinned at the cap whenever the premium exceeds 8 bps. /funding-slots is immune (it recomputes per-slot from the estimate's raw prices), but the publisher must be made slot-aware scale by the day's resolved slot_count before clamping, for enrolled symbols only before any symbol enrolls. Natural home: the same estimated_funding.rs change as the benchmark switch above (SoW PR 6).

4. Multiple-instance safety

Per the multiple-order-gateways / multiple-settlement constraint: 13 fires/day × N instances must not double-settle. Each slot settlement must be idempotent on (symbol, settlement_time) a CAS/unique-key insert on the funding_rates / settlement reference so a second instance for the same slot no-ops. (Today's once-daily runner already keys on settlement_time; preserve that guarantee at 30-min granularity.) Test client disconnect, runner restart mid-day, and replay of a missed slot.

Daily funding bar (GUI) + API surface

API

New REST endpoint (api-gateway, alongside get_funding_rates / get_estimated_funding_rate in rs/api-gateway/src/auth_routes.rs):

GET /funding-slots?symbol={symbol}&date=YYYY-MM-DD   (default: today, schedule tz)

(Flat kebab-case path with the symbol as a query param, matching the rest of the API no /v1 prefix or path params.)

{
  "symbol": "AAPL-USD-PERP",
  "date": "2026-06-25",
  "timezone": "America/New_York",
  "variant": "intraday_twap",
  "interval_count": 13,
  "cap_bps": 8,
  "slots": [
    {
      "index": 1,
      "funding_time": "2026-06-25T10:00:00-04:00",
      "status": "realized",          // realized | projected | skipped | pending
      "mark_twap": "100.50",
      "underlying_twap": "100.00",   // naming per the index→underlying rename (#2413)
      "premium_bps": "50.0",
      "funding_rate_bps": "3.85",
      "capped": false
    }
    // … 13 entries; future slots status="projected" from estimated funding,
    //    past-with-insufficient-data status="skipped"
  ],
  "realized_sum_bps": "21.4",        // sum of realized slots so far
  "projected_eod_bps": "49.9"        // realized + projected remaining
}

For the daily_close variant the same endpoint returns a single slot one surface for both, with interval_count distinguishing them.

GUI funding bar

A compact horizontal bar of 13 cells near the instrument header:

Check @architect-xyz/ui-components for an existing bar/sparkline primitive before building (per GUI code-style note); feed it from the endpoint above.

Edge cases

Rollout

  1. Land funding_variant column + IntradayEquitiesRunner behind it; no instrument enrolled zero behavior change.
  2. Stand up Databento consolidated ingestion merged (#2872). Deploy prerequisite: apply the a-3665 rename migration (index_prices underlying_prices) as of 2026-07-03 prod ClickHouse still has index_prices only, so the feed's writes would fail there. Then validate 30-min bar-close TWAPs vs known prints in underlying_prices for a week (no settlements).
  3. Ship API endpoint + GUI bar reading from realized funding_rates (works for daily variant immediately derisks the surface before intraday goes live).
  4. Enroll 12 liquid names in demo, run intraday fundings in dry-run (compute + record funding_rates, don't post transactions), reconcile sums.
  5. Flip those names to live intraday settlement in prod; monitor zero-sum recon and the bar; expand the enrolled set.
  6. Reversal at any point: funding_variant = 'daily_close' back to 16:00 daily.

Open questions

  1. Timezone RESOLVED: 10:0016:00 US/Eastern (America/New_York); the Central reading in the earlier draft was a mistake. Last slot = the 16:00 close.
  2. Databento dataset/schema + licensing RESOLVED: EQUS.MINI, zero license fees / free redistribution, so client-visible is fine. The merged feed (#2872) uses ohlcv-1m bar closes, not the originally-sketched mbp-1 mid; this RFC now adopts bar-close TWAP as the benchmark. Confirm: (a) OK with lit-venue-only coverage (no TRF off-exchange prints); (b) OK with bar-close TWAP vs. BBO-mid TWAP (reproducibility argues for bars; thin-name gappiness is handled by the skip rule).
  3. 1/13 on short days RESOLVED (2026-07-03): scale by 1/slot_count so half-days also sum to one basis charge.
  4. Cap symmetry: ±8 bps symmetric confirmed? (funding_rate_cap_lower_pct must be 0 fine.)
  5. First slot (10:00 ET) RESOLVED (2026-07-03): include the (09:30, 10:00] opening window; auction noise is damped by the 30-min mean and trade-heavy mark blend.
  6. Estimated-funding benchmark RESOLVED (2026-07-03): keep Pyth for v1; revisit switching enrolled symbols to the Databento benchmark later (see §3).

Appendix anticipated touch list