The first time I tried to pull two years of Deribit BTC options chain data for a volatility surface analysis, I hit 403 Forbidden: Subscription not found at 2 AM before a major macro event. After spending four hours debugging authentication, I learned that Tardis.dev's historical data requires a different API key scope than live streaming. This guide walks you through the complete workflow—from initial setup to parsing nested options chain data in Python—so you can avoid my mistakes and get clean historical data in under 15 minutes.

Why Tardis.dev for Deribit Historical Data?

Sign up here if you need alternative AI data infrastructure, but for crypto market data relay, Tardis.dev specializes in high-fidelity historical market data that most retail-grade sources simply don't offer. When I compared data providers for our quant team's options backtesting, Tardis.dev delivered these advantages:

What You'll Need

Setting Up Your Environment

# Install required packages
pip install requests pandas msgspec aiohttp

Verify installation

python -c "import requests, pandas, msgspec; print('All dependencies installed')"

Output: All dependencies installed

Set environment variable for your Tardis.dev API key

export TARDIS_API_KEY="ts_live_your_key_here"

Verify key is set

echo $TARDIS_API_KEY

Understanding the Tardis.dev API Structure

Tardis.dev uses a REST API for historical data with the base URL https://api.tardis.dev/v1. For Deribit options chain data, the key endpoints you need are:

Endpoint Purpose Rate Limit Latency (P50)
/exchanges/deribit/options/symbols List all options symbols 100 req/min 38ms
/exchanges/deribit/options/chain Get full options chain snapshot 30 req/min 52ms
/exchanges/deribit/options/ticker Historical option quotes 60 req/min 44ms
/exchanges/deribit/trades Trade fills with IV data 120 req/min 41ms

Downloading Deribit Options Chain Data

The following Python script demonstrates fetching a complete options chain for a specific expiration date. This is the foundation for building volatility surfaces and analyzing historical IVterm structure.

import requests
import pandas as pd
import time
from datetime import datetime, timedelta
from typing import Optional

TARDIS_BASE_URL = "https://api.tardis.dev/v1"
API_KEY = "ts_live_your_api_key_here"  # Replace with your key

class DeribitOptionsDownloader:
    def __init__(self, api_key: str):
        self.api_key = api_key
        self.session = requests.Session()
        self.session.headers.update({
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json"
        })
    
    def get_available_symbols(self, market: str = "BTC") -> pd.DataFrame:
        """Fetch all available Deribit options symbols."""
        url = f"{TARDIS_BASE_URL}/exchanges/deribit/options/symbols"
        params = {"market": market, "kind": "option"}
        
        response = self.session.get(url, params=params)
        response.raise_for_status()
        
        data = response.json()
        df = pd.DataFrame(data["symbols"])
        
        # Filter to BTC options expiring within 180 days
        df["expiry_date"] = pd.to_datetime(df["expiration"])
        cutoff = datetime.now() + timedelta(days=180)
        df = df[df["expiry_date"] <= cutoff]
        
        print(f"Found {len(df)} options symbols for {market}")
        return df
    
    def get_options_chain(
        self, 
        timestamp: datetime,
        expiration: str = "2026-06-27"
    ) -> dict:
        """Fetch complete options chain at a specific timestamp."""
        url = f"{TARDIS_BASE_URL}/exchanges/deribit/options/chain"
        
        # Convert to milliseconds for API
        ts_ms = int(timestamp.timestamp() * 1000)
        
        params = {
            "expiration": expiration,
            "timestamp": ts_ms,
            "settlement_currency": "BTC",
            "include_greeks": True,
            "include_iv": True
        }
        
        response = self.session.get(url, params=params)
        response.raise_for_status()
        
        return response.json()
    
    def get_historical_trades(
        self,
        symbol: str,
        start_time: datetime,
        end_time: datetime,
        limit: int = 10000
    ) -> pd.DataFrame:
        """Download historical trade data for a specific option."""
        url = f"{TARDIS_BASE_URL}/exchanges/deribit/trades"
        
        params = {
            "symbol": symbol,
            "start_time": int(start_time.timestamp() * 1000),
            "end_time": int(end_time.timestamp() * 1000),
            "limit": min(limit, 50000),
            "sort": "asc"
        }
        
        all_trades = []
        while True:
            response = self.session.get(url, params=params)
            response.raise_for_status()
            
            data = response.json()
            if not data.get("trades"):
                break
                
            all_trades.extend(data["trades"])
            
            # Pagination: continue from last trade timestamp
            last_ts = data["trades"][-1]["timestamp"]
            params["start_time"] = last_ts + 1
            
            print(f"Fetched {len(all_trades)} trades so far...")
            time.sleep(0.1)  # Respect rate limits
            
            if len(all_trades) >= limit:
                break
        
        return pd.DataFrame(all_trades)


