# --- # jupyter: # jupytext: # cell_metadata_filter: tags,-all # formats: ipynb,py:percent # text_representation: # extension: .py # format_name: percent # format_version: '1.3' # jupytext_version: 1.19.3 # kernelspec: # display_name: Python 3 (ipykernel) # language: python # name: python3 # --- # %% [markdown] # # Crypto Premium Index: Funding Rate Arbitrage Data # # **Docker image**: `ml4t` # # ## Purpose # Explore the Binance perpetual-futures premium index — the per-period deviation between # perpetual and spot prices that determines the funding rate paid every 8 hours. The # notebook profiles 19 USDT-margined perpetuals from 2020-01 to 2025-12 and turns the # raw premium series into estimated funding APY for the funding-arbitrage case study. # # ## Learning Objectives # - Load the 8-hour premium-index panel and read its schema. # - Characterize the distribution and time-series behavior of BTC premium. # - Compare premium volatility across majors and altcoins. # - Translate premium into Binance's clamped funding rate and an annualized return. # # ## Book reference # Chapter 2, §2.2 (asset-class market data — crypto datasets). The funding-arbitrage # case study built on this dataset lives in `case_studies/crypto_perps_funding/`. # # ## Prerequisites # - Crypto perpetual + premium parquet files materialized under `ML4T_DATA_PATH`. # - Loader `data.load_crypto_premium`. # # --- # %% """Crypto Premium Index — Funding rate arbitrage data exploration.""" import numpy as np import plotly.express as px import plotly.graph_objects as go import polars as pl from plotly.subplots import make_subplots from data import load_crypto_premium # %% tags=["parameters"] # Production defaults — Papermill injects overrides for CI # %% [markdown] # --- # # ## Section 1: Understanding the Premium Index # # ### What is the Premium Index? # # The **Premium Index** measures the deviation between perpetual futures prices and spot prices: # # $$\text{Premium Index} = \frac{\text{Perpetual Price} - \text{Spot Price}}{\text{Spot Price}}$$ # # ### Key Properties: # # 1. **Positive Premium**: Perpetual > Spot → Longs pay Shorts (bullish sentiment) # 2. **Negative Premium**: Perpetual < Spot → Shorts pay Longs (bearish sentiment) # 3. **Funding Rate**: Derived from premium index, paid every 8 hours on Binance # # ### Arbitrage Opportunity # # When premium is significantly positive: # - **Long Spot** + **Short Perpetual** = Collect funding payments # - Market-neutral position captures the funding rate # # When premium is significantly negative: # - **Short Spot** + **Long Perpetual** = Collect funding payments # %% # Load the combined premium index data premium_df = load_crypto_premium(frequency="8h") print(f"Total rows: {len(premium_df):,}") print(f"Columns: {premium_df.columns}") print("\nSchema:") for col, dtype in premium_df.schema.items(): print(f" {col}: {dtype}") # %% # Overview by asset symbol_stats = ( premium_df.group_by("symbol") .agg( [ pl.col("timestamp").min().alias("start"), pl.col("timestamp").max().alias("end"), pl.len().alias("rows"), pl.col("premium_index_close").mean().alias("avg_premium"), pl.col("premium_index_close").std().alias("std_premium"), ] ) .sort("rows", descending=True) ) symbol_stats # %% # Sample data - BTC premium index btc_premium = premium_df.filter(pl.col("symbol") == "BTCUSDT").sort("timestamp") print(f"BTC Premium Index: {len(btc_premium):,} 8h observations") print(f"Date range: {btc_premium['timestamp'].min()} to {btc_premium['timestamp'].max()}") btc_premium.head(10) # %% [markdown] # --- # # ## Section 2: Premium Index Distribution # # Understanding the distribution of premium values is crucial for: # 1. Setting entry/exit thresholds for arbitrage # 2. Risk management (tail events) # 3. Comparing opportunities across assets # %% # BTC Premium distribution btc_close = btc_premium["premium_index_close"].to_numpy() # Convert to basis points for readability btc_close_bps = btc_close * 10000 fig = go.Figure() fig.add_trace( go.Histogram( x=btc_close_bps, nbinsx=100, name="BTC Premium", marker_color="#F7931A", ) ) # Add vertical lines for mean and +-2 std mean_val = np.mean(btc_close_bps) std_val = np.std(btc_close_bps) fig.add_vline( x=mean_val, line_dash="dash", line_color="red", annotation_text=f"Mean: {mean_val:.1f} bps" ) fig.add_vline( x=mean_val + 2 * std_val, line_dash="dot", line_color="green", annotation_text=f"+2σ: {mean_val + 2 * std_val:.1f} bps", ) fig.add_vline( x=mean_val - 2 * std_val, line_dash="dot", line_color="green", annotation_text=f"-2σ: {mean_val - 2 * std_val:.1f} bps", ) fig.update_layout( title="BTC Premium Index Distribution (Basis Points)", xaxis_title="Premium Index (bps)", yaxis_title="Frequency", height=400, template="plotly_white", ) fig.show() # %% print("BTC Premium Statistics:") print(f" Mean: {mean_val:.2f} bps") print(f" Std: {std_val:.2f} bps") print(f" Min: {np.min(btc_close_bps):.2f} bps") print(f" Max: {np.max(btc_close_bps):.2f} bps") print(f" Skew: {((btc_close_bps - mean_val) ** 3).mean() / std_val**3:.2f}") # %% # Compare premium distributions across major assets major_symbols = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT"] fig = make_subplots(rows=2, cols=2, subplot_titles=major_symbols) colors = ["#F7931A", "#627EEA", "#00FFA3", "#F3BA2F"] for idx, (symbol, color) in enumerate(zip(major_symbols, colors, strict=False)): row = idx // 2 + 1 col = idx % 2 + 1 data = premium_df.filter(pl.col("symbol") == symbol)["premium_index_close"].to_numpy() * 10000 fig.add_trace( go.Histogram(x=data, nbinsx=50, marker_color=color, name=symbol), row=row, col=col ) fig.update_layout( title="Premium Index Distributions (bps) - Major Cryptos", height=500, showlegend=False, template="plotly_white", ) fig.show() # %% [markdown] # --- # # ## Section 3: Time Series Analysis # # Premium index varies over time based on market sentiment. Let's analyze: # 1. Long-term trends # 2. Regime changes (bull vs bear markets) # 3. Correlation with price movements # %% # BTC premium time series. # Data is on an 8h cadence (3 obs per day), so 30 days = 90 windows. PERIODS_PER_DAY = 3 # Binance funding interval is 8h ROLLING_WINDOW_DAYS = 30 btc_bps = btc_premium.with_columns( (pl.col("premium_index_close") * 10000).alias("premium_bps"), ).with_columns( pl.col("premium_index_close") .rolling_mean(window_size=ROLLING_WINDOW_DAYS * PERIODS_PER_DAY) .alias("rolling_30d"), ) # %% # Plot raw 8h premium and 30-day rolling mean fig = make_subplots( rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, subplot_titles=[ "BTC Premium Index (bps, 8h observations)", f"{ROLLING_WINDOW_DAYS}-Day Rolling Average", ], ) fig.add_trace( go.Scatter( x=btc_bps["timestamp"].to_list(), y=btc_bps["premium_bps"].to_list(), mode="lines", name="8h Premium", line=dict(color="#F7931A", width=1), opacity=0.6, ), row=1, col=1, ) fig.add_trace( go.Scatter( x=btc_bps["timestamp"].to_list(), y=(btc_bps["rolling_30d"] * 10000).to_list(), mode="lines", name="30-Day Rolling Avg", line=dict(color="red", width=2), ), row=2, col=1, ) fig.add_hline(y=0, line_dash="dash", line_color="gray", row=1, col=1) fig.add_hline(y=0, line_dash="dash", line_color="gray", row=2, col=1) fig.update_layout(height=600, template="plotly_white", showlegend=False) fig.update_yaxes(title_text="Premium (bps)", row=1, col=1) fig.update_yaxes(title_text="Premium (bps)", row=2, col=1) fig.show() # %% # Report the observed BTC range so the reader can size the y-axis. btc_bps_series = btc_bps["premium_bps"] print(f"BTC premium range: {btc_bps_series.min():.1f} to {btc_bps_series.max():.1f} bps") # %% # Identify premium regimes btc_regimes = btc_premium.with_columns( [ # Define regimes based on premium level pl.when(pl.col("premium_index_close") > 0.001) .then(pl.lit("High Premium (Bullish)")) .when(pl.col("premium_index_close") < -0.001) .then(pl.lit("Low Premium (Bearish)")) .otherwise(pl.lit("Neutral")) .alias("regime"), # Year for grouping pl.col("timestamp").dt.year().alias("year"), ] ) # Regime distribution by year (counts of 8h periods, ~1095 per full year) regime_dist = ( btc_regimes.group_by(["year", "regime"]) .agg(pl.len().alias("periods_8h")) .sort(["year", "regime"]) ) regime_dist.pivot(on="regime", index="year", values="periods_8h").fill_null(0) # %% [markdown] # --- # # ## Section 4: Cross-Asset Premium Comparison # # Different cryptocurrencies have different premium dynamics: # - **BTC/ETH**: Lower volatility, tighter premiums # - **Altcoins**: Higher volatility, wider premium swings # # This affects arbitrage opportunity selection. # %% # Calculate premium statistics for all assets premium_stats = ( premium_df.group_by("symbol") .agg( [ pl.col("premium_index_close").mean().alias("mean_premium"), pl.col("premium_index_close").std().alias("std_premium"), pl.col("premium_index_close").min().alias("min_premium"), pl.col("premium_index_close").max().alias("max_premium"), # Percentage of time premium > 10 bps (profitable arbitrage threshold) (pl.col("premium_index_close").abs() > 0.001).mean().alias("pct_above_10bps"), ] ) .sort("std_premium", descending=True) ) # Convert to basis points for display premium_stats_bps = premium_stats.with_columns( [ (pl.col("mean_premium") * 10000).round(2).alias("mean_bps"), (pl.col("std_premium") * 10000).round(2).alias("std_bps"), (pl.col("min_premium") * 10000).round(2).alias("min_bps"), (pl.col("max_premium") * 10000).round(2).alias("max_bps"), (pl.col("pct_above_10bps") * 100).round(1).alias("pct_above_10bps"), ] ).select(["symbol", "mean_bps", "std_bps", "min_bps", "max_bps", "pct_above_10bps"]) premium_stats_bps # %% # Scatter: Premium volatility vs mean premium fig = px.scatter( premium_stats_bps.to_pandas(), x="std_bps", y="mean_bps", size="pct_above_10bps", color="symbol", hover_name="symbol", title="Premium Volatility vs Mean Premium", labels={ "std_bps": "Premium Volatility (bps)", "mean_bps": "Mean Premium (bps)", "pct_above_10bps": "% Time > 10bps", }, ) fig.update_layout( height=600, template="plotly_white", legend=dict( orientation="h", yanchor="top", y=-0.15, xanchor="center", x=0.5, ), margin=dict(b=120), ) fig.show() print("\nInterpretation:") print("- Top-right quadrant: High volatility, positive bias (bullish altcoins)") print("- Larger bubbles: More arbitrage opportunities (premium often > 10bps)") # %% # Monthly premium heatmap # Note: Some months have extreme values (e.g., SOL during FTX collapse at -72 bps) # We clip the color scale at ±20 bps for better visualization of typical patterns monthly_premium = ( premium_df.with_columns([pl.col("timestamp").dt.strftime("%Y-%m").alias("month")]) .group_by(["symbol", "month"]) .agg(pl.col("premium_index_close").mean().alias("avg_premium")) ) # Pivot for heatmap heatmap_data = monthly_premium.pivot(on="month", index="symbol", values="avg_premium").sort( "symbol" ) # Get month columns in order month_cols = sorted([c for c in heatmap_data.columns if c != "symbol"]) assets = heatmap_data["symbol"].to_list() # Extract values for heatmap z_values = heatmap_data.select(month_cols).to_numpy() * 10000 # Convert to bps # Clip color scale at ±20 bps for better visualization COLOR_CLIP_BPS = 20 # %% fig = go.Figure( data=go.Heatmap( z=z_values, x=month_cols, y=assets, colorscale="RdBu", zmid=0, zmin=-COLOR_CLIP_BPS, zmax=COLOR_CLIP_BPS, colorbar=dict(title="Premium (bps)"), ) ) fig.update_layout( title=f"Monthly Average Premium by Asset (bps, color clipped at ±{COLOR_CLIP_BPS})", xaxis_title="Month", yaxis_title="Symbol", height=600, template="plotly_white", ) fig.show() # Report extremes that exceed color scale (shown as saturated colors) extremes = ( monthly_premium.filter(pl.col("avg_premium").abs() * 10000 > COLOR_CLIP_BPS) .with_columns((pl.col("avg_premium") * 10000).round(1).alias("avg_bps")) .select(["symbol", "month", "avg_bps"]) .sort("avg_bps") ) extremes # %% [markdown] # --- # # ## Section 5: Funding Rate Estimation # # Binance calculates funding rates from premium index every 8 hours: # # $$\text{Funding Rate} = \text{clamp}(\text{Premium Index}, -0.05\%, 0.05\%) + \text{Interest Rate}$$ # # Where Interest Rate ≈ 0.01% (0.03%/day). # # **Annualized Return** from funding collection: # $$\text{APY} = \text{Funding Rate} \times 3 \times 365$$ # %% # Calculate estimated funding rates using native Polars expressions # Formula: funding_rate = clamp(premium, -0.05%, 0.05%) + interest_rate # Interest rate ≈ 0.01% per 8h (0.0001) INTEREST_RATE = 0.0001 btc_funding = btc_premium.with_columns( # Clamp premium to [-0.05%, 0.05%] and add interest rate (pl.col("premium_index_close").clip(-0.0005, 0.0005) + INTEREST_RATE).alias("est_funding_rate"), ).with_columns( # Annualized return: 3 funding periods/day * 365 days * 100 for percentage (pl.col("est_funding_rate") * 3 * 365 * 100).alias("annualized_pct"), ) avg_funding_rate = float(btc_funding["est_funding_rate"].mean()) ann_min = float(btc_funding["annualized_pct"].min()) ann_max = float(btc_funding["annualized_pct"].max()) ann_mean = float(btc_funding["annualized_pct"].mean()) print("BTC Estimated Funding Rate Analysis:") print(f" Average funding rate (per 8h): {avg_funding_rate * 100:.4f}%") print(f" Annualized return (avg): {ann_mean:.1f}%") print(f" Annualized return (max): {ann_max:.1f}%") print(f" Annualized return (min): {ann_min:.1f}%") # %% # Visualize annualized funding returns over time # Note: Funding rate is clamped to ±0.05% per period, so annualized range is bounded # to approximately ±55% (3 periods/day × 365 days × 0.05%) fig = go.Figure() fig.add_trace( go.Scatter( x=btc_funding["timestamp"].to_list(), y=btc_funding["annualized_pct"].to_list(), mode="lines", name="Annualized Funding Return", line=dict(color="#F7931A", width=1), ) ) # Add horizontal lines for reference fig.add_hline(y=0, line_dash="dash", line_color="gray") fig.add_hline(y=20, line_dash="dot", line_color="green", annotation_text="20% APY") fig.add_hline(y=-20, line_dash="dot", line_color="red", annotation_text="-20% APY") y_padding = 10 fig.update_layout( title="BTC Estimated Annualized Funding Return (%)", xaxis_title="Date", yaxis_title="Annualized Return (%)", yaxis=dict(range=[ann_min - y_padding, ann_max + y_padding]), height=400, template="plotly_white", ) fig.show() print(f"Annualized funding return range: {ann_min:.1f}% to {ann_max:.1f}%") # %% # 8h periods where the estimated funding APY exceeds ±20%. # Note: the funding-rate clamp pins per-period funding at ±0.0005 + 0.0001 interest, # so the APY ceiling is 3 × 365 × 0.0006 × 100 ≈ 65.7% (and floor ≈ −43.8%); # the top rows therefore all sit at the clamp. high_conviction = btc_funding.filter(pl.col("annualized_pct").abs() > 20) print( f"High-conviction periods (|APY| > 20%): {len(high_conviction):,} of {len(btc_funding):,} 8h periods" ) print(f"Share of total: {len(high_conviction) / len(btc_funding) * 100:.1f}%") ( high_conviction.sort("annualized_pct", descending=True) .head(10) .select(["timestamp", "premium_index_close", "est_funding_rate", "annualized_pct"]) ) # %% [markdown] # --- # # ## Section 6: Using the CryptoDataManager # # The ml4t-data library provides a `CryptoDataManager` for convenient access to the premium index data. # %% # Using the CryptoDataManager (requires ml4t-data library) # This demonstrates the programmatic API for loading crypto data from ml4t.data.crypto import CryptoDataManager # noqa: F401 # CryptoDataManager provides a clean API for loading crypto data # For this notebook, we use direct parquet loading as shown above print("CryptoDataManager API available from ml4t-data library.") print("For this analysis, we use direct parquet loading for simplicity.") # %% [markdown] # --- # # ## Key Takeaways # # Profile of the Binance premium-index panel underpinning the funding-arbitrage # case study. # # ### Quantitative Findings # - **Panel scale**: 107,839 8h observations across 19 USDT-margined perpetuals, # 2020-01-01 → 2025-12-31. Coverage ranges from BTC/ETH (6,555 obs) down to # SUIUSDT (2,920 obs from May 2023). # - **Slight short bias**: All 19 symbols have a *negative* mean premium # (between −0.03 and −2.6 bps; MKR is essentially flat), so on average # perpetuals trade *below* spot — the raw funding flow is from shorts to # longs before adding the interest-rate baseline. # - **Volatility spectrum**: BTC has the tightest premium (std 5.6 bps). # ETH/ADA/DOT cluster at 6–7 bps (~1.2× BTC). The wide-tail altcoins are # SOL (std 36.7 bps, min −1,915 bps during the FTX collapse), XRP/UNI/COMP # (10–11 bps), reflecting episodic dislocation rather than steady-state # volatility. # - **Arbitrage frequency**: |premium| > 10 bps in 5–14 % of 8h periods # depending on the symbol (BTC 5.0 %, COMP/ATOM 13–14 %). # - **Funding APY**: Binance's clamped funding rate (±0.05 % + 0.01 % interest) # bounds the BTC annualized return at +65.7 % / −43.8 %. Realised mean is # −5.7 % over 2020-25; the clamp is hit in 82.9 % of 8h periods (driven by # the interest-rate baseline pushing |APY| above 20 % whenever premium is # small). # # ### Implications for the Funding-Arbitrage Case Study # - **Direction matters**: The negative mean premium means a *delta-neutral # short-spot / long-perpetual* leg captures the structural funding flow on # average for these symbols; the mirror trade only profits during transient # bullish dislocations. # - **Asset selection**: Wide-tail altcoins (SOL, XRP, COMP) offer the largest # per-event funding but expose the strategy to extreme premium tails. # BTC/ETH provide a tighter, more reliable funding stream. # - **Regime awareness**: The 30-day rolling premium swings between bull (2021) # and bear (2022) regimes; static thresholds will mis-fire — see the # `case_studies/crypto_perps_funding/` pipeline for the regime-aware signal # used downstream. # # **Next**: `12_fx_pairs_eda` profiles the third 24/7-adjacent dataset — # G10 FX pairs at 4h cadence — completing the global market-data tour.