Crypto-Native·Perpetual Futures Mechanics·Intermediate

Long Short Ratio Analysis

Analyze exchange-reported aggregate long versus short position ratios across perpetual futures markets to identify market positioning extremes and imbalances, detecting potential short squeeze or long squeeze scenarios when the crowd positioning becomes heavily one-sided at unsustainable levels.

cryptoperpetual-futures-mechanics

Long/Short Ratio Analysis — Crypto-Native

Category: Crypto-Native | Subcategory: Perpetuals


What This Notebook Does

The long/short ratio measures the proportion of traders (or top traders) holding net long vs net short positions on perpetual futures. As a contrarian sentiment indicator, extreme values often precede reversals: when everyone is long, there is no one left to buy; when everyone is short, there is no one left to sell.

This notebook:

  1. Fetches Binance top-trader long/short ratio data (or synthetic fallback)
  2. Computes rolling statistics, z-score, and deviation from the historical mean
  3. Generates contrarian trading signals at extreme readings
  4. Backtests the contrarian strategy on BTC price data
  5. Visualizes the ratio alongside price action and signal timing
  6. Exports the enriched dataset for downstream use

Long/Short Ratio Types on Binance

EndpointDescriptionUpdate Frequency
Global L/S RatioAll accounts with open positions15-minute intervals
Top Trader Position RatioAccounts with top 20% largest positions15-minute intervals
Top Trader Account Ratio% of top accounts that are net long15-minute intervals
Taker Buy/Sell VolumeDirectional taker flow15-minute intervals
[ ]
!pip install numpy pandas matplotlib seaborn requests --quiet
[ ]
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import requests
from scipy import stats
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
print('Imports ready.')
Imports ready.
[ ]
# --- Configuration ---
USE_SYNTHETIC      = False
SYMBOL             = 'BTCUSDT'
PERIOD             = '15m'
LIMIT              = 500
ROLLING_WINDOW     = 48       # 48 x 15min = 12 hours
EXTREME_LONG_Z     =  1.5     # z-score above this → contrarian short signal
EXTREME_SHORT_Z    = -1.5     # z-score below this → contrarian long signal
SIGNAL_HOLD_BARS   = 8        # hold signal for N bars
print('Config ready.')
Config ready.

Section 2 — Data Fetch

This section is responsible for obtaining the raw data required for the analysis. It includes a function to generate synthetic long/short ratio and BTC price data, which serves as a fallback or for testing purposes. Additionally, it contains a function to fetch actual top-trader long/short ratio and BTC price data from the Binance Futures API. The purpose is to ensure we have a consistent dataset, either real or simulated, to proceed with the analysis. The code will execute the data fetching and display a summary of the loaded DataFrame.

[ ]
def generate_synthetic_ls_data(
    n: int = 500,
    seed: int = 42
) -> pd.DataFrame:
    """
    Generate synthetic long/short ratio and BTC price data.

    Parameters
    ----------
    n : int
        Number of 15-minute bars.
    seed : int
        Random seed.

    Returns
    -------
    pd.DataFrame
        Columns: timestamp, ls_ratio, btc_close.

    Notes
    -----
    L/S ratio is mean-reverting around 1.2 with slow trend component.
    BTC price is anti-correlated with L/S extremes to simulate contrarian signal.
    """
    rng = np.random.default_rng(seed)
    # Mean-reverting L/S around 1.2
    ls = [1.2]
    for _ in range(n - 1):
        ls.append(ls[-1] + 0.05 * (1.2 - ls[-1]) + rng.normal(0, 0.04))
    ls = np.clip(ls, 0.5, 3.0)

    # BTC price with inverse relation to extreme L/S
    btc = [50_000.0]
    for i in range(1, n):
        ls_dev = ls[i] - 1.2
        drift  = -0.0002 * ls_dev  # contrarian effect
        btc.append(btc[-1] * (1 + drift + rng.normal(0, 0.003)))

    timestamps = pd.date_range('2024-01-01', periods=n, freq='15min')
    return pd.DataFrame({'timestamp': timestamps, 'ls_ratio': ls, 'btc_close': btc})