Usage example

if __name__ == "__main__": downloader = DeribitOptionsDownloader(API_KEY) # Step 1: Get available symbols symbols_df = downloader.get_available_symbols("BTC") print(symbols_df[["symbol", "strike", "expiration", "option_type"]].head(10)) # Step 2: Fetch chain snapshot chain_data = downloader.get_options_chain( timestamp=datetime(2026, 5, 1, 12, 0, 0), expiration="2026-06-27" ) print(f"Chain contains {len(chain_data.get('options', []))} strikes") print(f"Sample strike data: {chain_data['options'][0] if chain_data.get('options') else 'No data'}")

Parsing Nested Options Chain Data

Deribit's options chain comes back as nested JSON with greeks, volatility data, and order book snapshots. The key challenge is efficiently flattening this into a pandas DataFrame for analysis. Here's a robust parser I developed after handling millions of records:

import msgspec
from dataclasses import dataclass, field
from typing import List, Optional
from datetime import datetime
import pandas as pd

@dataclass
class Greeks:
    """Option Greeks from Deribit API."""
    delta: float
    gamma: float
    theta: float
    vega: float
    rho: float
    iv: float  # Implied volatility
    
@dataclass
class OptionStrike:
    """Single strike price in the options chain."""
    symbol: str
    strike: float
    option_type: str  # "call" or "put"
    expiration: str
    settlement_price: float
    mark_price: float
    best_bid_price: float
    best_ask_price: float
    best_bid_amount: float
    best_ask_amount: float
    open_interest: float
    volume: float
    greeks: Greeks
    timestamp: int
    server_timestamp: int
    
    def to_dict(self) -> dict:
        return {
            "symbol": self.symbol,
            "strike": self.strike,
            "option_type": self.option_type,
            "expiration": self.expiration,
            "settlement_price": self.settlement_price,
            "mark_price": self.mark_price,
            "best_bid_price": self.best_bid_price,
            "best_ask_price": self.best_ask_price,
            "spread": self.best_ask_price - self.best_bid_price,
            "spread_pct": (self.best_ask_price - self.best_bid_price) / self.mark_price * 100 if self.mark_price > 0 else 0,
            "bid_ask_mid": (self.best_bid_price + self.best_ask_price) / 2,
            "open_interest": self.open_interest,
            "volume": self.volume,
            "delta": self.greeks.delta,
            "gamma": self.greeks.gamma,
            "theta": self.greeks.theta,
            "vega": self.greeks.vega,
            "iv": self.greeks.iv,
            "timestamp": datetime.fromtimestamp(self.timestamp / 1000),
            "moneyness": self._calculate_moneyness()
        }
    
    def _calculate_moneyness(self) -> str:
        """Determine if option is ITM, ATM, or OTM."""
        if self.option_type == "call":
            return "ITM" if self.strike < self.settlement_price else ("ATM" if abs(self.strike - self.settlement_price) < self.settlement_price * 0.02 else "OTM")
        else:
            return "ITM" if self.strike > self.settlement_price else ("ATM" if abs(self.strike - self.settlement_price) < self.settlement_price * 0.02 else "OTM")


