FlowscopeDocs

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

cvd-breakout-strategy.fs
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

ExitRule
StopThe midpoint of the broken range, as an absolute price
Trailing stopActivates after trailAtr × ATR of profit, then trails by the same distance
Time stopstrategy.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 each strategy.exit (for example 3 × ATR) and compare the profit factor with the trailing-only version.