Building Candlesticks in Rust

Posted October 29, 2022

Candlesticks are a common way to represent price and volume of an asset over a period of time. There are various common types of bars such as time, volume, tick bars, hieken-ashi, renko to name a few. There is a lot of information about the implementations of these on the internet so their details will not be covered here. The aim of this article is to share some tips for implementation and also an implementation of Marcos Lopez de Prado’s volume imbalance bars from the book Advances in financial machine learning.

This article assumes that the reader is familiar with what a candlestick is, and the common types of methods of building candlesticks. If not then this Alternative Candlestick Types covers the basics.

There is also the question of why use volume bars or tick bars over time bars. The general idea is that due to volatility clustering asset returns are heteroskedastic so the alternative candlesticks aim to sample by information rate as opposed to time. This is a similar idea to stochastic volatility models such as the Heston Model in which the process for the volatility is separately modelled and then just assumes everything else is normal.

This article covers a lot of the information regarding not only these alternative bars but the impact on the statistical properties of the resulting return series Bars. TLDR; kurtosis is bad. You don’t want to find out the trend has changed, but waiting for a new candle stick left the position 20% underwater.

Let’s start by defining a bar

#[derive(Debug, Clone, Serialize)]
pub struct Bar {
    pub instrument_id: u32,        // instrument id
    pub open: f64,                 // open price
    pub high: f64,                 // high price
    pub low: f64,                  // low price
    pub close: f64,                // close price
    pub buy_volume: f64,           // total buy volume
    pub sell_volume: f64,          // total sell volume
    pub vwap_price: f64,           // volume weighted average price for bar
    pub open_ts: u64,              // timestamp in ns
    pub close_ts: u64,             // timestamp in ns
}


There a couple of extra variables of importance compared with OHLCV bars. Volume should be signed since there is information in the imbalance between buys and sells. Also volume weighted average price (vwap) can be tracked over the period since close-vwap contains information about momentum/commitment.

Time Bars

Define the BarBuilder trait ( i.e. interface).

pub trait BarBuilder {
    fn trade_update(&mut self, timestamp: &u64, price: &f64, qty: &f64) -> Option<Bar>;
}

Define TimeBarBuilder class. Notice in rust it doesn’t implement the BarBuilder interface.

pub struct TimeBarBuilder {
    time_window: u64,
    next_bar_time: u64,
    bar: Bar,
}

Constructor implementation

impl TimeBarBuilder {
    pub fn new(instrument_id: u32, time_window: u64) -> Self {
        TimeBarBuilder {
            time_window,
            next_bar_time: 0,
            bar: Bar::new(instrument_id),
        }
    }
}

Implement trade_update trait for the TimeBarBuilder

impl BarBuilder for TimeBarBuilder {
    fn trade_update(&mut self, timestamp: &u64, price: &f64, qty: &f64) -> Option<Bar> {
        let mut res: Option<Bar> = None;

        // check if there is a new bar
        if *timestamp > self.next_bar_time {
            // set the next time a bar should publish
            let rem: u64 = *timestamp % self.time_window;
            self.next_bar_time = *timestamp - rem + self.time_window;

            self.bar.close = *price;
            self.bar.close_ts = *timestamp;

            // compute the volume weighted average price for the bar
            self.bar.vwap_price /= self.bar.buy_volume - self.bar.sell_volume;

            if self.bar.open_ts != 0 {
                // take a copy of the bar information
                res = Some(self.bar.clone());
            }

            // reset the tracking values
            self.bar.reset(price, timestamp);
        }

        // update high/low prices
        if *price > self.bar.high { self.bar.high = *price; }
        else if *price < self.bar.low { self.bar.low = *price; }

        // update the vwap and quantity information
        self.bar.vwap_price += *price * (*qty).abs();
        if *qty > 0.0 { self.bar.buy_volume += *qty; }
        else { self.bar.sell_volume += *qty; }

        return res;
    }
}

Volume bars

Define VolumeBarBuilder class

pub struct VolumeBarBuilder {
    volume_threshold: f64,
    bar: Bar,
}

Constructor implementation