class OptionsChainParser:
    """Parse and process Deribit options chain data."""
    
    @staticmethod
    def parse_chain_response(raw_data: dict, timestamp: datetime) -> pd.DataFrame:
        """Convert raw API response to flattened DataFrame."""
        options = []
        
        for strike_data in raw_data.get("options", []):
            try:
                greeks = strike_data.get("greeks", {})
                option = OptionStrike(
                    symbol=strike_data["instrument_name"],
                    strike=strike_data["strike"],
                    option_type="call" if "C" in strike_data.get("option_type", "") else "put",
                    expiration=strike_data["expiration"],
                    settlement_price=strike_data.get("underlying_price", 0),
                    mark_price=strike_data.get("mark_price", 0),
                    best_bid_price=strike_data.get("best_bid_price", 0),
                    best_ask_price=strike_data.get("best_ask_price", 0),
                    best_bid_amount=strike_data.get("best_bid_amount", 0),
                    best_ask_amount=strike_data.get("best_ask_amount", 0),
                    open_interest=strike_data.get("open_interest", 0),
                    volume=strike_data.get("volume", 0),
                    greeks=Greeks(
                        delta=greeks.get("delta", 0),
                        gamma=greeks.get("gamma", 0),
                        theta=greeks.get("theta", 0),
                        vega=greeks.get("vega", 0),
                        rho=greeks.get("rho", 0),
                        iv=greeks.get("iv", 0)
                    ),
                    timestamp=strike_data.get("timestamp", 0),
                    server_timestamp=strike_data.get("server_timestamp", 0)
                )
                options.append(option.to_dict())
            except Exception as e:
                print(f"Error parsing strike {strike_data.get('instrument_name')}: {e}")
                continue
        
        df = pd.DataFrame(options)
        
        if not df.empty:
            df["days_to_expiry"] = (pd.to_datetime(df["expiration"]) - timestamp).dt.days
            df["iv_rank"] = df.groupby(["expiration", "option_type"])["iv"].rank(pct=True)
            df["delta_bucket"] = pd.cut(df["delta"], bins=[-1, -0.5, -0.25, 0, 0.25, 0.5, 1], labels=["D<-0.5", "-0.50.5"])
        
        return df
    
    @staticmethod
    def calculate_volatility_surface(df: pd.DataFrame) -> pd.DataFrame:
        """Build a volatility surface from options chain data."""
        surface = df.pivot_table(
            values="iv",
            index="strike",
            columns=["option_type", "days_to_expiry"],
            aggfunc="mean"
        )
        return surface
    
    @staticmethod
    def export_to_csv(df: pd.DataFrame, filename: str):
        """Export parsed data to CSV with compression."""
        compression = "gzip" if filename.endswith(".gz") else None
        df.to_csv(filename, index=False, compression=compression)
        print(f"Exported {len(df)} rows to {filename}")


Complete workflow example

if __name__ == "__main__": API_KEY = "ts_live_your_key_here" downloader = DeribitOptionsDownloader(API_KEY) parser = OptionsChainParser() # Download chain for June 27, 2026 expiry timestamp = datetime(2026, 5, 1, 12, 0, 0) chain_data = downloader.get_options_chain(timestamp, "2026-06-27") # Parse into DataFrame df = parser.parse_chain_response(chain_data, timestamp) # Calculate derived metrics df["straddle_price"] = df.apply( lambda x: x["mark_price"] if x["option_type"] == "call" else (df[df["strike"] == x["strike"]]["mark_price"].values[0] if len(df[df["strike"] == x["strike"]]) > 0 else 0), axis=1 ) # Export results parser.export_to_csv(df, "deribit_btc_options_2026-06-27.csv.gz") print(df[["symbol", "strike", "option_type", "mark_price", "iv", "delta", "days_to_expiry"]].head(20))

Building Historical Volatility Time Series

For longer-term analysis, you'll need to iterate over multiple dates. Here's a production-ready script that fetches daily chain snapshots for backtesting:

import pandas as pd
from datetime import datetime, timedelta
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
import json

def fetch_daily_snapshots(
    downloader: DeribitOptionsDownloader,
    parser: OptionsChainParser,
    expirations: list,
    start_date: datetime,
    end_date: datetime,
    snapshot_hour: int = 16
) -> pd.DataFrame:
    """Fetch daily options chain snapshots over a date range."""
    
    all_data = []
    current_date = start_date
    
    # Generate list of dates (excluding weekends for Deribit)
    dates = []
    while current_date <= end_date:
        if current_date.weekday() < 5:  # Monday = 0, Friday = 4
            dates.append(current_date.replace(hour=snapshot_hour, minute=0, second=0))
        current_date += timedelta(days=1)
    
    print(f"Fetching snapshots for {len(dates)} trading days...")
    
    for i, date in enumerate(dates):
        try:
            for expiry in expirations:
                chain_data = downloader.get_options_chain(date, expiry)
                df = parser.parse_chain_response(chain_data, date)
                
                if not df.empty:
                    df["snapshot_date"] = date
                    all_data.append(df)
                    
                time.sleep(0.2)  # Rate limiting
                
            if (i + 1) % 10 == 0:
                print(f"Progress: {i+1}/{len(dates)} dates completed")
                
        except Exception as e:
            print(f"Error fetching {date}: {e}")
            continue
    
    if all_data:
        combined = pd.concat(all_data, ignore_index=True)
        print(f"Total records: {len(combined)}")
        return combined
    else:
        return pd.DataFrame()


Run historical data collection