def fetch_ls_ratio(
    symbol: str = 'BTCUSDT',
    period: str = '15m',
    limit: int = 500,
    use_synthetic: bool = False
) -> pd.DataFrame:
    """
    Fetch top-trader long/short ratio from Binance Futures API.

    Parameters
    ----------
    symbol : str
        Trading pair (e.g., 'BTCUSDT').
    period : str
        Candle period string ('5m', '15m', '1h', '4h', '1d').
    limit : int
        Number of bars to fetch (max 500).
    use_synthetic : bool
        Skip API call if True.

    Returns
    -------
    pd.DataFrame
        Columns: timestamp, ls_ratio, btc_close.
    """
    if use_synthetic:
        return generate_synthetic_ls_data(limit)

    try:
        # Top-trader position long/short ratio
        ls_url   = 'https://fapi.binance.com/futures/data/topLongShortPositionRatio'
        kline_url = 'https://fapi.binance.com/fapi/v1/klines'

        ls_resp = requests.get(ls_url, params={'symbol': symbol, 'period': period, 'limit': limit}, timeout=10)
        ls_resp.raise_for_status()
        ls_data = ls_resp.json()

        kl_resp = requests.get(kline_url, params={'symbol': symbol, 'interval': period, 'limit': limit}, timeout=10)
        kl_resp.raise_for_status()
        kl_data = kl_resp.json()

        ls_df = pd.DataFrame(ls_data)
        ls_df['timestamp'] = pd.to_datetime(ls_df['timestamp'].astype(int), unit='ms')
        ls_df['ls_ratio']  = ls_df['longShortRatio'].astype(float)

        kl_df = pd.DataFrame(kl_data, columns=['open_time','open','high','low','close','vol','close_time','qvol','ntrades','taker_buy_base','taker_buy_quote','ignore'])
        kl_df['timestamp']  = pd.to_datetime(kl_df['open_time'].astype(int), unit='ms')
        kl_df['btc_close']  = kl_df['close'].astype(float)

        df = pd.merge(ls_df[['timestamp', 'ls_ratio']], kl_df[['timestamp', 'btc_close']], on='timestamp', how='inner')
        print(f'Fetched {len(df)} bars of L/S + price data.')
        return df
    except Exception as e:
        print(f'API failed ({e}), using synthetic.')
        return generate_synthetic_ls_data(limit)


