DEV Community

Orion
Orion

Posted on

Free Pine Script: Crypto Basis Arbitrage Monitor (spot vs perp, Z-score, 5 alerts)

Cash-and-carry arbitrage on crypto is one of the cleanest, most mechanical edges in the market — when perpetual futures trade at a meaningful premium to spot, you sell the perp and buy spot, pocket the spread when it collapses.

The problem: watching two prices on separate charts and doing the mental math is painful. So I built a dedicated Pine Script v5 indicator that does it all in a sub-chart pane.

What it tracks

  • Basis %(futuresPrice − spotPrice) / spotPrice × 100 in real time
  • Basis EMA — smoothed trend of the spread
  • Z-Score — how statistically extreme the current basis is vs the last N bars
  • Fee-adjusted thresholds — you set your round-trip fee %; the indicator draws ±threshold bands and only signals when the edge is real

Green background = futures at a premium → cash-and-carry opportunity

Red background = futures at a discount → reverse carry opportunity

Info table (top-right)

Metric What it tells you
Signal ▲ CASH-AND-CARRY / ▼ REVERSE CARRY / — NEUTRAL
Basis % Live spread
Break-Even % Your fee + buffer (min spread to profit)
Edge vs Fees How much margin above break-even
Z-Score Statistical extremity (>2σ = rare event)
Basis EMA Smoothed baseline
N-bar Range Spread hi/lo over the lookback window
Futs − Spot Raw $ difference

5 alert conditions

  1. Cash-and-carry opens (spread crosses above break-even)
  2. Cash-and-carry closes (spread compresses back)
  3. Reverse carry opens
  4. Reverse carry closes
  5. Extreme basis — Z-score hits ±2.5σ (statistically rare divergence)

How to use

  1. Open a spot chart on TradingView (e.g. BINANCE:BTCUSDT)
  2. Add this indicator as a new pane
  3. Set Perpetual Futures Symbol to the matching perp (BINANCE:BTCUSDT.P)
  4. Set Round-trip Fee % to your exchange's taker fee × 2 (Binance = 0.08%)
  5. Optional: add a Profit Buffer % (default 0.05%) to guard against slippage
  6. Set alerts on "Cash-and-Carry Opens" — that's your entry signal

Full source code

// This source code is subject to the terms of the Mozilla Public License 2.0
// at https://mozilla.org/MPL/2.0/
// © nexusnetworkai

//@version=5
indicator("Crypto Basis Arbitrage Monitor", shorttitle="BASIS ARB", overlay=false, max_bars_back=500)

grp1 = "Symbols"
futuresSymbol = input.symbol("BINANCE:BTCUSDT.P", "Perpetual Futures Symbol", group=grp1,
     tooltip="Matching perpetual-futures contract for the spot chart you have open.")

grp2 = "Cost & Thresholds"
feeRoundtrip  = input.float(0.08, "Round-trip Fee %",  minval=0.0, maxval=5.0, step=0.01, group=grp2)
extraBuffer   = input.float(0.05, "Profit Buffer %",   minval=0.0, maxval=2.0, step=0.01, group=grp2)
breakEven     = feeRoundtrip + extraBuffer

grp3 = "Statistics"
lookback = input.int(168, "Statistical Lookback (bars)", minval=20, maxval=1000, group=grp3,
     tooltip="168 bars on 1h ≈ 1 week.")

grp4 = "Display"
showTable  = input.bool(true, "Show Info Table",   group=grp4)
showZScore = input.bool(true, "Show Z-Score Panel", group=grp4)

spotPrice    = close
futuresPrice = request.security(futuresSymbol, timeframe.period, close, gaps=barmerge.gaps_off)

basis    = (futuresPrice - spotPrice) / spotPrice * 100
basisMA  = ta.ema(basis, lookback)
basisStd = ta.stdev(basis, lookback)
basisZ   = basisStd != 0 ? (basis - basisMA) / basisStd : 0.0

basisHigh = ta.highest(basis, lookback)
basisLow  = ta.lowest(basis,  lookback)

longArb  = basis >  breakEven
shortArb = basis < -breakEven