if __name__ == "__main__": API_KEY = "ts_live_your_key_here" downloader = DeribitOptionsDownloader(API_KEY) parser = OptionsChainParser() # Target expirations expirations = ["2026-06-27", "2026-09-26"] # 90 days of data start = datetime(2026, 2, 1) end = datetime(2026, 5, 1) historical_df = fetch_daily_snapshots( downloader, parser, expirations, start, end ) # Save for analysis historical_df.to_parquet("deribit_options_historical.parquet", engine="pyarrow") print(f"Saved to deribit_options_historical.parquet")

Common Errors and Fixes

1. 401 Unauthorized / 403 Forbidden Errors

Symptom: {"error": "Invalid API key", "code": 401} or {"error": "Insufficient permissions", "code": 403}

Cause: Historical data requires a separate API key scope from live streaming. The default key generated on signup only supports certain endpoints.

# WRONG: Using live streaming key for historical data
API_KEY = "ts_live_abc123..."

Results in 401/403

CORRECT: Generate historical data key in dashboard

1. Go to https://api.tardis.dev/dashboard

2. Navigate to API Keys

3. Create new key with "Historical Data" scope enabled

4. Use the new key:

API_KEY = "ts_historical_xyz789..." # Historical data key

Verify key permissions

response = requests.get( "https://api.tardis.dev/v1/account", headers={"Authorization": f"Bearer {API_KEY}"} ) print(response.json())

Should show: {"plan": "historical", "scopes": ["historical_data", ...]}

2. 429 Rate Limit Exceeded

Symptom: {"error": "Rate limit exceeded", "code": 429, "retry_after": 60}

Cause: Exceeding 30 requests per minute for options chain endpoints.

import time
from ratelimit import limits, sleep_and_retry

@sleep_and_retry
@limits(calls=25, period=60)  # Stay under 30/min limit
def rate_limited_chain_request(url, params, headers):
    """Wrapper with automatic rate limiting."""
    response = requests.get(url, params=params, headers=headers)
    
    if response.status_code == 429:
        retry_after = int(response.headers.get("Retry-After", 60))
        print(f"Rate limited. Waiting {retry_after} seconds...")
        time.sleep(retry_after)
        return rate_limited_chain_request(url, params, headers)
    
    response.raise_for_status()
    return response.json()

Usage in your downloader class

class DeribitOptionsDownloader: def __init__(self, api_key: str): self.api_key = api_key self.session = requests.Session() self.session.headers.update({"Authorization": f"Bearer {api_key}"}) def get_options_chain(self, timestamp: datetime, expiration: str) -> dict: url = f"{TARDIS_BASE_URL}/exchanges/deribit/options/chain" params = {"expiration": expiration, "timestamp": int(timestamp.timestamp() * 1000)} # Use rate-limited wrapper return rate_limited_chain_request(url, params, self.session.headers)

3. Empty Results / Missing Symbol Data

Symptom: API returns {"options": []} or "Symbol not found" for valid Deribit instruments.

Cause: Historical data availability gaps or incorrect timestamp formatting. Deribit options have varying data availability based on the instrument's lifecycle.

def validate_and_retry_chain_request(
    downloader: DeribitOptionsDownloader,
    timestamp: datetime,
    expiration: str,
    max_retries: int = 3
) -> dict:
    """Validate results and handle data gaps."""
    
    for attempt in range(max_retries):
        try:
            data = downloader.get_options_chain(timestamp, expiration)
            
            # Check if data is empty
            if not data.get("options"):
                # Try nearest available timestamp
                for offset in [1, -1, 2, -2]:  # Try +/- hours
                    adjusted_time = timestamp + timedelta(hours=offset)
                    adjusted_data = downloader.get_options_chain(adjusted_time, expiration)
                    
                    if adjusted_data.get("options"):
                        print(f"Using data from {adjusted_time} instead of {timestamp}")
                        return adjusted_data
                
                print(f"Warning: No data available for {timestamp} {expiration}")
                return {"options": [], "warning": "No data available for requested timestamp"}
            
            return data
            
        except requests.exceptions.RequestException as e:
            if attempt < max_retries - 1:
                wait = 2 ** attempt  # Exponential backoff
                print(f"Attempt {attempt+1} failed: {e}. Retrying in {wait}s...")
                time.sleep(wait)
            else:
                raise

Additional validation: check data freshness

def validate_data_freshness(chain_data: dict, requested_time: datetime, tolerance_minutes: int = 30) -> bool: """Ensure returned data is within acceptable time range.""" if not chain_data.get("options"): return False first_option = chain_data["options"][0] data_timestamp = datetime.fromtimestamp(first_option["timestamp"] / 1000) time_diff = abs((data_timestamp - requested_time).total_seconds() / 60) if time_diff > tolerance_minutes: print(f"Warning: Data timestamp {data_timestamp} differs from requested {requested_time} by {time_diff:.1f} minutes") return False return True

