Analyzing Bitcoin Options Data with QuestDB
On October 18, the Securities and Exchange Commission (SEC) approved the New York Stock Exchange to list and trade options tied to three spot bitcoin ETFs including Fidelity, Ark21Shares, and Grayscale. CBOE Global Markets also followed suit by listing its first cash-settled Bitcoin ETF options on December 2.
We now have more options for Bitcoin investors than ever, and it’s an exciting time to start analyzing that public data. And we also have an incoming US regime that appears to be crypto-friendly, and a heckuva bull run that's pushed Bitcoin to new all-time highs.
What does the future have in-store for Bitcoin? Who knows - but with this data and QuestDB, in just a few minutes, you'll be well-equipped to find out.
Using the Deribit API
To begin, we'll need some data. Our ideal source will be both public and accessible via an API, at little (or preferably no) cost. For this, we'll use Deribit's public API. What's Derebit?
Deribit is a cryptocurrency derivatives platform. Launched in June 2016 after two years of dedicated development, Deribit appeared as the world's first cryptocurrency options exchange.
Even though Deribit is not licensed to operate in the US, we can still grab data from its public endpoint. Let’s start out by exploring which Bitcoin options are available for us to consume:
import requestsBASE_URL = "https://www.deribit.com/api/v2"def get_options_data():# Endpoint to get the available Bitcoin optionsendpoint = "/public/get_instruments"params = {"currency": "BTC","kind": "option" # Fetch only options instruments}response = requests.get(BASE_URL + endpoint, params=params)if response.status_code == 200:data = response.json()if data["result"]:print("Available Options Instruments:")for option in data["result"]:print(f"Option Symbol: {option['instrument_name']}, Type: {option['option_type']}")else:print("No options data available.")else:print("Error fetching options data:", response.status_code)if __name__ == "__main__":get_options_data()
This script fetchs and analyzes Bitcoin options trade data. It uses the requests
library to retrieve the latest trades for a specified option symbol and limit. Upon a successful API call, it extracts key details like timestamp
, price
, and direction
.
This will list our instruments like:
Option Symbol | Type |
---|---|
BTC-9DEC24-88000-C | call |
BTC-9DEC24-88000-P | put |
The format is BTC-<DATE>-<PRICE>-C/P
. We'll unpack the option symbol later in the article, but for now let’s just pick an option and grab some data points:
import requestsimport timeBASE_URL = "https://www.deribit.com/api/v2"def get_option_trade_history(option_symbol, limit=10):endpoint = "/public/get_last_trades_by_instrument"params = {"instrument_name": option_symbol,"count": limit,}response = requests.get(BASE_URL + endpoint, params=params)if response.status_code == 200:data = response.json()trades = data.get("result", {}).get("trades", [])if trades:print(f"Trade History for {option_symbol}:n")for trade in trades:# Extract details from each tradetimestamp = trade.get('timestamp')iv = trade.get('iv')price = trade.get('price')direction = trade.get('direction')index_price = trade.get('index_price')amount = trade.get('amount')mark_price = trade.get('mark_price')contracts = trade.get('contracts')# Format timestamptimestamp_str = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(timestamp / 1000)) if timestamp else "N/A"# Print trade detailsprint(f"Timestamp: {timestamp_str}, Price: {price}, IV: {iv}, Direction: {direction}, "f"Index Price: {index_price}, Amount: {amount}, Mark Price: {mark_price}, Contracts: {contracts}n")else:print(f"No trade history available for {option_symbol}.")else:print(f"Error fetching trade history for {option_symbol}: {response.status_code}")print(f"Response: {response.text}")if __name__ == "__main__":option_symbol = "BTC-9DEC24-102000-C"limit = 10get_option_trade_history(option_symbol, limit)
The script defines a function, get_option_trade_history
, which constructs a request to retrieve the last trades for the given option symbol and limit. That options symbol is BTC-9DEC24-102000-C
, or: a call of $102,000USD on December 9, 2024.
Upon receiving a successful response, the script extracts key trade details such as:
- timestamp
- price
- implied volatility (IV)
- direction
- index price
- amount
- mark price
- contracts
It formats the timestamp for readability and prints the trade information. If no trades are found or if there's an error in the API call, appropriate messages are displayed.
The script runs the function on the symbol and limit when executed, allowing us to easily review recent trade data. It will print out something like the following - keep scrolling to the right to see the full table:
Timestamp | Price | IV | Direction | Index Price | Amount | Mark Price | Contracts |
---|---|---|---|---|---|---|---|
2024-12-08 19:05:54 | 0.001 | 47.5 | sell | 99736.33 | 0.1 | 0.00112418 | 0.1 |
2024-12-08 15:20:11 | 0.0011 | 45.08 | sell | 99567.81 | 0.1 | 0.00121224 | 0.1 |
2024-12-08 15:07:24 | 0.0012 | 45.95 | sell | 99544.18 | 0.2 | 0.00112608 | 0.2 |
Ingesting Data into QuestDB
Instead of just printing the data, we'll update our script to send it to QuestDB using a new insert_into_questdb
function, which formats the timestamp appropriately.
Not running? Check out our quick start guide.
For this, we start QuestDB via Docker:
docker run \-p 9000:9000 \-p 9009:9009 \-p 8812:8812 \-p 9003:9003 \questdb/questdb:8.2.1
We will then use the QuestDB Python client library so let’s install that first:
pip install questdb
After that, we'll import that library and write a little helper function to publish data into QuestDB:
from questdb.ingress import Sender, TimestampNanos…def insert_into_questdb(trade_data):try:conf = "http::addr=localhost:9000;username=admin;password=quest;"with Sender.from_conf(conf) as sender:for trade in trade_data:# Send the row to QuestDBsender.row(TABLE_NAME,symbols={'option_symbol': trade["option_symbol"], 'direction': trade["direction"]},columns={"price": trade["price"],"index_price": trade["index_price"],"amount": trade["amount"],"mark_price": trade["mark_price"],"contracts": trade["contracts"]},# Wrap nanos to match QuestDB's default timestamp formatat=TimestampNanos(trade["timestamp"] * 1_000_000))sender.flush()
The insert_into_questdb
function sends trade data to QuestDB for storage. It establishes a connection using our desired configuration and iterates through the provided trade data.
For each trade, it sends relevant details — such as option symbol, price, index price, amount, mark price, and contracts — along with a timestamp formatted in nanoseconds. After processing all trades, it flushes the sender to ensure the data is committed to QuestDB.
We'll start with the full code, then break it down in an explanation:
import requestsimport timefrom questdb.ingress import Sender, TimestampNanosBASE_URL = "https://www.deribit.com/api/v2"TABLE_NAME = "btc_option_trades"# Function to fetch last trades for a specific optiondef get_option_trade_history(option_symbol, limit=1000):endpoint = "/public/get_last_trades_by_instrument"params = {"instrument_name": option_symbol, # Specify the option symbol like BTC-9DEC24-88000-P"count": limit, # Limit the number of trades returned (e.g., 10, 100, etc.)}print(f"Request Parameters:nInstrument: {option_symbol}nLimit: {limit}")response = requests.get(BASE_URL + endpoint, params=params)if response.status_code == 200:data = response.json()trades = data.get("result", {}).get("trades", [])if trades:print(f"Trade History for {option_symbol}:n")trade_data = []for trade in trades:timestamp = trade.get('timestamp')iv = trade.get('iv')price = trade.get('price')direction = trade.get('direction')index_price = trade.get('index_price')amount = trade.get('amount')mark_price = trade.get('mark_price')contracts = trade.get('contracts')trade_data.append({"timestamp": timestamp,"option_symbol": option_symbol,"price": price,"iv": iv,"direction": direction,"index_price": index_price,"amount": amount,"mark_price": mark_price,"contracts": contracts})# Insert data into QuestDBinsert_into_questdb(trade_data)else:print(f"No trade history available for {option_symbol}.")else:print(f"Error fetching trade history for {option_symbol}: {response.status_code}")print(f"Response: {response.text}")def insert_into_questdb(trade_data):try:conf = "http::addr=localhost:9000;username=admin;password=quest;"with Sender.from_conf(conf) as sender:for trade in trade_data:sender.row(TABLE_NAME,symbols={'option_symbol': trade["option_symbol"], 'direction': trade["direction"]},columns={"price": trade["price"],"index_price": trade["index_price"],"amount": trade["amount"],"mark_price": trade["mark_price"],"contracts": trade["contracts"]},at=TimestampNanos(trade["timestamp"] * 1_000_000))sender.flush()print("Data successfully inserted into QuestDB!")except Exception as e:print(f"Error inserting data into QuestDB: {e}")if __name__ == "__main__":option_symbol = "BTC-9DEC24-102000-C" # Replace with any instrument symbol you getlimit = 1000 # Number of trades to fetch (you can adjust this value)get_option_trade_history(option_symbol, limit)
Our above starter script defines two main functions:
-
get_option_trade_history
: Fetches the last trades for our chosen Bitcoin options symbol. It then constructs an API request, processes the response, and extracts key trade details such as timestamp, price, and direction. If trades are found, it compiles the data into a list for further processing. -
insert_into_questdb
: Takes the compiled trade data and sends it to QuestDB for storage. It establishes a connection, formats the timestamp in nanoseconds, and inserts relevant trade details. After all trades are sent, it flushes the sender to ensure the data is committed.
After we run this code, we can see our data arrive into QuestDB:
Alright, we've got some juicy market data into QuestDB.
What now?
Querying the data
Let's put together some powerful queries to see what we can learn.
We'll look at a few different window functions.
Moving average
To calculate a moving average price of the last 10 trades, we can set a ROWS-based frame:
SELECTtimestamp,option_symbol,price,avg(price) OVER (PARTITION BY option_symbolORDER BY timestampROWS BETWEEN 9 PRECEDING AND CURRENT ROW) AS moving_avg_price_last_10FROM btc_option_tradesWHERE option_symbol = 'BTC-9DEC24-102000-C'ORDER BY timestamp;
For each trade of the given option_symbol
's, this query averages the current and the previous 9 trades (10 total) to provide a rolling average price:
timestamp | option_symbol | price | moving_avg_price_last_10 |
---|---|---|---|
2024-12-06T20:02:12.186000Z | BTC-9DEC24-102000-C | 0.014 | 0.014 |
2024-12-06T20:02:35.032000Z | BTC-9DEC24-102000-C | 0.0135 | 0.01375 |
2024-12-06T20:04:46.643000Z | BTC-9DEC24-102000-C | 0.0135 | 0.0136666666666 |
2024-12-06T20:07:12.377000Z | BTC-9DEC24-102000-C | 0.013 | 0.0135 |
5-minute rolling average
Time-series databases love time.
Let's look at a 5-minute rolling average of the price for our chosen option symbol:
SELECTtimestamp,option_symbol,price,avg(price) OVER (PARTITION BY option_symbolORDER BY timestampRANGE BETWEEN '5' MINUTE PRECEDING AND CURRENT ROW) AS avg_price_last_5mFROM btc_option_tradesWHERE option_symbol = 'BTC-9DEC24-102000-C'ORDER BY timestamp;
For each trade, QuestDB finds all trades from the same option_symbol that occurred in the last 5 minutes (relative to the current row’s timestamp) and calculates the average price over that interval:
timestamp | option_symbol | price | avg_price_last_5m |
---|---|---|---|
2024-12-06T20:02:12.186000Z | BTC-9DEC24-102000-C | 0.014 | 0.014 |
2024-12-06T20:02:35.032000Z | BTC-9DEC24-102000-C | 0.0135 | 0.01375 |
2024-12-06T20:02:35.032000Z | BTC-9DEC24-102000-C | 0.0135 | 0.0136666666666 |
2024-12-06T20:04:46.643000Z | BTC-9DEC24-102000-C | 0.0135 | 0.013625 |
Separate direction, summarize volume
Now, how can we compare the volume difference between buy and sell trades?
A rolling 15-minute window of traded amount could help:
SELECTtimestamp,option_symbol,direction,amount,sum(amount) OVER (PARTITION BY option_symbol, directionORDER BY timestampRANGE BETWEEN '15' MINUTE PRECEDING AND CURRENT ROW) AS amount_15mFROM btc_option_tradesORDER BY timestamp;
For each row, we take trades from the same option_symbol
and direction
in the past 15 minutes and sums their amounts. This can help reveal short-term buying or selling pressure trends:
timestamp | option_symbol | direction | amount | amount_15m |
---|---|---|---|---|
2024-12-06T20:02:12.186000Z | BTC-9DEC24-102000-C | buy | 0.6 | 0.6 |
2024-12-06T20:02:35.032000Z | BTC-9DEC24-102000-C | sell | 0.7 | 0.7 |
2024-12-06T20:02:35.032000Z | BTC-9DEC24-102000-C | sell | 0.7 | 1.4 |
2024-12-06T20:04:46.643000Z | BTC-9DEC24-102000-C | sell | 0.6 | 2.0 |
Extending our script
You know that old saying "the riper the berry, the sweeter the juice"? Well, in capital markets, the more data we have, the more we can learn. Let's see how we can ripen the berry, as it were.
Data fetch intervals
Our demo only pulls in 10000 rows of data. To ingest more data, we can pull the data in chunks by running our script on an interval. The public APIs are rate-limited, to what extent is not immediately clear in the documentation. However, an agreeable amount of data appears to be 1000 rows every 60 seconds.
To handle that, we can update our script to look something like so:
from datetime import datetime...def continuous_data_collection(option_symbol, limit=1000, interval_seconds=60):print(f"Starting continuous data collection for {option_symbol}")print(f"Fetching {limit} trades every {interval_seconds} seconds")try:while True:current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")print(f"\n[{current_time}] Fetching new batch of trades...")get_option_trade_history(option_symbol, limit)print(f"Waiting {interval_seconds} seconds until next fetch...")time.sleep(interval_seconds)except KeyboardInterrupt:print("\nData collection stopped by user")except Exception as e:print(f"\nError occurred: {e}")if __name__ == "__main__":option_symbol = "BTC-9DEC24-102000-C"continuous_data_collection(option_symbol,limit=1000, # Fetch 1000 trades per requestinterval_seconds=60 # Wait 1 minute between requests)
This will create a steady flow of fresh data for our target option symbol into QuestDB.
Alternatively, if we are able to get a Deribit API license, we can subscribe to the price and market data websocket to stream data instead. This opens a whole new world of possibilities. And QuestDB is well-suited for that.
Unpacking options data
Above, we chose one option symbol: BTC-9DEC24-102000-C
.
Let's break down what each part means:
Underlying Asset (BTC)
- Indicates that the option is based on Bitcoin
- We could have chosen Ethereum, or any other provided asset
Expiration Date (9DEC24)
- Day: 9th
- Month: December (DEC)
- Year: 2024 (24)
- Full date: December 9, 2024
Strike Price (102000)
- Represents $102,000 USD per BTC
- This is the price at which the option can be exercised
Option Type (C)
- C = Call option
- P = Put option (for other contracts)
So putting it all together:
BTC-9DEC24-102000-C│ │ │ ││ │ │ └─ Call option│ │ └──────── Strike price ($102,000)│ └─────────────── Expires Dec 9, 2024└─────────────────── Bitcoin underlying
This represents a Bitcoin call option that expires on December 9, 2024, with a strike price of $102,000. We can use the first script we demonstrated to fetch new options, and plug them as needed.
Summary
This article fetched Bitcoin options data from Deribit’s public API. After that, we stored it in QuestDB for deeper analysis, and then ran a few powerful SQL queries to uncover trends. We also looked at how to parse an option symbol like BTC-9DEC24-102000-C
.
By integrating Python code for data retrieval and QuestDB’s powerful analytical window functions, we were able to compute rolling averages, summarize trade directions, and handle continuous data collection — all in just a few minutes and from publicly accessible Bitcoin options data sources.
Want to chat with us? Holler on social media or join in our engaging Community Forum or our public Slack.
For more financial blogs, checkout: