A range bar

Bar types

A range bar

Price-distance bar types can close more than one bar on a single print. This is the multi-close pattern: loop the threshold, write each bar into the span, and keep volume honest.

Closing several bars in one tick

A tick bar closes at most one bar per tick, but a price-distance rule is different: one violent print can jump across several thresholds at once, and each crossing is a finished bar. This is exactly why OnTick returns a count instead of a bool — you loop while price keeps crossing the threshold, write one bar per crossing into closedBars, and return how many you wrote.

A range bar also sets ClosingTickBelongsToNextBar => true: standard range semantics chain, so each new bar opens at the previous bar's close (the threshold price), and the bars butt up against each other with no gap.

Tick size comes in through the constructor

RangeTicks is a count, but the rule operates in price, so the builder needs the instrument's tick size (the minimum price increment) to turn "4 ticks" into a price distance. The builder does not hold an IInstrumentMetadata reference and does not look anything up at run time — the tick size is resolved once, at registration, and handed to the constructor as a plain double (see Registration). The built-in RangeBarBuilder does exactly this.

A worked range builder

The builder below opens a bar at the first trade, then on every subsequent trade closes as many bars as the move spans. Each closed bar is clamped to the threshold price, and the next bar opens there — no gap. Because it is a threshold builder, it leaves ResetsOnSessionBoundary at its default of true and implements ForceCloseCurrentBar so a forming bar is flushed at the session boundary rather than bleeding across the overnight gap.

NRangeBarBuilder.csusing System;
using TradeStrike.Pipeline.Bars;
using TradeStrike.Pipeline.Ticks;

public sealed class NRangeBarBuilder : ITickBarBuilder
{
    private readonly RangeBarSpec _spec;
    private readonly double _range;   // RangeTicks * tickSize, resolved at registration
    private bool _has;
    private double _o, _h, _l, _c;
    private long _vol, _ticks;
    private DateTime _start, _end;

    public NRangeBarBuilder(RangeBarSpec spec, double tickSize)
    {
        _spec = spec;
        _range = spec.RangeTicks * tickSize;
    }

    public BarSpecification Spec => _spec;
    public bool HasCurrentBar => _has;
    public Bar CurrentBar => _has
        ? new(_start, _end, _o, _h, _l, _c, _vol, _ticks)
        : throw new InvalidOperationException("No bar in progress.");

    // The threshold-crossing tick opens the NEXT band — it is not part of the bar that closed.
    public bool ClosingTickBelongsToNextBar => true;

    public int OnTick(in Tick tick, Span<Bar> closedBars)
    {
        if ((tick.Flags & TickFlags.Trade) == 0) return 0;
        _end = tick.ExchangeTimestampUtc;

        if (!_has) { Open(tick.Price, tick.ExchangeTimestampUtc); _vol = tick.Size; _ticks = 1; return 0; }

        int closed = 0;
        // One print may jump several thresholds — emit one bar per crossing.
        while (tick.Price >= _o + _range || tick.Price <= _o - _range)
        {
            double edge = tick.Price > _o ? _o + _range : _o - _range;
            _h = Math.Max(_h, edge); _l = Math.Min(_l, edge);
            // Credit volume/ticks to the FIRST closing bar only; the print is one event.
            closedBars[closed] = new Bar(_start, tick.ExchangeTimestampUtc, _o, _h, _l, edge,
                                         closed == 0 ? _vol + tick.Size : 0,
                                         closed == 0 ? _ticks + 1 : 0);
            closed++;
            Open(edge, tick.ExchangeTimestampUtc);   // next band opens at the threshold — no gap
            if (closed == closedBars.Length) break;  // never overflow the caller's span
        }
        if (closed == 0) { _h = Math.Max(_h, tick.Price); _l = Math.Min(_l, tick.Price); _c = tick.Price; _vol += tick.Size; _ticks++; }
        return closed;
    }

    // Threshold builder: flush the forming bar at a session boundary.
    public int ForceCloseCurrentBar(Span<Bar> closedBars)
    {
        if (!_has) return 0;
        closedBars[0] = new Bar(_start, _end, _o, _h, _l, _c, _vol, _ticks);
        _has = false;
        return 1;
    }

    private void Open(double price, DateTime ts)
    {
        _has = true; _start = ts; _end = ts;
        _o = _h = _l = _c = price; _vol = 0; _ticks = 0;
    }
}

Volume accounting

The subtle correctness issue here is volume. All of those bars closed on one print, so attributing the print's volume to every bar would multiply it. Credit it once — to the first bar that consumed the print — and leave the rest with the volume they accrued before this tick (zero, for bars that opened and closed on the same print).

Volume accounting. A single print is one event. When one tick closes multiple bars, credit its volume / tick-count to the first closing bar only — the others closed on the same print and saw no new volume.
Range bars chain, so they seed. Because each bar opens at the previous close, a range series must continue from history at the live handoff or it leaves a phantom gap. Range / Renko / Kagi / P&F / Line-Break builders opt into seeding — see Seeding the live handoff.