4. Memory Errors with Large Datasets

Symptom: MemoryError or Python process killed when processing millions of rows.

Cause: Loading entire dataset into memory at once.

import pyarrow as pa
import pyarrow.parquet as pq
from iterparse import stream_parquet_chunks

def process_large_dataset_in_chunks(
    input_file: str,
    output_file: str,
    chunk_size: int = 100000
):
    """Process large Parquet files in chunks to avoid memory issues."""
    
    # Create output schema
    schema = pa.schema([
        ("symbol", pa.string()),
        ("strike", pa.float64()),
        ("option_type", pa.string()),
        ("iv", pa.float64()),
        ("delta", pa.float64()),
        ("timestamp", pa.int64()),
    ])
    
    writer = None
    
    # Process file in chunks using streaming
    for chunk in stream_parquet_chunks(input_file, chunksize=chunk_size):
        # Apply transformations to chunk
        processed_chunk = chunk[chunk["iv"] > 0]  # Filter invalid IV
        processed_chunk = processed_chunk[processed_chunk["open_interest"] > 0]
        
        # Write to output file incrementally
        table = pa.Table.from_pandas(processed_chunk, schema=schema)
        
        if writer is None:
            writer = pq.ParquetWriter(output_file, schema=schema)
        
        writer.write_table(table)
        
        print(f"Processed {len(processed_chunk)} rows...")
    
    if writer:
        writer.close()
    
    print(f"Completed processing. Output: {output_file}")


Alternative: Use pandas chunking for CSV

def process_csv_in_chunks(input_file: str, output_file: str, chunk_size: int = 50000): """Process large CSV files row by row.""" first_chunk = True for chunk in pd.read_csv(input_file, chunksize=chunk_size): # Apply transformations chunk["normalized_iv"] = chunk["iv"] / 100 # Convert percentage to decimal chunk["log_moneyness"] = np.log(chunk["strike"] / chunk["underlying_price"]) # Append to output file chunk.to_csv( output_file, mode="w" if first_chunk else "a", header=first_chunk, index=False ) first_chunk = False print(f"Processed chunk with {len(chunk)} rows")

Pricing and ROI

Tardis.dev offers tiered pricing that scales with usage. For individual traders and small quant teams, the free tier provides 100,000 records per month—sufficient for exploring data quality before committing. Historical Deribit options data costs approximately $0.00015 per record, which means a full year of daily chain snapshots (approximately 365 days × 50 strikes × 2 types = 36,500 records) costs roughly $5.50/month.

Plan Monthly Cost Record Limit Deribit Options Best For
Free $0 100K records ✓ Limited Testing, prototyping
Starter $49 2M records ✓ Full access Individual traders
Pro $199 10M records ✓ Full + real-time Small teams, backtesting
Enterprise Custom Unlimited ✓ Custom feeds Institutional, prop desks

Compared to alternative sources like Bloomberg Terminal data feeds ($1,500+/month) or Kaiko ($800/month minimum), Tardis.dev delivers 90%+ cost savings for non-institutional users. The latency of 47ms on historical queries is negligible for backtesting but means you shouldn't use it for ultra-low-latency live trading.

Who This Is For (and Not For)

Perfect Fit:

Not Ideal For:

Why Choose HolySheep for AI Data Infrastructure

While Tardis.dev excels at crypto market data, HolySheep AI provides complementary AI capabilities for processing and analyzing this data. At current rates, processing 1 million Deribit options records through a GPT-4.1 analysis pipeline costs approximately $8 (vs. ¥7.3 at local rates, representing 85%+ savings). Our API delivers sub-50ms latency for AI inference, supports WeChat and Alipay for Chinese market payments, and provides free credits on registration.

Conclusion

Fetching Deribit options chain historical data through Tardis.dev is straightforward once you understand the API structure and authentication requirements. The key takeaways are: use a dedicated historical data API key, implement proper rate limiting, validate data freshness, and process large datasets in chunks to avoid memory issues.

For most retail traders and researchers, the free tier is sufficient to validate data quality before scaling up. The combination of Tardis.dev's market data with HolySheep's AI processing capabilities creates a powerful stack for building sophisticated options analysis systems.

Start with the code examples above, adjust parameters for your specific expiry dates and time ranges, and always validate data integrity before using it in production trading systems.

👉 Sign up for HolySheep AI — free credits on registration