impl VolumeBarBuilder {
    pub fn new(instrument_id: u32, volume_threshold: f64) -> Self {
        VolumeBarBuilder {
            volume_threshold,
            bar: Bar::new(instrument_id),
        }
    }
}

Implement trade_update trait for the VolumeBarBuilder

impl BarBuilder for VolumeBarBuilder {
    fn trade_update(&mut self, timestamp: &u64, price: &f64, qty: &f64) -> Option<Bar> {
        let mut res: Option<Bar> = None;

        // a new bar is created when the volume threshold is crossed
        if (self.bar.buy_volume - self.bar.sell_volume) > self.volume_threshold {
            self.bar.close = *price;
            self.bar.close_ts = *timestamp;

            // compute the volume weighted average price for the bar
            self.bar.vwap_price /= self.bar.buy_volume - self.bar.sell_volume;

            if self.bar.open_ts != 0 {
                // take a copy of the bar information
                res = Some(self.bar.clone());
            }

            // reset the tracking values
            self.bar.reset(price, timestamp);
        }

        // update high/low prices
        if *price > self.bar.high { self.bar.high = *price; }
        else if *price < self.bar.low { self.bar.low = *price; }

        // update the vwap and quantity information
        self.bar.vwap_price += *price * (*qty).abs();
        if *qty > 0.0 { self.bar.buy_volume += *qty; }
        else { self.bar.sell_volume += *qty; }

        return res;
    }
}

Volume Time Bars

What is the correct threshold for a volume bar builder? Rather than set it to a single value it might be convenient to set it to a dynamic value based on the volume of recent X time bars. So a 5m volume time bar would create a new bar when the volume is larger than the recent average of 5 minute bars. This helps in two ways, one is that when working with different assets you don’t need to specify values for each individual asset, the second is that the average then naturally includes measure of seasonality. The aim of volume bars is to sample more frequently when “something unusual is happening” so it’s useful to have a benchmark of normal. It would be possible to go further in regard to seasonality modelling and if you are interested there is a review of using spline models to build seasonality models in my Masters Thesis.

This idea can be used to solve some hyper parameter problem associated with Marcos Lopez de Prado’s volume imbalance bars. The idea of a volume imbalance bar is that a new bar should be created if either a volume threshold is passed or the ratio of buys to sells exceeds a threshold. The implementation of this can be tricky since the thresholds need to be set. If the thresholds are calculated using the generated bars they can have degenerate behaviour where expectations tend towards zero or infinity.

My solution to this is to set the imbalance threshold to be a % of the expected volume so if the volume is greater than x or the imbalance threshold is greater than 20% then a new bar will be created. This means only parameters needed are the time aggregation, the decay for the volume ema for the time builder and the imbalance threshold percent. Since the threshold is not a function of the generated imbalance bars the system does not have issues with the expectations tending to zero or infinity.

The following is the implementation of a dynamic volume imbalance bar builder.

// implementation of a volume imbalance builder

pub struct DynVolumeImbBarBuilder<T: BarBuilder> {
    imb_threshold: f64,    // imbalance threashold [0,1]
    exp_volume: f64,       // volume threshold
    exp_imbalance: f64,    // imbalance threshold
    builder: T,            // bar bulider for generating samples for threshold
    bar: Bar,              // storage
    volume_info: Sma,      // moving average calulating recent volume
}

note the <T: BarBuilder> which means the builder uses another builder to be its “clock”. Normally a time bar is used but other types of bar could also be used such a range bar. This would give a new bar when an amount of volume trades that would normally move the market by a specified percentage.


impl<T: BarBuilder> DynVolumeBarBuilder<T> {
    pub fn new(instrument_id: u32, builder: T, imb_threshold: f64, vol_halflife: u16) -> Self {
        debug_assert!(0.0 <= imb_threshold && imb_threshold <= 1.0);

        DynVolumeImbBarBuilder {
            imb_threshold,
            exp_volume: 0.0,
            exp_imbalance: 0.0,
            builder,
            bar: Bar::new(instrument_id),
            volume_info: Sma::new(vol_halflife),
        }
    }

