A real-time derivatives pricing engine for BTC and ETH options — built from scratch in Go.
Deribit streams raw option tickers but gives you no way to reason about the surface — where volatility is rich or cheap, how a multi-leg structure pays off, or whether a contract is trading away from its theoretical value. This platform closes that gap:
- Subscribes to ~1,300 BTC/ETH option contracts over Deribit's WebSocket API, processing 200–400 updates/sec through a bounded Go worker pool
- Computes a Black-Scholes theoretical price for every contract on the hot path at a measured 95 µs p99 end-to-end latency, and surfaces its deviation from the exchange mark
- Maintains a live implied-volatility surface across the strike × expiry grid, rendered as an interactive 3D plot
- Models multi-leg positions with live payoff diagrams and interpolated breakeven points
- Persists every tick to TimescaleDB with continuous aggregates at 1 min / 15 min / 1 hour
Two problems motivated it: latency under sustained load (a pricing number is worthless if it arrives late) and pricing models that hold up in a crypto regime (BTC realises ~150% implied vol against equity's ~30%, jumps far more often, and shows almost none of the leverage effect that anchors equity models).
Deribit WSS (ticker.{instrument}.100ms · ~1,300 channels · 200-400 msg/sec)
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Go backend │
│ readLoop ──► buffered chan (1000) ──► worker pool (10-30) │
│ sonic JSON (partial decode, pointer deref) │
│ instrument metadata ◄─ sync.Map (lock-free) │
│ Black-Scholes ◄─ 10k-entry N(d) lookup table │
│ IV surface update (strike × expiry grid) │
│ ┌───────────────────────────────┴───────────────────────────┐ │
│ ▼ ▼ │
│ Redis 7 TimescaleDB │
│ · source of truth for live state · hypertable │
│ · L1 LRU in front (1024 entries, 1s TTL) · 1m/15m/1h │
│ · batched pipeline writes aggregates │
│ · circuit breaker (gobreaker) · compression│
│ · Pub/Sub ──┐ + retention│
└──────────────┼───────────────────────────────────────────────────┘
▼
/ws fan-out ──► React dashboard (virtual table, 3D surface, PnL)
Failure behaviour is explicit. Without Redis the server boots but serves no live data and never
registers /ws — Redis is the live state. Without TimescaleDB only history is lost. Neither is fatal.
Every optimisation below came out of a pprof profile, and each is measurable in isolation.
Verified on a 13th-gen Intel i7-13700K against the live Deribit feed.
| Stage | Technique | Cost |
|---|---|---|
| JSON decode | bytedance/sonic with *float64 fields — decode once, dereference pointers instead of re-unmarshalling nested objects |
~2 µs |
| Instrument metadata | sync.Map pre-populated at startup, then effectively read-only → lock-free concurrent reads |
9.1 ns seq / 0.88 ns parallel, 0 allocs |
| Black-Scholes | 10,000-entry N(d) lookup table (100 KB, [-5,+5], step 0.001) with linear interpolation, discount-factor cache keyed on (expiry, rate), puts via put-call parity |
~2 µs, 0 allocs |
| Redis write | Batched pipeline writes behind a circuit breaker | ~5 µs |
The lookup table is the interesting one: math.Erf dominated the profile at this message rate.
Precomputing the standard normal CDF trades 100 KB of cache-resident memory for a ~6× speedup,
and adding linear interpolation between adjacent entries recovers an order of magnitude of accuracy
(0.05% → 0.005% error) for about 5 ns.
The metrics dashboard initially reported ~1.1 ms per message while pprof showed ~95 µs p99. Both
numbers were real — they measured different things:
end-to-end p99 ≈ 95 µs
├── queue wait ~80 µs (84%) ← messages sitting in the channel
└── processing ~15 µs (16%) ← parse + price + cache write
Queue wait dominating is normal and healthy for an async worker pool — it means workers are
idle-waiting rather than backlogged. But reporting it as "processing latency" is misleading, so I
instrumented each stage separately. Percentiles (p50/p95/p99/p99.9) come from a fixed-size ring
buffer with a sync.Pool for sort scratch space, so the metrics path itself allocates nothing.
OptionState is touched on every single update, so it's laid out by access frequency rather than
logical grouping — a filter sweep over 1,300 options pulls in far fewer cache lines than the naive
field order did:
// HOT (~168 B) — read together on every pricing/filter pass
InstrumentName, MarkPrice, UnderlyingPrice, MarkIV, DaysToExpiry,
Delta, Gamma, Vega, Theta, Rho, Timestamp, ExpirationTime
// WARM (64 B — exactly one cache line) — spreads, theoretical price
BidPrice, AskPrice, BidIV, AskIV, MarkPriceUSD, TheoreticalPrice, ...
// COLD — display-only strings, last
Underlying, OptionType, Expirationgo test -bench=InstrumentCacheLookup -benchmem ./internal/parser/
# Sequential 9.094 ns/op 0 B/op 0 allocs/op
# Parallel 0.8771 ns/op 0 B/op 0 allocs/op
curl localhost:8080/api/metrics # live percentiles, cache hit rates, pool depthEvery contract gets a theoretical price from the exchange's mark IV, with mark − theoretical
surfaced in absolute and percentage terms. The IV surface is a strike × expiry grid with bilinear
interpolation between quoted contracts, backed by a keyed grid cache (5 s TTL) with dirty-tracking
so a single contract update doesn't force a full surface rebuild.
Greeks come from Deribit's feed rather than being recomputed locally — the exchange's Greeks are what the market references for margin and hedging, so recomputing them would introduce disagreement without adding information. Multi-leg payoffs are evaluated at expiry via intrinsic value summed across legs, with breakevens located by scanning for sign changes and linearly interpolating the crossing.
The deeper modelling work targets the Bates model — Heston stochastic volatility plus Merton Poisson jumps:
dS/S = (r − λk_J) dt + √v dW^S + (e^J − 1) dN
dv = κ(θ − v) dt + σ_v √v dW^v
Priced with the COS method (Fang & Oosterlee, 2008), which expands the risk-neutral density in a
Fourier-cosine series. For European payoffs this collapses to a single sum over N terms — one
characteristic-function evaluation each — far cheaper than Monte Carlo across a strike ladder, since
the truncation range and payoff coefficients are computed once and reused for every strike.
Implemented:
- Characteristic function with
A(τ,u)/B(τ,u)decomposition, the jump MGFexp(λτ(e^{iuμ_J − σ_J²u²/2} − 1))as a multiplicative factor, and the "little Heston trap" formulation (Albrecher et al. 2007; Lord & Kahl 2010) for correct complex-log branch selection — the naive branch makes long-dated prices oscillate and diverge - Cumulant-based adaptive truncation
[a,b] = c₁ ± L√(c₂ + √c₄), withL = 12rather than the textbook 10, because crypto's fatter tails need a wider integration domain - Adaptive term count —
N = 128inside 7 days to expiry,N = 64beyond, since short-dated options have sharply peaked densities - Feller condition
2κθ ≥ σ_v²enforced as a hard parameter constraint, plus overflow clamping on complex exponentials, near-zero denominator detection, and NaN propagation checks
The crypto-specific calibration bounds are the genuinely interesting part: equity defaults price BTC options 3–5× below market. Encoding the regime difference means capturing that crypto vol runs ~10× equity's, mean-reverts ~3× slower, shows almost no leverage effect (ρ ≈ −0.1 vs equity's strongly negative ρ), and jumps ~3× as often (λ ≈ 12/yr).
I'd rather be precise than oversell:
| Component | Status |
|---|---|
| Deribit ingestion, worker pool, latency instrumentation | Live in production |
| Black-Scholes pricing + mark deviation | Live in production |
| IV surface construction + 3D rendering | Live in production |
| Multi-leg PnL / breakeven engine | Live in production |
| Redis + TimescaleDB tiering, aggregates, retention | Live in production |
| Heston / Bates COS pricer (standalone) | Numerical test suite passing |
| Bates COS pricer wired for production | Implemented, not yet numerically correct — known bug |
| Parameter calibration (L-BFGS against observed smile) | Specified, not implemented |
On the known bug: the production-wired Bates pricer returns divergent prices, which points at the
payoff-coefficient normalisation and the e^{iu ln S₀} forward term being double-counted between the
characteristic function and the summation loop. The standalone implementation — which applies the
forward term inside the CF and uses the little-trap branch — passes its suite. Shipping a fast,
correct Black-Scholes path while the harder model is still being validated was the right call, and
being able to say exactly why it isn't live yet seems more useful than a green checkmark.
Next: reconcile and deploy the Bates pricer · L-BFGS calibrator over the 8 Bates parameters with
Feller as a hard constraint and an atomic.Pointer swap so recalibration never stalls the hot path ·
replace bilinear IV interpolation with SVI/SABR to rule out calendar and butterfly arbitrage by
construction · analytic Greeks from the COS engine (currently finite-difference, 3–5 pricing calls each).
| Layer | Choice | Reasoning |
|---|---|---|
| Backend | Go 1.23 | Goroutines map naturally onto per-message work; GC pauses are tolerable once the hot path is allocation-free |
| JSON | bytedance/sonic |
SIMD-accelerated; partial decode into pointer fields avoids re-parsing nested Greeks |
| Concurrency | bounded worker pool, sync.Map, atomic, sync.Pool |
Bounded pool gives backpressure instead of unbounded goroutine growth under burst |
| Live cache | Redis 7 | Hash storage plus Pub/Sub makes caching and fan-out one dependency, not two |
| Resilience | sony/gobreaker |
Circuit-break Redis so a slow cache doesn't cascade into ingestion |
| History | TimescaleDB | Continuous aggregates compute the rollups without a separate batch job |
| Frontend | React 19, TypeScript, Vite 7 | Plotly.js for the WebGL 3D surface, Recharts for 2D series |
| Table | @tanstack/react-virtual |
Windowing keeps ~20 rows in the DOM regardless of dataset size |
| Infra | Docker multi-stage, Compose, nginx, Let's Encrypt | — |
On the frontend, rendering 1,300 live rows also meant hoisting Intl.NumberFormat out of the render
loop and calling Plotly.purge() on unmount — Plotly owns its DOM imperatively and doesn't release
WebGL contexts when React unmounts, which leaked ~20 MB/min while navigating.
MIT. Educational and informational use only — not investment advice, and not affiliated with or endorsed by Deribit. Theoretical prices and Greeks are model outputs, not quotes.