ls_df = fetch_ls_ratio(SYMBOL, PERIOD, LIMIT, USE_SYNTHETIC)
print(ls_df.describe())
API failed (451 Client Error:  for url: https://fapi.binance.com/futures/data/topLongShortPositionRatio?symbol=BTCUSDT&period=15m&limit=500), using synthetic.
                 timestamp    ls_ratio     btc_close
count                  500  500.000000    500.000000
mean   2024-01-03 14:22:30    1.193202  47293.013034
min    2024-01-01 00:00:00    0.923676  45023.808410
25%    2024-01-02 07:11:15    1.123641  46093.434190
50%    2024-01-03 14:22:30    1.188226  47152.064289
75%    2024-01-04 21:33:45    1.266701  48198.034961
max    2024-01-06 04:45:00    1.471459  50484.410018
std                    NaN    0.102280   1390.919700

Section 3 — Signal Computation

Here, we compute the core statistical indicators from the fetched long/short ratio data. The primary goal is to transform the raw ratio into actionable signals. This involves calculating rolling moving averages, standard deviations, and crucially, the z-score of the long/short ratio. The z-score helps to identify extreme deviations from the recent mean, which are often indicative of contrarian opportunities. Finally, a contrarian signal (long, short, or flat) is generated based on predefined z-score thresholds and a signal holding period. The code will add these computed statistics and the resulting signals to our DataFrame.

[ ]
def compute_ls_statistics(
    df: pd.DataFrame,
    window: int = 48
) -> pd.DataFrame:
    """
    Compute rolling statistics on the long/short ratio.

    Parameters
    ----------
    df : pd.DataFrame
        DataFrame with 'ls_ratio' column.
    window : int
        Rolling window size in bars.

    Returns
    -------
    pd.DataFrame
        Original df with added: ls_ma, ls_std, ls_zscore, ls_pct_rank.
    """
    df = df.copy()
    df['ls_ma']    = df['ls_ratio'].rolling(window).mean()
    df['ls_std']   = df['ls_ratio'].rolling(window).std()
    df['ls_zscore']= (df['ls_ratio'] - df['ls_ma']) / (df['ls_std'] + 1e-9)
    df['ls_pct_rank'] = df['ls_ratio'].rolling(window).rank(pct=True)
    return df


def generate_ls_signal(
    df: pd.DataFrame,
    long_z: float = -1.5,
    short_z: float = 1.5,
    hold_bars: int = 8
) -> pd.DataFrame:
    """
    Generate contrarian signals when L/S ratio reaches z-score extremes.

    Parameters
    ----------
    df : pd.DataFrame
        DataFrame with 'ls_zscore' column.
    long_z : float
        Z-score threshold below which to go long (everyone is short → reversal).
    short_z : float
        Z-score threshold above which to go short (everyone is long → reversal).
    hold_bars : int
        Number of bars to hold the signal before resetting.

    Returns
    -------
    pd.DataFrame
        Original df with 'signal' column: 1=long, -1=short, 0=flat.

    Notes
    -----
    Contrarian logic: when the crowd is extremely long, there's limited
    remaining buying pressure — the market is more likely to reverse.
    """
    df = df.copy()
    signal = np.zeros(len(df))
    hold_counter = 0
    current_signal = 0

    for i in range(len(df)):
        z = df['ls_zscore'].iloc[i]
        if np.isnan(z):
            signal[i] = 0
            continue

        if hold_counter > 0:
            signal[i] = current_signal
            hold_counter -= 1
        elif z >= short_z:
            current_signal = -1  # contrarian: everyone long → go short
            hold_counter   = hold_bars
            signal[i]      = -1
        elif z <= long_z:
            current_signal = 1   # contrarian: everyone short → go long
            hold_counter   = hold_bars
            signal[i]      = 1
        else:
            signal[i] = 0

    df['signal'] = signal
    return df


ls_df = compute_ls_statistics(ls_df, ROLLING_WINDOW)
ls_df = generate_ls_signal(ls_df, EXTREME_SHORT_Z, EXTREME_LONG_Z, SIGNAL_HOLD_BARS)
print(ls_df[['ls_ratio', 'ls_zscore', 'signal']].tail(10).to_string())
print(f'\nSignal distribution: {ls_df["signal"].value_counts().to_dict()}')
     ls_ratio  ls_zscore  signal
490  1.290902   0.686077    -1.0
491  1.329768   1.066929     0.0
492  1.337941   1.109480     0.0
493  1.319594   0.870899     0.0
494  1.331773   0.975525     0.0
495  1.312838   0.725520     0.0
496  1.344618   1.059120     0.0
497  1.264130   0.075008     0.0
498  1.247500  -0.146530     0.0
499  1.165492  -1.147470     0.0

Signal distribution: {0.0: 311, -1.0: 99, 1.0: 90}

Section 4 — Backtest

This section evaluates the performance of our contrarian trading strategy using a vectorized backtesting approach. It simulates trades based on the signals generated in the previous section against historical BTC price data. The purpose is to quantify the effectiveness of the strategy and understand its profitability and risk characteristics. The backtesting function calculates percentage returns for both the strategy and a simple buy-and-hold approach, and then computes cumulative equity curves. Key performance metrics like total return, Sharpe ratio, and maximum drawdown are also calculated to provide a comprehensive overview of the strategy's historical performance.

[ ]
def backtest_ls_strategy(
    df: pd.DataFrame,
    signal_col: str = 'signal',
    price_col: str = 'btc_close'
) -> pd.DataFrame:
    """
    Vectorized backtest of the long/short ratio contrarian strategy.

    Parameters
    ----------
    df : pd.DataFrame
        DataFrame with signal and price columns.
    signal_col : str
        Column containing position signals (1/-1/0).
    price_col : str
        Column containing close prices.

    Returns
    -------
    pd.DataFrame
        Input df with added: price_ret, strategy_ret, equity, bh_equity.
    """
    df = df.copy()
    df['price_ret']    = df[price_col].pct_change()
    df['strategy_ret'] = df[signal_col].shift(1) * df['price_ret']
    df['equity']       = (1 + df['strategy_ret'].fillna(0)).cumprod()
    df['bh_equity']    = (1 + df['price_ret'].fillna(0)).cumprod()
    return df


def compute_strategy_metrics(df: pd.DataFrame) -> dict:
    """
    Compute key performance metrics for the backtest.

    Parameters
    ----------
    df : pd.DataFrame
        Output of backtest_ls_strategy().

    Returns
    -------
    dict
        Dictionary of metrics: total_return, sharpe, max_dd, n_signals.
    """
    rets = df['strategy_ret'].dropna()
    total_ret = df['equity'].iloc[-1] - 1
    sharpe    = rets.mean() / (rets.std() + 1e-9) * np.sqrt(252 * 96)  # 15min bars/day
    running_max = df['equity'].cummax()
    drawdown  = (df['equity'] - running_max) / running_max
    max_dd    = drawdown.min()
    n_signals = (df['signal'] != 0).sum()
    return {'total_return': total_ret, 'sharpe': sharpe, 'max_drawdown': max_dd, 'n_signals': int(n_signals)}


ls_df   = backtest_ls_strategy(ls_df)
metrics = compute_strategy_metrics(ls_df)
print('Strategy Metrics:')
for k, v in metrics.items():
    print(f'  {k}: {v:.4f}' if isinstance(v, float) else f'  {k}: {v}')
Strategy Metrics:
  total_return: 0.0415
  sharpe: 6.7622
  max_drawdown: -0.0311
  n_signals: 189

Section 5 — Visualization

Visualizations are crucial for understanding the strategy's dynamics and its interaction with price action. This section provides a multi-panel plot that presents the BTC price alongside the generated long/short signals, the long/short ratio and its z-score (indicating extreme sentiment), and the comparative equity curves of our strategy against a buy-and-hold benchmark. This visual representation helps in identifying patterns, assessing signal timing, and gaining insights into how the strategy performed during different market conditions.

[ ]
def plot_ls_analysis(df: pd.DataFrame) -> None:
    """
    Three-panel visualization: L/S ratio, z-score with signals, equity curves.

    Parameters
    ----------
    df : pd.DataFrame
        Fully enriched dataframe from the analysis pipeline.
    """
    fig, axes = plt.subplots(3, 1, figsize=(14, 12), sharex=True)

    # Panel 1: Price and signals
    axes[0].plot(df.index, df['btc_close'], color='steelblue', linewidth=1.0)
    long_sigs  = df[df['signal'] == 1]
    short_sigs = df[df['signal'] == -1]
    axes[0].scatter(long_sigs.index,  long_sigs['btc_close'],  color='green', marker='^', s=50, zorder=5, label='Long signal')
    axes[0].scatter(short_sigs.index, short_sigs['btc_close'], color='red',   marker='v', s=50, zorder=5, label='Short signal')
    axes[0].set_ylabel('BTC Price')
    axes[0].set_title('BTC Price with L/S Contrarian Signals')
    axes[0].legend()

    # Panel 2: L/S ratio and z-score
    ax2a = axes[1]
    ax2b = ax2a.twinx()
    ax2a.plot(df.index, df['ls_ratio'],  color='purple',    linewidth=1.0, label='L/S Ratio')
    ax2a.plot(df.index, df['ls_ma'],     color='purple',    linewidth=0.6, linestyle='--', alpha=0.6, label='Rolling Mean')
    ax2b.plot(df.index, df['ls_zscore'], color='darkorange', linewidth=1.0, alpha=0.7, label='Z-score')
    ax2b.axhline(EXTREME_LONG_Z,  color='red',   linestyle=':', linewidth=0.8)
    ax2b.axhline(EXTREME_SHORT_Z, color='green', linestyle=':', linewidth=0.8)
    ax2b.axhline(0, color='gray', linestyle='--', linewidth=0.5)
    ax2a.set_ylabel('L/S Ratio', color='purple')
    ax2b.set_ylabel('Z-score', color='darkorange')
    axes[1].set_title(f'Long/Short Ratio and Z-score (window={ROLLING_WINDOW})')
    ax2a.legend(loc='upper left')
    ax2b.legend(loc='upper right')

    # Panel 3: Equity curves
    axes[2].plot(df.index, df['equity'],    color='green',     linewidth=1.5, label='L/S Contrarian')
    axes[2].plot(df.index, df['bh_equity'], color='steelblue', linewidth=1.0, linestyle='--', alpha=0.7, label='Buy & Hold')
    axes[2].axhline(1.0, color='gray', linewidth=0.5, linestyle='--')
    axes[2].set_ylabel('Equity (normalized)')
    axes[2].set_title('Strategy vs Buy & Hold')
    axes[2].legend()

    plt.tight_layout()
    plt.show()


plot_ls_analysis(ls_df)
cell output

Section 6 — Export

This final section is dedicated to data persistence. After all the computations, signal generation, and backtesting, the enriched DataFrame containing the original data, computed statistics, signals, and backtest results is exported to a CSV file. This allows for easy access to the processed data for further analysis, reporting, or integration into other systems without needing to re-run the entire notebook.

[ ]
def export_ls_data(df: pd.DataFrame) -> None:
    """
    Export the enriched long/short ratio dataset.

    Parameters
    ----------
    df : pd.DataFrame  Fully processed L/S ratio dataframe.
    """
    df.to_csv('long_short_ratio_analysis.csv', index=True)
    print('Exported: long_short_ratio_analysis.csv')


export_ls_data(ls_df)
Exported: long_short_ratio_analysis.csv

Summary & Next Steps

Key Takeaways

  • The L/S ratio is a crowd positioning indicator — extremes signal exhaustion of the dominant side
  • A z-score above +1.5 means the crowd is unusually long relative to recent history — contrarian short setup
  • A z-score below -1.5 means the crowd is unusually short — contrarian long setup
  • The signal works best at major turning points; in trending markets it generates false signals
  • Combine with funding rate and open interest for higher-confidence contrarian entries