Perp Basis Monitor
Monitor the perpetual futures funding basis by continuously tracking the price spread between perpetual swap mark prices and underlying spot index prices across exchanges, identifying funding rate arbitrage entry opportunities and shifts in aggregate market directional sentiment.
Perpetual Futures Basis Monitor — Crypto-Native
Category: Crypto-Native | Subcategory: Perpetuals
What This Notebook Does
Perpetual futures (perps) are crypto's unique trading instrument — futures contracts with no expiry date, kept anchored to spot price via a funding rate mechanism. The basis (perp price - spot price) and the funding rate are the most important crypto-native market signals.
Understanding the basis and funding rate is essential for:
- Detecting market sentiment and leverage buildup
- Finding cash-and-carry arbitrage opportunities
- Timing entries (high positive funding → longs overextended → potential correction)
- Building basis-capture strategies
This notebook:
- Explains the perpetual futures mechanism: funding rates, mark price, basis
- Simulates perp and spot price data with realistic funding rate dynamics
- Computes the basis (absolute and percentage), funding rate history, and open interest
- Detects extreme basis events: overheated longs (positive basis > threshold) and panic (negative basis)
- Builds a basis-based sentiment signal for spot trading
- Simulates a cash-and-carry trade: long spot + short perp to capture funding income
- Exports basis data and sentiment signals
Perpetual Futures Mechanics
| Concept | Description |
|---|---|
| Mark Price | Weighted average of multiple spot prices (avoids manipulation) |
| Basis | Perp price - Spot price. Positive = perp at premium |
| Funding Rate | Payment between longs and shorts every 8h to keep basis near zero |
| Positive Funding | Longs pay shorts → longs are dominant → market bullish but overextended |
| Negative Funding | Shorts pay longs → shorts dominant → bearish but shorts may be squeezed |
| Cash-and-Carry | Long spot + Short perp → earn funding rate with near-zero directional risk |
Rule of Thumb
- Funding rate > 0.1% per 8h (0.3% daily) → extreme greed, correction risk
- Funding rate < -0.05% per 8h → extreme fear / squeeze risk
!pip install numpy pandas matplotlib seaborn requests --quietimport numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import requests
import warnings
warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (14, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
sns.set_palette('husl')
print('Imports ready.')Imports ready.
Section 2 — Configuration
This section defines the core parameters that govern the analysis performed throughout the notebook. Users can customize the trading symbol, funding interval, and set thresholds for identifying extreme market conditions. These configurations are crucial for tailoring the analysis to specific assets and adapting to different market dynamics, providing flexibility for research and backtesting. The code in this section consists of straightforward variable assignments that set up these foundational parameters.
SYMBOL = 'BTCUSDT' # Binance/Bybit symbol
FUNDING_INTERVAL_H = 8 # hours between funding payments
EXTREME_FUNDING_PCT = 0.10 # per-period funding rate (%) that triggers extreme signal
BASIS_EXTREME_PCT = 0.50 # basis > this % of spot = overheated signal
USE_SYNTHETIC = True # set False to fetch from Binance public API
N_PERIODS = 500 # number of 8-hour periods to simulateSection 3 — Data Acquisition
The Data Acquisition section is dedicated to retrieving the necessary historical data for perpetual futures analysis. It implements functions to either fetch live funding rate and mark price data directly from a public exchange API, such as Binance, or to generate realistic synthetic data for simulation purposes. This dual approach is vital for ensuring the notebook's usability, allowing for rapid development and testing in a controlled environment without constant reliance on live API access. The following code defines these data fetching and generation utilities, followed by a conditional block to select the data source.
def fetch_binance_funding_history(
symbol: str,
limit: int = 500
) -> pd.DataFrame:
"""
Fetch historical funding rate data from Binance public API.
Parameters
----------
symbol : str
Trading pair symbol (e.g., 'BTCUSDT').
limit : int
Number of funding rate periods to fetch (max 1000).
Returns
-------
pd.DataFrame
Columns: timestamp, funding_rate, mark_price.
Notes
-----
Binance perpetual funding rates are settled every 8 hours at 00:00, 08:00, 16:00 UTC.
Bybit uses the same schedule. OKX uses 8h but with different rate formula.
The funding rate is applied to the notional value of the open position.
A rate of 0.01% per 8h = 0.03% daily = ~11% annualized carry cost for longs.
"""
url = f'https://fapi.binance.com/fapi/v1/fundingRate'
params = {'symbol': symbol, 'limit': limit}
try:
resp = requests.get(url, params=params, timeout=10)
resp.raise_for_status()
data = resp.json()
df = pd.DataFrame(data)
df['timestamp'] = pd.to_datetime(df['fundingTime'], unit='ms')
df['funding_rate'] = df['fundingRate'].astype(float) * 100 # convert to percentage
df['mark_price'] = df['markPrice'].astype(float)
return df[['timestamp', 'funding_rate', 'mark_price']].sort_values('timestamp').reset_index(drop=True)
except Exception as e:
raise RuntimeError(f'Binance API request failed: {e}')
def generate_synthetic_perp_data(
n_periods: int,
start_price: float = 50_000
) -> pd.DataFrame:
"""
Generate synthetic perpetual futures data with realistic basis and funding dynamics.
Embeds three market phases:
- Bull run: positive funding, high basis, OI increasing
- Neutral: funding near zero, small basis
- Bear/liquidation: negative funding spike, negative basis
Parameters
----------
n_periods : int
Number of 8-hour funding periods.
start_price : float
Initial spot price.
Returns
-------
pd.DataFrame
Columns: timestamp, spot_price, perp_price, basis_usd, basis_pct,
funding_rate_pct, open_interest, market_phase.
"""
np.random.seed(42)
timestamps = pd.date_range('2023-01-01', periods=n_periods, freq='8h')
# Phase assignments
phases = np.full(n_periods, 'neutral')
phases[:n_periods//4] = 'bull_run'
phases[3*n_periods//4:] = 'bear'
# Spot price simulation with phase-dependent drift
spot_rets = np.zeros(n_periods)
for i, phase in enumerate(phases):
drift = {'bull_run': 0.004, 'neutral': 0.0, 'bear': -0.003}.get(phase, 0.0)
spot_rets[i] = drift + np.random.normal(0, 0.015)
spot_prices = start_price * np.exp(np.cumsum(spot_rets))
# Basis as % of spot — mean-reverts around phase-specific level
basis_mean = {'bull_run': 0.4, 'neutral': 0.05, 'bear': -0.2}
basis_pct = np.zeros(n_periods)
b = 0.05
for i, phase in enumerate(phases):
target = basis_mean.get(phase, 0.05)
b = b + 0.15 * (target - b) + np.random.normal(0, 0.05)
b = np.clip(b, -1.0, 2.0)
basis_pct[i] = b
perp_prices = spot_prices * (1 + basis_pct / 100)
basis_usd = perp_prices - spot_prices
funding_rate = basis_pct / 3 + np.random.normal(0, 0.01, n_periods) # funding ≈ basis / 3 per period
# Open interest: rises in bull, falls in bear
oi_base = 500_000 # BTC
oi_multiplier = {'bull_run': 1.5, 'neutral': 1.0, 'bear': 0.7}
oi = np.array([oi_base * oi_multiplier.get(p, 1.0) * (1 + np.random.normal(0, 0.02)) for p in phases])
return pd.DataFrame({
'timestamp': timestamps,
'spot_price': np.round(spot_prices, 2),
'perp_price': np.round(perp_prices, 2),
'basis_usd': np.round(basis_usd, 2),
'basis_pct': np.round(basis_pct, 4),
'funding_rate_pct': np.round(funding_rate, 4),
'open_interest': np.round(oi, 0),
'market_phase': phases,
})
if USE_SYNTHETIC:
data = generate_synthetic_perp_data(N_PERIODS)
print('Using synthetic perpetual data.')
else:
try:
funding_data = fetch_binance_funding_history(SYMBOL)
print(f'Fetched {len(funding_data)} funding periods from Binance.')
data = funding_data
except Exception as e:
print(f'Live fetch failed ({e}). Using synthetic.')
data = generate_synthetic_perp_data(N_PERIODS)
print(data.tail(5))Using synthetic perpetual data.
timestamp spot_price perp_price basis_usd basis_pct \
495 2023-06-15 00:00:00 38574.75 38444.84 -129.91 -0.3368
496 2023-06-15 08:00:00 37865.46 37779.74 -85.72 -0.2264
497 2023-06-15 16:00:00 37644.40 37572.73 -71.67 -0.1904
498 2023-06-16 00:00:00 37041.91 36960.27 -81.64 -0.2204
499 2023-06-16 08:00:00 36172.82 36104.56 -68.26 -0.1887
funding_rate_pct open_interest market_phase
495 -0.0922 357491.0 bear
496 -0.0548 349814.0 bear
497 -0.0514 343827.0 bear
498 -0.0632 348859.0 bear
499 -0.0570 344786.0 bear
def compute_basis_signals(
data: pd.DataFrame,
extreme_funding_pct: float,
basis_extreme_pct: float,
roll: int = 21 # periods (~7 days at 8h intervals)
) -> pd.DataFrame:
"""
Compute derived basis signals: rolling stats, extreme flags, sentiment score.
Parameters
----------
data : pd.DataFrame
Perpetual data with 'basis_pct', 'funding_rate_pct', 'open_interest'.
extreme_funding_pct : float
Funding rate (per period, %) that triggers extreme signal.
basis_extreme_pct : float
Basis (% of spot) that triggers extreme premium signal.
roll : int
Rolling window for percentile-rank signals.
Returns
-------
pd.DataFrame
Extended DataFrame with signal columns.
Notes
-----
The 'basis_sentiment' composite score (0=extreme bearish, 1=extreme bullish)
combines funding rate, basis percentage, and OI trend.
It is a CONTRARIAN signal in excess: extreme bullish reading (>0.8) suggests
overextension and is bearish for SPOT price. Extreme bearish (<0.2) suggests
over-leveraged shorts and is bullish for spot (squeeze risk).
"""
out = data.copy()
# Rolling funding stats
out['funding_rolling_mean'] = out['funding_rate_pct'].rolling(roll).mean()
out['funding_cumulative'] = out['funding_rate_pct'].cumsum() # total carry earned
# Extreme event flags
out['extreme_positive_funding'] = (out['funding_rate_pct'] >= extreme_funding_pct).astype(int)
out['extreme_negative_funding'] = (out['funding_rate_pct'] <= -extreme_funding_pct / 2).astype(int)
out['extreme_positive_basis'] = (out['basis_pct'] >= basis_extreme_pct).astype(int)
out['extreme_negative_basis'] = (out['basis_pct'] <= -basis_extreme_pct / 3).astype(int)
# OI trend: rising OI + positive funding = leveraged longs (bearish for spot)
out['oi_change_pct'] = out['open_interest'].pct_change() * 100 if 'open_interest' in out.columns else 0.0
# Composite sentiment: higher = more bullish leverage (CONTRARIAN bearish signal)
funding_score = out['funding_rate_pct'].rolling(roll).rank(pct=True)
basis_score = out['basis_pct'].rolling(roll).rank(pct=True)
out['basis_sentiment'] = (0.6 * funding_score.fillna(0.5) + 0.4 * basis_score.fillna(0.5)).clip(0, 1)
out['sentiment_signal'] = pd.cut(
out['basis_sentiment'].fillna(0.5),
bins=[-0.01, 0.25, 0.75, 1.01],
labels=['contrarian_buy', 'neutral', 'contrarian_sell']
)
return out
signals = compute_basis_signals(data, EXTREME_FUNDING_PCT, BASIS_EXTREME_PCT)
print('Basis signal summary:')
print(f' Mean funding rate: {signals["funding_rate_pct"].mean():.4f}%')
print(f' Extreme positive funding: {signals["extreme_positive_funding"].sum()} periods')
print(f' Extreme negative funding: {signals["extreme_negative_funding"].sum()} periods')
print(f' Current sentiment: {signals["sentiment_signal"].iloc[-1]}')
print(f' Cumulative funding earned: {signals["funding_cumulative"].iloc[-1]:.2f}% (as basis capture)')Basis signal summary: Mean funding rate: 0.0014% Extreme positive funding: 1 periods Extreme negative funding: 68 periods Current sentiment: contrarian_sell Cumulative funding earned: 0.68% (as basis capture)
Section 5 — Cash-and-Carry Backtest
This section delves into the practical application of the funding rate by backtesting a cash-and-carry arbitrage strategy. This strategy involves simultaneously longing the spot asset and shorting the equivalent notional value in perpetual futures to capture consistent funding income while remaining delta-neutral. The backtest simulates trade entries and exits based on predefined funding rate thresholds and calculates the cumulative profit and loss. This analysis is crucial for understanding the potential profitability and inherent risks of such a strategy, especially in varying market conditions. The code here defines the backtest_cash_and_carry function that orchestrates this simulation.
def backtest_cash_and_carry(
signals: pd.DataFrame,
enter_funding_thresh: float = 0.05,
exit_funding_thresh: float = 0.01,
position_size_usdt: float = 10_000
) -> pd.DataFrame:
"""
Backtest a cash-and-carry (delta-neutral basis capture) strategy.
Enter trade when funding rate exceeds enter_funding_thresh:
- Long spot BTC
- Short equal notional value of BTC perpetual
Net position: delta-neutral, earns funding payments from short perp.
Parameters
----------
signals : pd.DataFrame
Output of compute_basis_signals().
enter_funding_thresh : float
Funding rate (%) to enter the carry trade.
exit_funding_thresh : float
Funding rate (%) to exit (rate has dropped, less attractive).
position_size_usdt : float
Notional position size in USDT.
Returns
-------
pd.DataFrame
Backtest results with entry/exit flags, cumulative PnL.
Notes
-----
This is a simplified backtest — it ignores:
- Exchange margin requirements (short perp requires collateral)
- Borrow costs for spot (if using borrowed BTC)
- Liquidation risk on the short perp during extreme moves
- Transaction costs (~0.05% per trade side)
In practice, annualized yields of 10-30% were achievable during bull markets.
"""
bt = signals[['timestamp', 'spot_price', 'perp_price', 'funding_rate_pct', 'basis_pct']].copy()
in_trade = False
cumulative_pnl = 0.0
entry_price = None
pnl_per_period = []
in_trade_flag = []
for _, row in bt.iterrows():
fr = row['funding_rate_pct']
if not in_trade and fr >= enter_funding_thresh:
in_trade = True
entry_price = row['spot_price']
period_pnl = 0.0
if in_trade:
# Funding received from short perp position
notional = position_size_usdt
period_pnl = notional * (fr / 100) # received each period
cumulative_pnl += period_pnl
if fr <= exit_funding_thresh:
in_trade = False
entry_price = None
pnl_per_period.append(period_pnl)
in_trade_flag.append(int(in_trade))
bt['pnl_per_period'] = pnl_per_period
bt['cumulative_pnl'] = np.cumsum(pnl_per_period)
bt['in_carry_trade'] = in_trade_flag
bt['return_on_notional_pct'] = bt['cumulative_pnl'] / position_size_usdt * 100
total_return = bt['return_on_notional_pct'].iloc[-1]
n_periods_active = sum(in_trade_flag)
daily_periods = 3
ann_return = (total_return / len(bt)) * daily_periods * 365
print(f'Cash-and-Carry Backtest Results:')
print(f' Total return on notional: {total_return:.2f}%')
print(f' Annualized return: {ann_return:.1f}%')
print(f' Periods in trade: {n_periods_active} / {len(bt)} ({n_periods_active/len(bt)*100:.1f}%)')
print(f' Total funding earned: ${bt["cumulative_pnl"].iloc[-1]:.0f} on ${position_size_usdt:.0f} notional')
return bt
carry_bt = backtest_cash_and_carry(signals)Cash-and-Carry Backtest Results: Total return on notional: 4.99% Annualized return: 10.9% Periods in trade: 104 / 500 (20.8%) Total funding earned: $499 on $10000 notional
Section 6 — Visualization
This Visualization section provides an intuitive, multi-panel dashboard designed to comprehensively display the key metrics and signals derived from the perpetual futures market. It generates plots for spot and perpetual prices, the basis percentage, the 8-hour funding rate, and the cumulative return of the simulated cash-and-carry strategy. These visual representations are essential for quickly interpreting complex market dynamics, identifying trends, and assessing the performance and validity of the analytical signals. The plot_perp_basis_dashboard function is implemented below to generate these insightful charts.
def plot_perp_basis_dashboard(
signals: pd.DataFrame,
carry_bt: pd.DataFrame
) -> None:
"""
Five-panel perpetual basis dashboard.
Parameters
----------
signals : pd.DataFrame
Computed signals from compute_basis_signals().
carry_bt : pd.DataFrame
Cash-and-carry backtest results.
"""
fig, axes = plt.subplots(4, 1, figsize=(15, 18), sharex=True)
# Panel 1: Spot and perp prices
if 'spot_price' in signals.columns:
axes[0].plot(signals['timestamp'], signals['spot_price'], label='Spot', color='steelblue', linewidth=1.5)
axes[0].plot(signals['timestamp'], signals['perp_price'], label='Perp', color='orange', linewidth=1.5, linestyle='--')
elif 'mark_price' in signals.columns:
axes[0].plot(signals['timestamp'], signals['mark_price'], label='Mark Price', color='steelblue', linewidth=1.5)
axes[0].set_ylabel('Price (USD)')
axes[0].set_title('Spot vs Perpetual Price')
axes[0].legend()
# Panel 2: Basis %
if 'basis_pct' in signals.columns:
axes[1].bar(signals['timestamp'], signals['basis_pct'],
color=np.where(signals['basis_pct'] >= 0, 'green', 'red'), alpha=0.7, width=0.3)
axes[1].axhline(0, color='black', linewidth=0.8)
axes[1].axhline( BASIS_EXTREME_PCT, color='red', linewidth=0.8, linestyle='--', alpha=0.7)
axes[1].axhline(-BASIS_EXTREME_PCT/3, color='blue', linewidth=0.8, linestyle='--', alpha=0.7)
axes[1].set_ylabel('Basis (%)')
axes[1].set_title('Perpetual Basis (% of Spot) — Red Line = Extreme Premium')
# Panel 3: Funding rate
axes[2].bar(signals['timestamp'], signals['funding_rate_pct'],
color=np.where(signals['funding_rate_pct'] >= 0, 'orange', 'purple'), alpha=0.7, width=0.3)
axes[2].axhline(0, color='black', linewidth=0.8)
axes[2].axhline( EXTREME_FUNDING_PCT, color='red', linewidth=0.8, linestyle=':', label='Extreme positive')
axes[2].axhline(-EXTREME_FUNDING_PCT/2, color='blue', linewidth=0.8, linestyle=':', label='Extreme negative')
axes[2].set_ylabel('Funding Rate (%)')
axes[2].set_title('8h Funding Rate — Orange=Positive (Longs Pay), Purple=Negative (Shorts Pay)')
axes[2].legend()
# Panel 4: Carry trade PnL
axes[3].plot(carry_bt['timestamp'], carry_bt['return_on_notional_pct'], color='gold', linewidth=1.5)
axes[3].fill_between(carry_bt['timestamp'], carry_bt['return_on_notional_pct'], 0,
where=(carry_bt['in_carry_trade'] == 1), alpha=0.3, color='green', label='Active carry trade')
axes[3].axhline(0, color='black', linewidth=0.8, linestyle='--')
axes[3].set_ylabel('Return on Notional (%)')
axes[3].set_title('Cash-and-Carry Strategy Cumulative Return')
axes[3].set_xlabel('Date')
axes[3].legend()
plt.tight_layout()
plt.show()
plot_perp_basis_dashboard(signals, carry_bt)Section 7 — Basis Sentiment Signal Summary
This section focuses on distilling the complex perpetual futures data into a concise and actionable summary of basis sentiment. It generates a summary table that includes current funding and basis rates, their 7-day averages, and a composite 'sentiment signal'. This summary is of paramount importance as it offers a quick, at-a-glance snapshot of prevailing market leverage and sentiment, facilitating rapid and informed decision-making for traders and analysts. The summarize_basis_signals function is defined and executed here to provide this critical overview.
def summarize_basis_signals(signals: pd.DataFrame) -> pd.DataFrame:
"""
Generate a concise summary of current and recent basis signal conditions.
Parameters
----------
signals : pd.DataFrame
Output of compute_basis_signals().
Returns
-------
pd.DataFrame
Summary table of current readings and historical context.
"""
latest = signals.iloc[-1]
roll_7d = signals.tail(21) # ~7 days at 8h intervals
summary = {
'Current Funding Rate (%)': round(float(latest['funding_rate_pct']), 4),
'Current Basis (%)': round(float(latest.get('basis_pct', 0)), 4),
'7d Avg Funding Rate (%)': round(float(roll_7d['funding_rate_pct'].mean()), 4),
'Extreme Funding Events (7d)': int(roll_7d['extreme_positive_funding'].sum()),
'Sentiment Signal': str(latest.get('sentiment_signal', 'N/A')),
'Cumulative Funding Earned (7d, %)': round(float(roll_7d['funding_rate_pct'].sum()), 3),
'Annualized Funding Rate (%)': round(float(roll_7d['funding_rate_pct'].mean()) * 3 * 365, 1),
}
df = pd.DataFrame([summary]).T
df.columns = ['Value']
print('Basis Signal Summary:')
print(df.to_string())
return df
signal_summary = summarize_basis_signals(signals)Basis Signal Summary:
Value
Current Funding Rate (%) -0.057
Current Basis (%) -0.1887
7d Avg Funding Rate (%) -0.0848
Extreme Funding Events (7d) 0
Sentiment Signal contrarian_sell
Cumulative Funding Earned (7d, %) -1.781
Annualized Funding Rate (%) -92.9
Section 8 — Export
The Export section handles the persistence of the analytical results and processed data. It provides functionality to save the full signals DataFrame, the cash-and-carry backtest results, and the summarized basis signals into separate CSV files. This capability is critical for enabling further offline analysis, integration with external reporting tools, or for building automated data pipelines. Ensuring data reusability and portability, this section concludes the analytical workflow by making all generated insights readily accessible. The export_perp_basis_data function is implemented to perform these export operations.
def export_perp_basis_data(
signals: pd.DataFrame,
carry_bt: pd.DataFrame,
signal_summary: pd.DataFrame
) -> None:
"""
Export perp basis signals, carry backtest, and summary.
Parameters
----------
signals : pd.DataFrame
Full signal DataFrame.
carry_bt : pd.DataFrame
Cash-and-carry backtest results.
signal_summary : pd.DataFrame
Current signal summary table.
"""
signals.to_csv('perp_basis_signals.csv', index=False)
carry_bt.to_csv('carry_trade_backtest.csv', index=False)
signal_summary.to_csv('basis_signal_summary.csv')
print('Exported: perp_basis_signals.csv')
print('Exported: carry_trade_backtest.csv')
print('Exported: basis_signal_summary.csv')
export_perp_basis_data(signals, carry_bt, signal_summary)Exported: perp_basis_signals.csv Exported: carry_trade_backtest.csv Exported: basis_signal_summary.csv
Summary & Next Steps
Key Takeaways
- The perpetual funding rate is the most important crypto-native market sentiment signal, with no equity analog
- Positive funding > 0.1% per 8h = longs overextended → contrarian bearish signal for spot
- Negative funding = shorts dominant → potential long squeeze → contrarian bullish signal for spot
- Cash-and-carry (long spot + short perp) provides ~10-40% annualized yield in bull markets with near-zero directional risk
- The basis and funding rate are leading indicators for price moves: extreme readings often precede sharp reversals
- During liquidation cascades, funding goes deeply negative briefly — these moments are high-conviction buy signals