    pub fn trade_update(&mut self, timestamp: &u64, price: &f64, qty: &f64) -> Option<Bar> {
        let mut res: Option<Bar> = None;

        // call the driver bar builder to estimate the expected volume per bar
        match self.builder.trade_update(timestamp, price, qty) {
            Some(bar) => {
                if !is_close(bar.high, bar.low) {
                    let volume = bar.buy_volume + bar.sell_volume.abs();
                    self.exp_volume = self.volume_info.update(volume);
                    self.exp_imbalance = volume * self.imb_threshold;
                }
            }
            None => {}
        }

        // check the threshold is set
        if self.exp_volume == 0.0 {
            return None;
        }

        // n.b sell_volume is -ve
        let total_volume = self.bar.buy_volume - self.bar.sell_volume;
        let volume_imb = (self.bar.buy_volume + self.bar.sell_volume).abs();
        if total_volume > self.exp_volume || volume_imb > self.exp_imbalance {
            // set the next time a bar should publish
            self.bar.close = *price;
            self.bar.close_ts = *timestamp;

            // compute the volume weighted average price for the bar
            self.bar.vwap_price /= self.bar.buy_volume - self.bar.sell_volume;

            let mut res: Option<Bar> = None;
            if self.bar.open_ts != 0 {
                // take a copy of the bar information
                res = Some(self.bar.clone());
            }

            // reset the tracking values
            self.bar.reset(price, timestamp);
        }

        // update high/low prices
        if *price > self.bar.high { self.bar.high = *price; }
        else if *price < self.bar.low { self.bar.low = *price; }

        // update the vwap and quantity information
        self.bar.vwap_price += *price * (*qty).abs();
        if *qty > 0.0 { self.bar.buy_volume += *qty; }
        else { self.bar.sell_volume += *qty; }

        return res
    }
}

Multi Bar Volume Imbalance Bars

I need to write an article about correlation structure in cryptos since its fascinating. It’s mostly fascinating since there isn’t any! The first principal component of the correlation matrix is about 85% of variance and the first two components explained about 95% of the market. So what does that means in practical terms? When bitcoin moves, or etherium moves, so does everything else. There are good posts by tr8dr in regard to correlation clustering and transfer-entropy. When this is applied to cryptocurrencies the results is not a very complex graph, but you can see the structures between L0 coins, L1 coins DeFi/NFTs coins etc.

Conceptually I think volume should not be thought of as per asset, but per risk factor. So what we can do is make a volume bar builder which ticks not when the volume in asset X breaks a threshold but when the amount of risk in a certain risk factor breaks a threshold.

A very simple version of this can be done by monitoring multiple assets and aggregating their volume. A new set of candle sticks are generated when the aggregated volume crosses a threshold. This is basically assuming that correlations are 1 for all assets which in crypto is not a particularly bad assumption. n.b. The below implementation assumes all assets are in the same quoted currency.

Define the trait for a multi bar builder

pub trait MultiBarBuilder {
    fn trade_update( &mut self, timestamp: &u64, instrument_id: &u32,
        price: &f64, // price
        qty: &f64    // signed quantity
    ) -> Option<Vec<Bar>>;
}

Define the MultiDynVolumeImbBarBuilder class

pub struct MultiDynVolumeImbBarBuilder<T: MultiBarBuilder> {
    imb_threshold: f64,            // threshold parameter
    exp_volume: f64,               // expected volume
    exp_imbalance: f64,            // expected imbalance
    total_buy_volume: f64,         // total buys of all assets
    total_sell_volume: f64,        // total sells of all assets
    time_bar_counter: u32,         // number of time bars
    wait_time_bars: u32,           // number of bars to build threshold
    builder: T,
    bars: HashMap<u32, Bar>,       // collection of bars

    volume_info: Sma,
}

Implement the constructor and update methods

impl<T: MultiBarBuilder> MultiDynVolumeImbBarBuilder<T> {

    /* constructor */
    pub fn new(
        instrument_ids: &Vec<u32>,
        builder: T,
        imb_threshold: f64,
        vol_halflife: u16,
        wait_time_bars: u32,
    ) -> Self {
        debug_assert!(0.0 <= imb_threshold && imb_threshold <= 1.0);

        let mut bars: HashMap<u32, Bar> = HashMap::new();
        for iid in instrument_ids {
            bars.insert(*iid, Bar::new(*iid));
        }

        MultiDynVolumeImbBarBuilder {
            imb_threshold,
            exp_volume: 0.0,
            exp_imbalance: 0.0,
            total_buy_volume: 0.0,
            total_sell_volume: 0.0,
            time_bar_counter: 0,
            wait_time_bars,
            builder,
            bars,
            volume_info: Sma::new(vol_halflife),
        }
    }

