CVD breakout strategy
A range breakout confirmed by cumulative delta and by the bar's buy/sell imbalance, with a funding filter, a trailing stop and a time stop.
Price breakouts fail often. This strategy only takes a breakout when the order flow behind it agrees: cumulative volume delta must break out of its own range with price, and aggressive volume on the breakout bar must lean the same way. It skips entries into crowded funding and pays funding while positioned. It reads best on an aggregated market, where the flow covers every venue.
The script
script "CVD breakout"
input (
lookback = input.int(36, title: "Range lookback (bars)", min: 5, max: 500)
minImbalance = input.float(0.15, title: "Min bar imbalance", min: 0.0, max: 1.0)
maxFundingPct = input.float(0.03, title: "Max funding against entry (% per period)", min: 0.0)
atrLen = input.int(14, title: "ATR length", min: 1, max: 100)
trailAtr = input.float(2.0, title: "Trail distance (ATR)", min: 0.2)
maxBars = input.int(48, title: "Time stop (bars)", min: 1, max: 1000)
riskPct = input.float(0.75, title: "Risk per trade (% equity)", min: 0.1, max: 5.0)
)
strategy(
initialCapital: 25000.0,
commissionType: strategy.commission.percent,
commissionValue: 0.05,
slippage: 2,
leverage: 3.0,
fundingCosts: true
)
data (
chart = subscribe(data.ohlcv)
cvd = subscribe(data.cvd)
stats = subscribe(data.stat)
)
pane flowPane = pane(title: "Bar imbalance", height: 0.2)
plot (
hiLine = plot.line(title: "Range high", color: color.cyan, style: linestyle.step)
loLine = plot.line(title: "Range low", color: color.cyan, style: linestyle.step)
imbBars = plot.histogram(title: "Buy/sell imbalance", on: flowPane)
)
state hud = entities.labelPool(max: 1, anchor: anchor.topRight)
on chart.close {
// Range of the previous bars (the current bar is excluded).
let rangeHigh = ta.highest(chart.high[1], lookback)
let rangeLow = ta.lowest(chart.low[1], lookback)
let cvdHigh = ta.highest(cvd.close[1], lookback)
let cvdLow = ta.lowest(cvd.close[1], lookback)
let atr = chart.atr(atrLen)
hiLine.plot(rangeHigh)
loLine.plot(rangeLow)
// Aggressive buys against sells on this bar: -1 .. +1.
let buys = chart.buyVolume ?? 0.0
let sells = chart.sellVolume ?? 0.0
let imbalance = buys + sells > 0.0 ? (buys - sells) / (buys + sells) : 0.0
imbBars.plot(imbalance, color: imbalance >= 0.0 ? color.green : color.red)
// Funding in percent per settlement period.
let fundingPct = (stats.fundingRate ?? 0.0) * 100.0
let price = chart.close
let cvdNow = cvd.close
let priceUp = price != null && rangeHigh != null && price > rangeHigh
let priceDown = price != null && rangeLow != null && price < rangeLow
let cvdUp = cvdNow != null && cvdHigh != null && cvdNow > cvdHigh
let cvdDown = cvdNow != null && cvdLow != null && cvdNow < cvdLow
let longOk = priceUp && cvdUp && imbalance >= minImbalance && fundingPct <= maxFundingPct
let shortOk = priceDown && cvdDown && imbalance <= 0.0 - minImbalance && fundingPct >= 0.0 - maxFundingPct
let flat = strategy.positionSize == 0.0
if flat && atr != null && price != null && rangeHigh != null && rangeLow != null {
let mid = (rangeHigh + rangeLow) / 2.0
let trailTicks = math.round(atr * trailAtr / context.tickSize)
let riskCash = strategy.equity * riskPct / 100.0
if longOk && price > mid {
// Stop at the middle of the broken range; risk sized to that distance.
strategy.entry("BO long", strategy.long, qty: riskCash / (price - mid))
strategy.exit("BO long exit", "BO long", stop: mid, trailPoints: trailTicks, trailOffset: trailTicks)
} else if shortOk && price < mid {
strategy.entry("BO short", strategy.short, qty: riskCash / (mid - price))
strategy.exit("BO short exit", "BO short", stop: mid, trailPoints: trailTicks, trailOffset: trailTicks)
}
}
// Time stop: breakouts that go nowhere are closed.
let open = strategy.openTrade(0)
if open != null && chart.index - open.entryBarIndex >= maxBars {
strategy.closeAll(comment: "Time stop")
}
let text = str.format("Funding {0:.4}%\nImbalance {1:.2}\nEquity {2:,.2}\nNet {3:,.2} Trades {4}", fundingPct, imbalance, strategy.equity, strategy.netProfit, strategy.closedTrades)
hud.get("status").set(12.0, 12.0, text, font: font.mono)
}Open a liquid market, for example BTC aggregated across venues, on a 15m or 1h chart, and add the script. The Strategy Tester opens with the backtest.
How it works
Two ranges, not one
rangeHigh and rangeLow are the highest high and lowest low of the previous
lookback bars. Passing chart.high[1] excludes the current bar, so a close above
rangeHigh is a genuine breakout of a level that existed before the bar. The same is
done for CVD: cvd.close must also exceed its own prior range. A price breakout with
CVD still inside its range means passive sellers absorbed the move, which is the
pattern this filter is designed to reject.
Bar imbalance
buyVolume and sellVolume are the bar’s aggressive buy and sell volume. Their
difference over their sum is an imbalance between −1 (all aggressive volume sold) and
+1 (all bought). A long needs at least minImbalance on the breakout bar. The
histogram in the lower pane shows the value on every bar, so you can see how often
the filter passes.
Funding filter
stats.fundingRate is the funding rate per settlement period, as a fraction. The
script converts it to percent and refuses longs when longs already pay more than
maxFundingPct, and shorts when shorts pay that much. Crowded positioning makes
breakouts more likely to reverse, and it also makes holding them expensive. Spot
markets have no funding; the filter then always passes.
Exits
| Exit | Rule |
|---|---|
| Stop | The midpoint of the broken range, as an absolute price |
| Trailing stop | Activates after trailAtr × ATR of profit, then trails by the same distance |
| Time stop | strategy.closeAll after maxBars bars in the trade |
There is no fixed target: breakouts that work tend to run, and the trailing stop
lets them. Because the stop is a price level, the size is the risk budget divided by
the distance from the signal close to that level. The real fill is at the next open,
so realised risk can differ slightly. The time stop reads the open trade’s
entryBarIndex from strategy.openTrade(0).
Costs
fundingCosts: true charges or credits funding at the market’s settlements while a
position is open. Commission is set to a conservative 0.05 % and slippage to
2 ticks. See the broker emulator for
how fills are simulated.
Variations
- Large traders only. Replace the bar imbalance with the imbalance of prints above a notional floor from a trades subscription, as in size-band delta. Trades reach back only as far as the chart holds them, so the backtest gets shorter.
- Liquidation confirmation. Require
stats.buyLiq(shorts liquidated) above a threshold on long breakouts: forced buying adds fuel. - Funding as a signal. Invert the funding filter and only trade breakouts against crowded funding, then compare the Long and Short columns.
- Fixed target. Add
profit:in ticks to eachstrategy.exit(for example 3 × ATR) and compare the profit factor with the trailing-only version.