Skip to content

About

BTC / ETH option tracker

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Crypto Options Analytics Platform

A real-time derivatives pricing engine for BTC and ETH options — built from scratch in Go.

Go React Redis TimescaleDB License: MIT

Live → optiondashboard.online


What it is

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).

Architecture

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.

Benchmarks

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.

Hot path: bytes to cached price

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.

Honest latency accounting

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.

Memory layout

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, Expiration

Reproducing

go 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 depth

Quantitative work

Live: Black-Scholes and surface construction

Every 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.

Research: Bates jump-diffusion via Fourier-cosine expansion

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 MGF exp(λτ(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₄), with L = 12 rather than the textbook 10, because crypto's fatter tails need a wider integration domain
  • Adaptive term count — N = 128 inside 7 days to expiry, N = 64 beyond, 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).

Project status

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).

Tech stack

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.

License

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.

About

BTC / ETH option tracker

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages