# 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

```flowscope title="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

| 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](/docs/scripting/strategies/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](/docs/scripting/examples/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.

## Related

- [EMA and ATR strategy](/docs/scripting/examples/ema-atr-strategy)
- [Strategies & backtesting](/docs/scripting/strategies), [orders](/docs/scripting/strategies/orders)
