Crypto Options Open Interest Analysis for Better Trades
For traders who already understand options basics, this guide shows how to turn exchange open interest into usable strike, expiry and crowding signals.
For traders who already understand options basics, this guide shows how to turn exchange open interest into usable strike, expiry and crowding signals.
Crypto options open interest analysis tells you where traders have real risk on the board, not just where price printed last. I use it to map strike magnets, crowded hedges, and expiry zones before sizing spot, perps, or option spreads. It is not a buy/sell signal by itself; it is context for where forced hedging can matter.
Open interest is the number of option contracts still open. For trading, the useful view is not total OI; it is OI by expiry, strike, and side. A $2 billion aggregate BTC options board is less useful than knowing $400 million sits at one Friday call strike within 2% of spot.
Rising OI with rising volume usually means new risk is entering. Falling OI after a price spike often means positions are closing, so the level may stop mattering. Perp OI tells you where leverage is building; options OI tells you where gamma, hedging, and expiry pressure may sit.
| Metric | Trader read |
|---|---|
| OI by strike | Find price zones where hedging or pinning may matter |
| OI by expiry | Separate weekly noise from monthly positioning |
| Call vs put OI | Spot speculative upside or downside hedge demand |
| 24h OI change | Detect fresh positioning instead of stale inventory |
I start with the top five strikes by USD open interest for the nearest weekly and monthly expiries. If the largest strike has at least 2x the OI of the next strike, I treat it as a real wall, not background noise. On OKX BTC options, I pay attention when more than 35% of an expiry's OI clusters within a 2-3% band around spot.
Bybit is useful for checking whether the same strike has meaningful per-symbol openInterest and Greeks. Binance options openInterest gives sumOpenInterestUsd by symbol and expiry, which is enough to rank strike concentration even if you do the deeper Greeks elsewhere.
VoiceOfChain tracks options open interest by strike, expiry and side in real time across Binance, Bybit and OKX - you can see live OI clusters and put/call shifts without building the API pipeline yourself. [voiceofchain.com]
Use public endpoints for the board and authenticated endpoints only to line up your own account risk. I prefer OKX for oiUsd, Bybit for option tickers with openInterest and Greeks, and Binance for expiry-level sumOpenInterestUsd. Always normalize contract units before comparing venues.
| Exchange | Endpoint |
|---|---|
| OKX | GET https://www.okx.com/api/v5/public/open-interest?instType=OPTION&uly=BTC-USD |
| Bybit | GET https://api.bybit.com/v5/market/tickers?category=option&baseCoin=BTC |
| Binance | GET https://eapi.binance.com/eapi/v1/openInterest?underlyingAsset=BTC&expiration=YYMMDD |
import base64
import datetime as dt
import hashlib
import hmac
import os
from collections import defaultdict
from urllib.parse import urlencode
import requests
BASE = "https://www.okx.com"
def okx_timestamp():
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def okx_headers(method, request_path, body=""):
ts = okx_timestamp()
key = os.environ["OKX_API_KEY"]
secret = os.environ["OKX_SECRET_KEY"]
passphrase = os.environ["OKX_PASSPHRASE"]
message = f"{ts}{method.upper()}{request_path}{body}"
sign = base64.b64encode(hmac.new(secret.encode(), message.encode(), hashlib.sha256).digest()).decode()
return {
"OK-ACCESS-KEY": key,
"OK-ACCESS-SIGN": sign,
"OK-ACCESS-TIMESTAMP": ts,
"OK-ACCESS-PASSPHRASE": passphrase,
"Content-Type": "application/json",
}
def get_okx(path, params=None, private=False):
query = urlencode(params or {})
request_path = f"{path}?{query}" if query else path
headers = okx_headers("GET", request_path) if private else {}
try:
response = requests.get(BASE + path, params=params, headers=headers, timeout=10)
response.raise_for_status()
payload = response.json()
except requests.RequestException as exc:
raise RuntimeError(f"OKX request failed: {exc}") from exc
except ValueError as exc:
raise RuntimeError("OKX returned non-JSON response") from exc
if payload.get("code") != "0":
raise RuntimeError(f"OKX API error: {payload}")
return payload["data"]
rows = get_okx("/api/v5/public/open-interest", {"instType": "OPTION", "uly": "BTC-USD"})
by_strike = defaultdict(lambda: {"C": 0.0, "P": 0.0})
for row in rows:
parts = row["instId"].split("-")
if len(parts) < 5:
continue
strike, side = parts[3], parts[4]
by_strike[strike][side] += float(row.get("oiUsd") or 0)
top = sorted(by_strike.items(), key=lambda item: item[1]["C"] + item[1]["P"], reverse=True)[:10]
for strike, sides in top:
total = sides["C"] + sides["P"]
cp_ratio = sides["C"] / max(sides["P"], 1.0)
print(f"{strike}: ${total:,.0f} OI USD, call/put={cp_ratio:.2f}")
if all(os.getenv(k) for k in ["OKX_API_KEY", "OKX_SECRET_KEY", "OKX_PASSPHRASE"]):
account_config = get_okx("/api/v5/account/config", private=True)
print("Authenticated OKX UID:", account_config[0].get("uid", "hidden"))
import hashlib
import hmac
import os
import time
from collections import defaultdict
from urllib.parse import urlencode
import requests
BASE = "https://api.bybit.com"
RECV_WINDOW = "5000"
def bybit_headers(query_string=""):
ts = str(int(time.time() * 1000))
key = os.environ["BYBIT_API_KEY"]
secret = os.environ["BYBIT_API_SECRET"]
payload = ts + key + RECV_WINDOW + query_string
signature = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
return {
"X-BAPI-API-KEY": key,
"X-BAPI-TIMESTAMP": ts,
"X-BAPI-RECV-WINDOW": RECV_WINDOW,
"X-BAPI-SIGN": signature,
}
def get_bybit(path, params=None, private=False):
params = params or {}
query = urlencode(params)
headers = bybit_headers(query) if private else {}
try:
response = requests.get(BASE + path, params=params, headers=headers, timeout=10)
response.raise_for_status()
payload = response.json()
except requests.RequestException as exc:
raise RuntimeError(f"Bybit request failed: {exc}") from exc
except ValueError as exc:
raise RuntimeError("Bybit returned non-JSON response") from exc
if payload.get("retCode") != 0:
raise RuntimeError(f"Bybit API error: {payload}")
return payload["result"]
result = get_bybit("/v5/market/tickers", {"category": "option", "baseCoin": "BTC"})
clusters = defaultdict(lambda: {"C": 0.0, "P": 0.0})
for row in result.get("list", []):
parts = row["symbol"].split("-")
if len(parts) != 4:
continue
strike, side = parts[2], parts[3]
clusters[strike][side] += float(row.get("openInterest") or 0)
for strike, sides in sorted(clusters.items(), key=lambda item: item[1]["C"] + item[1]["P"], reverse=True)[:10]:
print(strike, "calls", round(sides["C"], 4), "puts", round(sides["P"], 4))
if all(os.getenv(k) for k in ["BYBIT_API_KEY", "BYBIT_API_SECRET"]):
open_orders = get_bybit("/v5/order/realtime", {"category": "option"}, private=True)
print("Authenticated Bybit open orders:", len(open_orders.get("list", [])))
import hashlib
import hmac
import os
import time
from urllib.parse import urlencode
import requests
BASE = "https://eapi.binance.com"
def signed_query(params=None):
params = dict(params or {})
params["timestamp"] = int(time.time() * 1000)
query = urlencode(params)
secret = os.environ["BINANCE_API_SECRET"]
signature = hmac.new(secret.encode(), query.encode(), hashlib.sha256).hexdigest()
return f"{query}&signature={signature}"
def get_binance(path, params=None, signed=False):
headers = {"X-MBX-APIKEY": os.getenv("BINANCE_API_KEY", "")}
try:
if signed:
url = f"{BASE}{path}?{signed_query(params)}"
response = requests.get(url, headers=headers, timeout=10)
else:
response = requests.get(BASE + path, params=params, timeout=10)
response.raise_for_status()
payload = response.json()
except requests.RequestException as exc:
raise RuntimeError(f"Binance request failed: {exc}") from exc
except ValueError as exc:
raise RuntimeError("Binance returned non-JSON response") from exc
if isinstance(payload, dict) and payload.get("code") not in (None, 0):
raise RuntimeError(f"Binance API error: {payload}")
return payload
exchange_info = get_binance("/eapi/v1/exchangeInfo")
now_ms = int(time.time() * 1000)
symbols = [
item for item in exchange_info["optionSymbols"]
if item.get("status") == "TRADING" and item["symbol"].startswith("BTC-") and int(item["expiryDate"]) > now_ms
]
if not symbols:
raise RuntimeError("No active BTC option symbols found on Binance Options")
nearest = min(symbols, key=lambda item: int(item["expiryDate"]))
underlying, expiry = nearest["symbol"].split("-")[:2]
rows = get_binance("/eapi/v1/openInterest", {"underlyingAsset": underlying, "expiration": expiry})
for row in sorted(rows, key=lambda item: float(item["sumOpenInterestUsd"]), reverse=True)[:10]:
print(row["symbol"], "$" + format(float(row["sumOpenInterestUsd"]), ",.0f"))
if all(os.getenv(k) for k in ["BINANCE_API_KEY", "BINANCE_API_SECRET"]):
account = get_binance("/eapi/v1/account", signed=True)
print("Authenticated Binance account keys:", sorted(account.keys())[:5])
The cleanest signal is not high OI; it is high OI plus a change in price behavior. I normalize OI to USD, split calls from puts, compare 24h OI change to volume, then mark the level against spot, IV, and perp funding. If those do not line up, I treat the board as information, not a trade.
I've seen BTC perps print 0.3% per 8h funding with rising call OI before a 20% correction. The open interest did not mean support; it showed how crowded the upside trade had become. That is why I use OI to define risk zones first and entries second.
| Board setup | Trade read |
|---|---|
| Calls dominate above spot, funding hot | Squeeze may be late; fade only after failed breakout |
| Puts dominate below spot, spot holding | Hedges are expensive; watch for relief rally if puts unwind |
| OI rises, volume flat | Stale positioning risk; size smaller |
| OI falls after level breaks | Strike loses importance; do not anchor to it |
The common mistake is assuming high call OI is bullish and high put OI is bearish. Every option has a buyer and seller. A big call wall can be upside speculation, covered inventory, short call supply, or dealer hedge flow. Without price, volume, IV, and delta context, the same number can tell different stories.
This approach fails around sudden macro headlines, ETF flow shocks, exchange outages, and thin weekend liquidity. Into expiry, gamma can flip quickly; a level that pinned price for six hours can disappear after a few large closes. My risk rule is simple: if spot moves through the wall and OI drops, stop treating that strike as support or resistance.
The key takeaway: crypto options open interest analysis is a map of where risk is concentrated, not a standalone signal. The edge comes from watching how OI changes as price approaches the cluster. Normalize the data, confirm across OKX, Bybit and Binance, then wait for price and volume to prove the level matters. Once the board is mapped live, your job is to avoid crowded entries and trade the reaction.