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 × 100in 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
- Cash-and-carry opens (spread crosses above break-even)
- Cash-and-carry closes (spread compresses back)
- Reverse carry opens
- Reverse carry closes
- Extreme basis — Z-score hits ±2.5σ (statistically rare divergence)
How to use
- Open a spot chart on TradingView (e.g.
BINANCE:BTCUSDT) - Add this indicator as a new pane
- Set Perpetual Futures Symbol to the matching perp (
BINANCE:BTCUSDT.P) - Set Round-trip Fee % to your exchange's taker fee × 2 (Binance = 0.08%)
- Optional: add a Profit Buffer % (default 0.05%) to guard against slippage
- 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.")
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)