    /* constructor */
    pub fn trade_update(&mut self, timestamp: &u64, instrument_id: &u32, price: &f64, qty: &f64) -> Option<Vec<Bar>> {
        let mut res: Option<Vec<Bar>> = None;

        // update normalisation bar builder to calculate average bar information
        match self
            .builder
            .trade_update(timestamp, instrument_id, price, qty)
        {
            Some(bars) => {
                let mut volume: f64 = 0.0;

                for bar in &bars {
                    if bar.buy_volume > 0.0 || bar.sell_volume > 0.0 {
                        volume += bar.buy_volume - bar.sell_volume;
                    }
                }

                // update average volume
                self.volume_info.update(volume);

                // wait until we have a few updates before publishing
                if self.time_bar_counter >= self.wait_time_bars {
                    self.exp_volume = self.volume_info.value;
                    self.exp_imbalance = self.volume_info.value * self.imb_threshold;
                }

                self.time_bar_counter += 1
            }
            None => {}
        }

        // n.b sell_volume is -ve
        let total_volume = self.total_buy_volume - self.total_sell_volume;
        let volume_imb = (self.total_buy_volume + self.total_sell_volume).abs();
        if self.exp_volume > 0.0 &&      // check the the expected volume is set up
            (total_volume > self.exp_volume || volume_imb > self.exp_imbalance)
        {
            // new bar to publish so record results
            let mut bar_res: Vec<Bar> = Vec::new();
            for (iid, tbar) in &self.bars..iter_mut() {

                if iid == *instrument_id { tbar.close = *price; }
                tbar.close_ts = *timestamp;
                tbar.vwap_price /= (tbar.buy_volume - tbar.sell_volume);

                // record bar and reset the tracking values
                bar_res.push(tbar.clone());
                tbar.reset(price, timestamp);
            }

            self.total_buy_volume = 0.0;
            self.total_sell_volume = 0.0;

            res = Some(bar_res);
        }

        // update bars
        let mut bar = self.bars.get_mut(instrument_id).unwrap();

        bar.close = *price;

        // update high/low prices
        bar.high = f64::max(bar.high, *price);
        bar.low = f64::min(bar.low, *price);

        // update the vwap and quantity information
        bar.vwap_price += *price * (*qty).abs();
        if *qty > 0.0 {
            bar.buy_volume += *qty;
            total_buy_volume += *qty;
        }
        else {
            bar.sell_volume += *qty;
            self.total_sell_volume += *qty;
        }

        return res;
    }
}

It should be noted that if a simple multi volume bar builder is needed then the imb_threshold can be set to 1.0. This will give the same behaviour as not having the volume imbalance check at all.

The performance of these implementations could be improved upon. This is from my research code so for the sake of code re-use one bar builder is embedded in another. Since only the time by volume needs to be tracked for the dynamic volume bars it would be possible to simply use an exponentially decaying sum of volume as the threshold. This would be more performant than generating complete time bars only to throw away almost all the information. Also since bars are cloned it would be better to have the builder store the bar and then return a boolean with true as there being a new bar. This would avoid any over head from memory allocation.

Conclusion

  1. Using time bars to set the volume threshold is a neat way to avoid seasonality issues and also the complexity of defining multiple thresholds for different assets.
  2. Using time bars to set a baseline for volume imbalance bars is a good way to avoid the problem of the threshold becoming degenerate and greatly simplifies the choosing of hyper parameters.
  3. When multiple assets are concerned volume can be thought of as risk and its worth generating volume bars synchronously by looking at total traded risk.

I think these ideas are important specifically in cryptocurrency markets due to the kurtosis necessitating some for of informationally uniform sampling (volume or volume imbalance) and the correlation structure meaning volume traded on one asset cannot be viewed independently of the rest.

good luck, have fun.