basisCol = longArb  ? color.new(#00e676, 0)
         : shortArb ? color.new(#ff1744, 0)
         :             color.new(#78909c, 0)

plot(basis,   "Basis %",   color=basisCol,              linewidth=2)
plot(basisMA, "Basis EMA", color=color.new(#2196f3, 30), linewidth=1)

p_hi = plot( breakEven, "Long Arb Floor",  color=color.new(#00e676, 55), linewidth=1)
p_lo = plot(-breakEven, "Short Arb Floor", color=color.new(#ff1744, 55), linewidth=1)
fill(p_hi, p_lo, color=color.new(#546e7a, 92), title="No-Edge Zone")

hline(0, "Zero", color=color.new(#607d8b, 60), linestyle=hline.style_dotted)

bgcolor(longArb  ? color.new(#00e676, 88)
      : shortArb ? color.new(#ff1744, 88)
      : na, title="Signal Background")

zCol = math.abs(basisZ) > 2.0 ? color.new(#ffab00, 0) : color.new(#607d8b, 60)
plot(showZScore ? basisZ : na, "Z-Score", color=zCol, linewidth=1, style=plot.style_area)
hline( 2.0, "+2σ", color=color.new(#ffab00, 70), linestyle=hline.style_dashed)
hline(-2.0, "−2σ", color=color.new(#ffab00, 70), linestyle=hline.style_dashed)
hline( 0.0, "Z=0", color=color.new(#546e7a, 80), linestyle=hline.style_dotted)

if showTable and barstate.islast
    var table t = table.new(position.top_right, 2, 9,
         bgcolor=color.new(#1a1e2e, 5), border_color=color.new(#363a4f, 0),
         border_width=1, frame_color=color.new(#363a4f, 0), frame_width=1)

    hdrBg  = color.new(#252a3d, 0)
    hdrTxt = color.new(#a0a8c0, 0)
    valTxt = color.new(#dde0f0, 0)
    sz     = size.small

    table.cell(t, 0, 0, "Metric", text_color=hdrTxt, text_size=sz, bgcolor=hdrBg, text_halign=text.align_left)
    table.cell(t, 1, 0, "Value",  text_color=hdrTxt, text_size=sz, bgcolor=hdrBg, text_halign=text.align_right)

    sigTxt = longArb  ? "▲ CASH-AND-CARRY" : shortArb ? "▼ REVERSE CARRY" : "— NEUTRAL"
    sigCol = longArb  ? color.new(#00e676, 0) : shortArb ? color.new(#ff1744, 0) : color.new(#78909c, 0)
    table.cell(t, 0, 1, "Signal",  text_color=valTxt, text_size=sz, text_halign=text.align_left)
    table.cell(t, 1, 1, sigTxt,    text_color=sigCol, text_size=sz, text_halign=text.align_right)

    bColVal = basis > 0 ? color.new(#00e676, 0) : color.new(#ff1744, 0)
    table.cell(t, 0, 2, "Basis %", text_color=valTxt, text_size=sz, text_halign=text.align_left)
    table.cell(t, 1, 2, str.tostring(math.round(basis, 5)) + "%", text_color=bColVal, text_size=sz, text_halign=text.align_right)

    table.cell(t, 0, 3, "Break-Even %", text_color=valTxt, text_size=sz, text_halign=text.align_left)
    table.cell(t, 1, 3, "±" + str.tostring(math.round(breakEven, 3)) + "%", text_color=valTxt, text_size=sz, text_halign=text.align_right)

    edge    = math.abs(basis) - breakEven
    edgeCol = edge > 0 ? color.new(#00e676, 0) : color.new(#ff1744, 0)
    table.cell(t, 0, 4, "Edge vs Fees", text_color=valTxt, text_size=sz, text_halign=text.align_left)
    table.cell(t, 1, 4, (edge > 0 ? "+" : "") + str.tostring(math.round(edge, 4)) + "%", text_color=edgeCol, text_size=sz, text_halign=text.align_right)

    zTxtCol = math.abs(basisZ) > 2.0 ? color.new(#ffab00, 0) : valTxt
    table.cell(t, 0, 5, "Z-Score",  text_color=valTxt, text_size=sz, text_halign=text.align_left)
    table.cell(t, 1, 5, str.tostring(math.round(basisZ, 2)) + "σ", text_color=zTxtCol, text_size=sz, text_halign=text.align_right)

    table.cell(t, 0, 6, "Basis EMA", text_color=valTxt, text_size=sz, text_halign=text.align_left)
    table.cell(t, 1, 6, str.tostring(math.round(basisMA, 5)) + "%", text_color=color.new(#2196f3, 0), text_size=sz, text_halign=text.align_right)

    table.cell(t, 0, 7, str.tostring(lookback) + "b Range", text_color=valTxt, text_size=sz, text_halign=text.align_left)
    table.cell(t, 1, 7, str.tostring(math.round(basisLow, 3)) + " / " + str.tostring(math.round(basisHigh, 3)) + "%", text_color=valTxt, text_size=sz, text_halign=text.align_right)

    diff = futuresPrice - spotPrice
    dCol = diff > 0 ? color.new(#00e676, 0) : color.new(#ff1744, 0)
    table.cell(t, 0, 8, "Futs − Spot", text_color=valTxt, text_size=sz, text_halign=text.align_left)
    table.cell(t, 1, 8, (diff > 0 ? "+" : "") + str.tostring(math.round(diff, 2)), text_color=dCol, text_size=sz, text_halign=text.align_right)

alertcondition(longArb  and not longArb[1],  "Cash-and-Carry Opens",  "BASIS ARB ▲ — Futures premium exceeds break-even. Cash-and-carry window open.")
alertcondition(shortArb and not shortArb[1], "Reverse Carry Opens",   "BASIS ARB ▼ — Futures discount exceeds break-even. Reverse carry window open.")
alertcondition(not longArb  and longArb[1],  "Cash-and-Carry Closes", "BASIS ARB — Cash-and-carry spread compressed below break-even.")
alertcondition(not shortArb and shortArb[1], "Reverse Carry Closes",  "BASIS ARB — Reverse carry spread compressed below break-even.")
alertcondition(math.abs(basisZ) >= 2.5,      "Extreme Basis (±2.5σ)", "BASIS ARB ⚡ — Basis Z-score exceeded ±2.5σ. Statistically rare divergence.")
Enter fullscreen mode Exit fullscreen mode

Quick setup for BTC/ETH

Pair Spot Perp
BTC BINANCE:BTCUSDT BINANCE:BTCUSDT.P
ETH BINANCE:ETHUSDT BINANCE:ETHUSDT.P
SOL BINANCE:SOLUSDT BINANCE:SOLUSDT.P

The full source is also on GitHub Gist: https://gist.github.com/nexusnetworkai-jpg/e38e524a1efce3435ad51bcc5576ca02


If you trade the full suite (FVG Pro + Liquidity Zones + Order Blocks MTF) the basis monitor pairs well as a market-regime filter — don't trade directional SMC setups into a large futures premium/discount without knowing where the carry trade is.

Top comments (0)