feat: add 7 alt-chart bar builders (B19) (#220)

Adds seven information-driven bar builders to the **Alt-Chart Bars** family, the final batch of the family-deepening run. Indicator count **507 → 514**.

## Builders
All implement the `BarBuilder` trait (`update(Candle) -> Vec<Bar>`), emitting a data-dependent number of completed bars per candle.

| Builder | Driver | Bar fields |
|---------|--------|-----------|
| `RangeBars` | close | open, close, direction |
| `TickBars` | OHLCV | open, high, low, close, volume |
| `VolumeBars` | OHLCV | open, high, low, close, volume |
| `DollarBars` (Lopez de Prado) | OHLCV | + dollar |
| `ImbalanceBars` | OHLC | + imbalance, direction |
| `RunBars` | OHLC | + length, direction |
| `ThreeLineBreakBars` | close | open, close, direction |

## Touchpoints
Seven core modules (each with full unit tests), `mod.rs`/`lib.rs` (builders counted, bar element types on their own re-export lines), README family rows, Python/Node/WASM hand-written bindings for the variable-length output (Python tuples + `(k, N)` ndarray; Node `Vec<object>`; WASM array of objects), the `bar_builder_update_candle` fuzz target, dedicated Python + Node tests, the `BAR_BUILDERS` completeness exclusion, and CHANGELOG.

## Verification
- `cargo test -p wickra-core --lib` — 4207 passed
- `cargo test -p wickra-core --doc` — 464 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` — clean
- `npm test` (node) — 584 passed
- `pytest` (python) — 957 passed
This commit is contained in:
kingchenc
2026-06-08 14:32:40 +02:00
committed by GitHub
parent 46be7a54ea
commit e5305ffa94
22 changed files with 3488 additions and 49 deletions
@@ -0,0 +1,224 @@
//! Dollar bar builder — close a bar each time accumulated traded value reaches a threshold.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::BarBuilder;
/// One completed dollar bar (an OHLC aggregate spanning ~`dollar_per_bar` of traded value).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DollarBar {
/// Open of the first candle in the bar.
pub open: f64,
/// Highest high across the bar.
pub high: f64,
/// Lowest low across the bar.
pub low: f64,
/// Close of the candle that closed the bar.
pub close: f64,
/// Summed volume across the bar.
pub volume: f64,
/// Accumulated traded value (`Σ close · volume`, `>= dollar_per_bar`).
pub dollar: f64,
}
/// Dollar bar builder — emits a bar each time accumulated traded value
/// (`price × volume`) reaches `dollar_per_bar`.
///
/// Dollar bars are the most drift-robust of the information-driven bar types. Where
/// [`VolumeBars`](crate::VolumeBars) close on a fixed *quantity* of shares/contracts,
/// dollar bars close on a fixed *value*: each candle contributes `close × volume` to
/// the running total. As a market's price level rises over years, a fixed share
/// count buys ever more value and volume bars drift in meaning; dollar bars stay
/// economically comparable across the whole history, which is why they are the
/// preferred sampling for long backtests and machine-learning features.
///
/// The bar is candle-granular: at most one bar closes per candle, and the candle
/// that crosses the threshold closes the bar with its overshoot included.
/// [`BarBuilder::update`] returns either an empty vector or a single [`DollarBar`].
///
/// # Example
///
/// ```
/// use wickra_core::{BarBuilder, Candle, DollarBars};
///
/// let c = |cl, v| Candle::new(cl, cl, cl, cl, v, 0).unwrap();
/// let mut bars = DollarBars::new(1000.0).unwrap();
/// assert!(bars.update(c(10.0, 60.0)).is_empty()); // 600
/// let out = bars.update(c(10.0, 60.0)); // 1200 >= 1000 -> close
/// assert_eq!(out.len(), 1);
/// assert_eq!(out[0].dollar, 1200.0);
/// ```
#[derive(Debug, Clone)]
pub struct DollarBars {
dollar_per_bar: f64,
count: usize,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
dollar: f64,
}
impl DollarBars {
/// Construct a dollar-bar builder with the given traded-value threshold.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `dollar_per_bar` is not finite and positive.
pub fn new(dollar_per_bar: f64) -> Result<Self> {
if !dollar_per_bar.is_finite() || dollar_per_bar <= 0.0 {
return Err(Error::InvalidPeriod {
message: "dollar_per_bar must be finite and positive",
});
}
Ok(Self {
dollar_per_bar,
count: 0,
open: 0.0,
high: 0.0,
low: 0.0,
close: 0.0,
volume: 0.0,
dollar: 0.0,
})
}
/// Configured traded-value threshold per bar.
pub const fn dollar_per_bar(&self) -> f64 {
self.dollar_per_bar
}
/// Traded value accumulated into the in-progress bar.
pub const fn accumulated(&self) -> f64 {
self.dollar
}
}
impl BarBuilder for DollarBars {
type Bar = DollarBar;
fn update(&mut self, candle: Candle) -> Vec<DollarBar> {
if self.count == 0 {
self.open = candle.open;
self.high = candle.high;
self.low = candle.low;
self.volume = 0.0;
} else {
self.high = self.high.max(candle.high);
self.low = self.low.min(candle.low);
}
self.close = candle.close;
self.volume += candle.volume;
self.dollar += candle.close * candle.volume;
self.count += 1;
if self.dollar < self.dollar_per_bar {
return Vec::new();
}
let bar = DollarBar {
open: self.open,
high: self.high,
low: self.low,
close: self.close,
volume: self.volume,
dollar: self.dollar,
};
self.count = 0;
self.dollar = 0.0;
vec![bar]
}
fn reset(&mut self) {
self.count = 0;
self.volume = 0.0;
self.dollar = 0.0;
}
fn name(&self) -> &'static str {
"DollarBars"
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64) -> Candle {
Candle::new(open, high, low, close, volume, 0).unwrap()
}
#[test]
fn rejects_invalid_threshold() {
assert!(matches!(
DollarBars::new(0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
DollarBars::new(-1000.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
DollarBars::new(f64::NAN),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let bars = DollarBars::new(50_000.0).unwrap();
assert_relative_eq!(bars.dollar_per_bar(), 50_000.0, epsilon = 1e-6);
assert_relative_eq!(bars.accumulated(), 0.0, epsilon = 1e-12);
assert_eq!(bars.name(), "DollarBars");
}
#[test]
fn closes_when_value_reached() {
let mut bars = DollarBars::new(1000.0).unwrap();
assert!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 60.0)).is_empty()); // 600
let out = bars.update(candle(10.0, 10.0, 10.0, 10.0, 60.0)); // 1200
assert_eq!(out.len(), 1);
assert_relative_eq!(out[0].dollar, 1200.0, epsilon = 1e-9);
assert_relative_eq!(out[0].volume, 120.0, epsilon = 1e-12);
}
#[test]
fn aggregates_ohlc() {
let mut bars = DollarBars::new(1000.0).unwrap();
bars.update(candle(10.0, 11.0, 9.0, 10.0, 50.0)); // 500
let out = bars.update(candle(10.0, 12.0, 9.5, 11.0, 60.0)); // 500 + 660 = 1160
assert_relative_eq!(out[0].open, 10.0, epsilon = 1e-12);
assert_relative_eq!(out[0].high, 12.0, epsilon = 1e-12);
assert_relative_eq!(out[0].low, 9.0, epsilon = 1e-12);
assert_relative_eq!(out[0].close, 11.0, epsilon = 1e-12);
}
#[test]
fn below_threshold_emits_nothing() {
let mut bars = DollarBars::new(1000.0).unwrap();
bars.update(candle(10.0, 10.0, 10.0, 10.0, 30.0)); // 300
assert_relative_eq!(bars.accumulated(), 300.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut bars = DollarBars::new(1000.0).unwrap();
bars.update(candle(10.0, 10.0, 10.0, 10.0, 60.0));
bars.reset();
assert_relative_eq!(bars.accumulated(), 0.0, epsilon = 1e-12);
assert!(bars.update(candle(20.0, 20.0, 20.0, 20.0, 10.0)).is_empty());
}
#[test]
fn batch_concatenates_completed_bars() {
let mut bars = DollarBars::new(1000.0).unwrap();
let candles = [
candle(10.0, 10.0, 10.0, 10.0, 60.0),
candle(10.0, 10.0, 10.0, 10.0, 60.0),
candle(10.0, 10.0, 10.0, 10.0, 60.0),
candle(10.0, 10.0, 10.0, 10.0, 60.0),
];
let out = bars.batch(&candles);
assert_eq!(out.len(), 2);
}
}
@@ -0,0 +1,263 @@
//! Tick-imbalance bar builder (simplified López de Prado) — sample on cumulative signed order flow.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::BarBuilder;
/// One completed imbalance bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ImbalanceBar {
/// Open of the first candle in the bar.
pub open: f64,
/// Highest high across the bar.
pub high: f64,
/// Lowest low across the bar.
pub low: f64,
/// Close of the candle that closed the bar.
pub close: f64,
/// Signed cumulative tick imbalance at the close (`Σ sign`).
pub imbalance: f64,
/// `+1` if buy-side imbalance closed the bar, `-1` if sell-side.
pub direction: i8,
}
/// Tick-imbalance bar builder — a **simplified** form of López de Prado's
/// imbalance bars.
///
/// Each candle is assigned a tick sign by the tick rule: `+1` if its close is above
/// the previous close, `-1` if below, and the previous sign is carried on an
/// unchanged close. The signed imbalance `θ = Σ sign` accumulates until its absolute
/// value reaches a fixed `threshold`, at which point a bar closes. Imbalance bars
/// therefore sample the market when order flow becomes *one-sided* — a burst of
/// persistent buying or selling — rather than on time, count, or volume. This makes
/// them sensitive to informed, directional trading.
///
/// **Simplification.** The full method estimates a *dynamic* threshold
/// `E[T] · |2P 1|` from an EWMA of the expected bar length `E[T]` and the buy-tick
/// probability `P`, and can weight each sign by volume (volume-imbalance bars) or
/// traded value (dollar-imbalance bars). This builder uses a **fixed** threshold on
/// the unweighted tick imbalance. For the adaptive estimator and the volume/dollar
/// variants, see López de Prado (2018), ch. 2.
///
/// At most one bar closes per candle, so [`BarBuilder::update`] returns either an
/// empty vector or a single [`ImbalanceBar`].
///
/// # Example
///
/// ```
/// use wickra_core::{BarBuilder, Candle, ImbalanceBars};
///
/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap();
/// let mut bars = ImbalanceBars::new(3.0).unwrap();
/// bars.update(flat(10.0)); // seed, no sign
/// bars.update(flat(11.0)); // +1
/// bars.update(flat(12.0)); // +2
/// let out = bars.update(flat(13.0)); // +3 -> close
/// assert_eq!(out.len(), 1);
/// assert_eq!(out[0].direction, 1);
/// ```
#[derive(Debug, Clone)]
pub struct ImbalanceBars {
threshold: f64,
count: usize,
open: f64,
high: f64,
low: f64,
close: f64,
prev_close: Option<f64>,
last_sign: i8,
theta: f64,
}
impl ImbalanceBars {
/// Construct an imbalance-bar builder with the given absolute imbalance threshold.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `threshold` is not finite and positive.
pub fn new(threshold: f64) -> Result<Self> {
if !threshold.is_finite() || threshold <= 0.0 {
return Err(Error::InvalidPeriod {
message: "threshold must be finite and positive",
});
}
Ok(Self {
threshold,
count: 0,
open: 0.0,
high: 0.0,
low: 0.0,
close: 0.0,
prev_close: None,
last_sign: 0,
theta: 0.0,
})
}
/// Configured absolute imbalance threshold.
pub const fn threshold(&self) -> f64 {
self.threshold
}
/// Signed imbalance accumulated into the in-progress bar.
pub const fn imbalance(&self) -> f64 {
self.theta
}
}
impl BarBuilder for ImbalanceBars {
type Bar = ImbalanceBar;
fn update(&mut self, candle: Candle) -> Vec<ImbalanceBar> {
if self.count == 0 {
self.open = candle.open;
self.high = candle.high;
self.low = candle.low;
} else {
self.high = self.high.max(candle.high);
self.low = self.low.min(candle.low);
}
self.close = candle.close;
self.count += 1;
if let Some(prev) = self.prev_close {
let sign = if candle.close > prev {
1
} else if candle.close < prev {
-1
} else {
self.last_sign
};
self.last_sign = sign;
self.theta += f64::from(sign);
}
self.prev_close = Some(candle.close);
if self.theta.abs() < self.threshold {
return Vec::new();
}
let direction = if self.theta > 0.0 { 1 } else { -1 };
let bar = ImbalanceBar {
open: self.open,
high: self.high,
low: self.low,
close: self.close,
imbalance: self.theta,
direction,
};
self.count = 0;
self.theta = 0.0;
vec![bar]
}
fn reset(&mut self) {
self.count = 0;
self.prev_close = None;
self.last_sign = 0;
self.theta = 0.0;
}
fn name(&self) -> &'static str {
"ImbalanceBars"
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn flat(price: f64) -> Candle {
Candle::new(price, price, price, price, 1.0, 0).unwrap()
}
#[test]
fn rejects_invalid_threshold() {
assert!(matches!(
ImbalanceBars::new(0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
ImbalanceBars::new(-3.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
ImbalanceBars::new(f64::NAN),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let bars = ImbalanceBars::new(10.0).unwrap();
assert_relative_eq!(bars.threshold(), 10.0, epsilon = 1e-12);
assert_relative_eq!(bars.imbalance(), 0.0, epsilon = 1e-12);
assert_eq!(bars.name(), "ImbalanceBars");
}
#[test]
fn buy_imbalance_closes_up_bar() {
let mut bars = ImbalanceBars::new(3.0).unwrap();
bars.update(flat(10.0)); // seed
bars.update(flat(11.0)); // +1
bars.update(flat(12.0)); // +2
let out = bars.update(flat(13.0)); // +3
assert_eq!(out.len(), 1);
assert_eq!(out[0].direction, 1);
assert_relative_eq!(out[0].imbalance, 3.0, epsilon = 1e-12);
}
#[test]
fn sell_imbalance_closes_down_bar() {
let mut bars = ImbalanceBars::new(3.0).unwrap();
bars.update(flat(10.0));
bars.update(flat(9.0)); // -1
bars.update(flat(8.0)); // -2
let out = bars.update(flat(7.0)); // -3
assert_eq!(out.len(), 1);
assert_eq!(out[0].direction, -1);
}
#[test]
fn flat_tick_carries_previous_sign() {
let mut bars = ImbalanceBars::new(3.0).unwrap();
bars.update(flat(10.0));
bars.update(flat(11.0)); // +1
bars.update(flat(11.0)); // flat -> carries +1 -> +2
assert_relative_eq!(bars.imbalance(), 2.0, epsilon = 1e-12);
}
#[test]
fn oscillation_does_not_reach_threshold() {
let mut bars = ImbalanceBars::new(3.0).unwrap();
bars.update(flat(10.0));
bars.update(flat(11.0)); // +1
bars.update(flat(10.0)); // -1 -> theta 0
assert!(bars.update(flat(11.0)).is_empty()); // +1
assert_relative_eq!(bars.imbalance(), 1.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut bars = ImbalanceBars::new(3.0).unwrap();
bars.update(flat(10.0));
bars.update(flat(11.0));
bars.reset();
assert_relative_eq!(bars.imbalance(), 0.0, epsilon = 1e-12);
// After reset the next candle re-seeds (no previous close).
assert!(bars.update(flat(50.0)).is_empty());
}
#[test]
fn batch_concatenates_completed_bars() {
let mut bars = ImbalanceBars::new(2.0).unwrap();
let candles = [
flat(10.0),
flat(11.0), // +1
flat(12.0), // +2 -> close
flat(13.0), // +1
flat(14.0), // +2 -> close
];
let out = bars.batch(&candles);
assert_eq!(out.len(), 2);
assert!(out.iter().all(|b| b.direction == 1));
}
}
+27 -2
View File
@@ -112,6 +112,7 @@ mod disparity_index;
mod distance_ssd;
mod doji;
mod doji_star;
mod dollar_bars;
mod donchian;
mod donchian_stop;
mod double_bollinger;
@@ -204,6 +205,7 @@ mod hurst_channel;
mod hurst_exponent;
mod ichimoku;
mod identical_three_crows;
mod imbalance_bars;
mod in_neck;
mod inertia;
mod information_ratio;
@@ -331,6 +333,7 @@ mod qstick;
mod quartile_bands;
mod quoted_spread;
mod r_squared;
mod range_bars;
mod realized_spread;
mod realized_volatility;
mod recovery_factor;
@@ -358,6 +361,7 @@ mod rolling_quantile;
mod roofing_filter;
mod rsi;
mod rsx;
mod run_bars;
mod rvi;
mod rvi_volatility;
mod rwi;
@@ -431,11 +435,13 @@ mod term_structure_basis;
mod three_drives;
mod three_inside;
mod three_line_break;
mod three_line_break_bars;
mod three_line_strike;
mod three_outside;
mod three_soldiers_or_crows;
mod three_stars_in_south;
mod thrusting;
mod tick_bars;
mod tick_index;
mod tii;
mod time_based_stop;
@@ -485,6 +491,7 @@ mod volatility_cone;
mod volatility_of_volatility;
mod volatility_ratio;
mod volty_stop;
mod volume_bars;
mod volume_by_time_profile;
mod volume_oscillator;
mod volume_profile;
@@ -619,6 +626,7 @@ pub use disparity_index::DisparityIndex;
pub use distance_ssd::DistanceSsd;
pub use doji::Doji;
pub use doji_star::DojiStar;
pub use dollar_bars::{DollarBar, DollarBars};
pub use donchian::{Donchian, DonchianOutput};
pub use donchian_stop::{DonchianStop, DonchianStopOutput};
pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput};
@@ -711,6 +719,7 @@ pub use hurst_channel::{HurstChannel, HurstChannelOutput};
pub use hurst_exponent::HurstExponent;
pub use ichimoku::{Ichimoku, IchimokuOutput};
pub use identical_three_crows::IdenticalThreeCrows;
pub use imbalance_bars::{ImbalanceBar, ImbalanceBars};
pub use in_neck::InNeck;
pub use inertia::Inertia;
pub use information_ratio::InformationRatio;
@@ -838,6 +847,7 @@ pub use qstick::Qstick;
pub use quartile_bands::{QuartileBands, QuartileBandsOutput};
pub use quoted_spread::QuotedSpread;
pub use r_squared::RSquared;
pub use range_bars::{RangeBar, RangeBars};
pub use realized_spread::RealizedSpread;
pub use realized_volatility::RealizedVolatility;
pub use recovery_factor::RecoveryFactor;
@@ -865,6 +875,7 @@ pub use rolling_quantile::RollingQuantile;
pub use roofing_filter::RoofingFilter;
pub use rsi::Rsi;
pub use rsx::Rsx;
pub use run_bars::{RunBar, RunBars};
pub use rvi::Rvi;
pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
@@ -938,11 +949,13 @@ pub use term_structure_basis::TermStructureBasis;
pub use three_drives::ThreeDrives;
pub use three_inside::ThreeInside;
pub use three_line_break::ThreeLineBreak;
pub use three_line_break_bars::{LineBreakBar, ThreeLineBreakBars};
pub use three_line_strike::ThreeLineStrike;
pub use three_outside::ThreeOutside;
pub use three_soldiers_or_crows::ThreeSoldiersOrCrows;
pub use three_stars_in_south::ThreeStarsInSouth;
pub use thrusting::Thrusting;
pub use tick_bars::{TickBar, TickBars};
pub use tick_index::TickIndex;
pub use tii::Tii;
pub use time_based_stop::TimeBasedStop;
@@ -992,6 +1005,7 @@ pub use volatility_cone::{VolatilityCone, VolatilityConeOutput};
pub use volatility_of_volatility::VolatilityOfVolatility;
pub use volatility_ratio::VolatilityRatio;
pub use volty_stop::VoltyStop;
pub use volume_bars::{VolumeBar, VolumeBars};
pub use volume_by_time_profile::{VolumeByTimeProfile, VolumeByTimeProfileOutput};
pub use volume_oscillator::VolumeOscillator;
pub use volume_profile::{VolumeProfile, VolumeProfileOutput};
@@ -1573,7 +1587,18 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
),
(
"Alt-Chart Bars",
&["RenkoBars", "KagiBars", "PointAndFigureBars"],
&[
"RenkoBars",
"KagiBars",
"PointAndFigureBars",
"RangeBars",
"TickBars",
"VolumeBars",
"DollarBars",
"ImbalanceBars",
"RunBars",
"ThreeLineBreakBars",
],
),
(
"Market Breadth",
@@ -1681,6 +1706,6 @@ mod family_tests {
// the actual indicator count is the early-warning signal that an
// indicator was added without being assigned a family.
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
assert_eq!(total, 507, "FAMILIES total drifted from indicator count");
assert_eq!(total, 514, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,227 @@
//! Range bar builder — fixed price-range bars with no reversal penalty.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::BarBuilder;
/// One completed range bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RangeBar {
/// Price at the bar's origin edge.
pub open: f64,
/// Price at the bar's far edge (`open ± range`).
pub close: f64,
/// `+1` for an up bar, `-1` for a down bar.
pub direction: i8,
}
/// Range bar builder using a fixed price increment on close prices.
///
/// A range bar completes every time price travels a fixed `range` from the current
/// anchor, in *either* direction. This is the key difference from
/// [`RenkoBars`](crate::RenkoBars): Renko imposes a `2 * box_size` penalty to
/// reverse direction, so it filters out small oscillations; range bars have **no
/// reversal penalty** — a move of exactly `range` against the trend prints a bar
/// immediately. Range bars therefore track every leg of price movement, while Renko
/// smooths them.
///
/// Construction rules:
///
/// - The first candle seeds the anchor and prints no bar.
/// - Each subsequent candle prints one bar for every `range` of close movement away
/// from the anchor; a candle that gaps several ranges prints them all in one
/// [`BarBuilder::update`] call.
/// - Bars are aligned to the `range` grid relative to the seed price.
///
/// # Example
///
/// ```
/// use wickra_core::{BarBuilder, Candle, RangeBars};
///
/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap();
/// let mut bars = RangeBars::new(1.0).unwrap();
/// assert!(bars.update(flat(10.0)).is_empty()); // seed
/// let up = bars.update(flat(12.0)); // +2 ranges
/// assert_eq!(up.len(), 2);
/// let down = bars.update(flat(11.0)); // -1 range, no penalty
/// assert_eq!(down.len(), 1);
/// ```
#[derive(Debug, Clone)]
pub struct RangeBars {
range: f64,
anchor: Option<f64>,
}
impl RangeBars {
/// Construct a range-bar builder with the given price increment.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `range` is not finite and positive.
pub fn new(range: f64) -> Result<Self> {
if !range.is_finite() || range <= 0.0 {
return Err(Error::InvalidPeriod {
message: "range must be finite and positive",
});
}
Ok(Self {
range,
anchor: None,
})
}
/// Configured price range.
pub const fn range(&self) -> f64 {
self.range
}
/// Current anchor level (the close of the last completed bar, or the seed
/// price before any bar has formed).
pub const fn anchor(&self) -> Option<f64> {
self.anchor
}
}
impl BarBuilder for RangeBars {
type Bar = RangeBar;
fn update(&mut self, candle: Candle) -> Vec<RangeBar> {
let close = candle.close;
let Some(mut anchor) = self.anchor else {
self.anchor = Some(close);
return Vec::new();
};
let range = self.range;
let mut bars = Vec::new();
while close >= anchor + range {
bars.push(RangeBar {
open: anchor,
close: anchor + range,
direction: 1,
});
anchor += range;
}
while close <= anchor - range {
bars.push(RangeBar {
open: anchor,
close: anchor - range,
direction: -1,
});
anchor -= range;
}
self.anchor = Some(anchor);
bars
}
fn reset(&mut self) {
self.anchor = None;
}
fn name(&self) -> &'static str {
"RangeBars"
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn flat(price: f64) -> Candle {
Candle::new(price, price, price, price, 1.0, 0).unwrap()
}
#[test]
fn rejects_invalid_range() {
assert!(matches!(
RangeBars::new(0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
RangeBars::new(-1.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
RangeBars::new(f64::NAN),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let bars = RangeBars::new(2.5).unwrap();
assert_eq!(bars.name(), "RangeBars");
assert_relative_eq!(bars.range(), 2.5, epsilon = 1e-12);
assert_eq!(bars.anchor(), None);
}
#[test]
fn first_candle_seeds_without_bar() {
let mut bars = RangeBars::new(1.0).unwrap();
assert!(bars.update(flat(10.0)).is_empty());
assert_eq!(bars.anchor(), Some(10.0));
}
#[test]
fn up_move_prints_aligned_bars() {
let mut bars = RangeBars::new(1.0).unwrap();
bars.update(flat(10.0));
let up = bars.update(flat(13.0));
assert_eq!(up.len(), 3);
assert_relative_eq!(up[0].open, 10.0, epsilon = 1e-12);
assert_relative_eq!(up[2].close, 13.0, epsilon = 1e-12);
assert!(up.iter().all(|b| b.direction == 1));
assert_eq!(bars.anchor(), Some(13.0));
}
#[test]
fn down_move_prints_aligned_bars() {
let mut bars = RangeBars::new(1.0).unwrap();
bars.update(flat(10.0));
let down = bars.update(flat(7.0));
assert_eq!(down.len(), 3);
assert!(down.iter().all(|b| b.direction == -1));
assert_relative_eq!(down[2].close, 7.0, epsilon = 1e-12);
}
#[test]
fn reversal_needs_only_one_range() {
// Unlike Renko, a single-range move against the trend prints immediately.
let mut bars = RangeBars::new(1.0).unwrap();
bars.update(flat(10.0));
bars.update(flat(12.0)); // anchor 12, up
let down = bars.update(flat(11.0)); // drop of exactly one range
assert_eq!(down.len(), 1);
assert_eq!(down[0].direction, -1);
assert_relative_eq!(down[0].close, 11.0, epsilon = 1e-12);
assert_eq!(bars.anchor(), Some(11.0));
}
#[test]
fn small_move_prints_nothing() {
let mut bars = RangeBars::new(1.0).unwrap();
bars.update(flat(10.0));
assert!(bars.update(flat(10.5)).is_empty());
assert_eq!(bars.anchor(), Some(10.0));
}
#[test]
fn reset_clears_state() {
let mut bars = RangeBars::new(1.0).unwrap();
bars.update(flat(10.0));
bars.update(flat(13.0));
bars.reset();
assert_eq!(bars.anchor(), None);
assert!(bars.update(flat(50.0)).is_empty());
assert_eq!(bars.anchor(), Some(50.0));
}
#[test]
fn batch_concatenates_completed_bars() {
let mut bars = RangeBars::new(1.0).unwrap();
let candles = [flat(10.0), flat(12.0), flat(13.0)];
let out = bars.batch(&candles);
assert_eq!(out.len(), 3);
assert!(out.iter().all(|b| b.direction == 1));
}
}
@@ -0,0 +1,257 @@
//! Run bar builder (simplified López de Prado) — sample on runs of same-signed ticks.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::BarBuilder;
/// One completed run bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RunBar {
/// Open of the first candle in the bar.
pub open: f64,
/// Highest high across the bar.
pub high: f64,
/// Lowest low across the bar.
pub low: f64,
/// Close of the candle that closed the bar.
pub close: f64,
/// Length of the run that closed the bar (`== run_length`).
pub length: usize,
/// `+1` if a buy run closed the bar, `-1` if a sell run.
pub direction: i8,
}
/// Run bar builder — a **simplified** form of López de Prado's run bars.
///
/// A *run* is an uninterrupted sequence of same-signed ticks: a streak of up-ticks
/// (a buy run) or down-ticks (a sell run), with unchanged closes extending the
/// current run. This builder counts the current run's length and closes a bar when
/// it reaches `run_length`; a tick in the opposite direction restarts the run from
/// one. Where [`ImbalanceBars`](crate::ImbalanceBars) sample on the *net* signed
/// imbalance (which oscillating flow can cancel back to zero), run bars sample on
/// *persistence*: they fire only when the market pushes the same way without
/// interruption, making them a cleaner sequential-trend detector.
///
/// **Simplification.** The full method estimates a *dynamic* expected run length
/// from an EWMA and can weight runs by volume or traded value. This builder uses a
/// **fixed** run-length threshold on unweighted ticks. See López de Prado (2018),
/// ch. 2, for the adaptive estimator and weighted variants.
///
/// At most one bar closes per candle, so [`BarBuilder::update`] returns either an
/// empty vector or a single [`RunBar`].
///
/// # Example
///
/// ```
/// use wickra_core::{BarBuilder, Candle, RunBars};
///
/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap();
/// let mut bars = RunBars::new(3).unwrap();
/// bars.update(flat(10.0)); // seed
/// bars.update(flat(11.0)); // run 1
/// bars.update(flat(12.0)); // run 2
/// let out = bars.update(flat(13.0)); // run 3 -> close
/// assert_eq!(out.len(), 1);
/// assert_eq!(out[0].direction, 1);
/// ```
#[derive(Debug, Clone)]
pub struct RunBars {
run_length: usize,
count: usize,
open: f64,
high: f64,
low: f64,
close: f64,
prev_close: Option<f64>,
run_sign: i8,
run_len: usize,
}
impl RunBars {
/// Construct a run-bar builder that closes a bar on a run of `run_length` ticks.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `run_length == 0`.
pub fn new(run_length: usize) -> Result<Self> {
if run_length == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
run_length,
count: 0,
open: 0.0,
high: 0.0,
low: 0.0,
close: 0.0,
prev_close: None,
run_sign: 0,
run_len: 0,
})
}
/// Configured run length that closes a bar.
pub const fn run_length(&self) -> usize {
self.run_length
}
/// Length of the in-progress run.
pub const fn run(&self) -> usize {
self.run_len
}
}
impl BarBuilder for RunBars {
type Bar = RunBar;
fn update(&mut self, candle: Candle) -> Vec<RunBar> {
if self.count == 0 {
self.open = candle.open;
self.high = candle.high;
self.low = candle.low;
} else {
self.high = self.high.max(candle.high);
self.low = self.low.min(candle.low);
}
self.close = candle.close;
self.count += 1;
if let Some(prev) = self.prev_close {
let directional = if candle.close > prev {
1
} else if candle.close < prev {
-1
} else {
0
};
if directional == 0 {
// A flat tick extends the current run (if one is under way).
if self.run_sign != 0 {
self.run_len += 1;
}
} else if directional == self.run_sign {
self.run_len += 1;
} else {
self.run_sign = directional;
self.run_len = 1;
}
}
self.prev_close = Some(candle.close);
if self.run_sign == 0 || self.run_len < self.run_length {
return Vec::new();
}
let bar = RunBar {
open: self.open,
high: self.high,
low: self.low,
close: self.close,
length: self.run_len,
direction: self.run_sign,
};
self.count = 0;
self.run_sign = 0;
self.run_len = 0;
vec![bar]
}
fn reset(&mut self) {
self.count = 0;
self.prev_close = None;
self.run_sign = 0;
self.run_len = 0;
}
fn name(&self) -> &'static str {
"RunBars"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn flat(price: f64) -> Candle {
Candle::new(price, price, price, price, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_run_length() {
assert!(matches!(RunBars::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let bars = RunBars::new(5).unwrap();
assert_eq!(bars.run_length(), 5);
assert_eq!(bars.run(), 0);
assert_eq!(bars.name(), "RunBars");
}
#[test]
fn buy_run_closes_up_bar() {
let mut bars = RunBars::new(3).unwrap();
bars.update(flat(10.0)); // seed
bars.update(flat(11.0)); // run 1
bars.update(flat(12.0)); // run 2
let out = bars.update(flat(13.0)); // run 3
assert_eq!(out.len(), 1);
assert_eq!(out[0].direction, 1);
assert_eq!(out[0].length, 3);
}
#[test]
fn sell_run_closes_down_bar() {
let mut bars = RunBars::new(3).unwrap();
bars.update(flat(10.0));
bars.update(flat(9.0)); // run 1
bars.update(flat(8.0)); // run 2
let out = bars.update(flat(7.0)); // run 3
assert_eq!(out.len(), 1);
assert_eq!(out[0].direction, -1);
}
#[test]
fn opposite_tick_restarts_run() {
let mut bars = RunBars::new(3).unwrap();
bars.update(flat(10.0));
bars.update(flat(11.0)); // up run 1
bars.update(flat(12.0)); // up run 2
bars.update(flat(11.0)); // down -> run restarts at 1
assert_eq!(bars.run(), 1);
}
#[test]
fn flat_tick_extends_run() {
let mut bars = RunBars::new(3).unwrap();
bars.update(flat(10.0));
bars.update(flat(11.0)); // run 1
bars.update(flat(11.0)); // flat -> run 2
let out = bars.update(flat(12.0)); // run 3
assert_eq!(out.len(), 1);
assert_eq!(out[0].direction, 1);
}
#[test]
fn reset_clears_state() {
let mut bars = RunBars::new(3).unwrap();
bars.update(flat(10.0));
bars.update(flat(11.0));
bars.reset();
assert_eq!(bars.run(), 0);
assert!(bars.update(flat(50.0)).is_empty());
}
#[test]
fn batch_concatenates_completed_bars() {
let mut bars = RunBars::new(2).unwrap();
let candles = [
flat(10.0),
flat(11.0), // run 1
flat(12.0), // run 2 -> close
flat(13.0), // run 1
flat(14.0), // run 2 -> close
];
let out = bars.batch(&candles);
assert_eq!(out.len(), 2);
assert!(out.iter().all(|b| b.direction == 1));
}
}
@@ -0,0 +1,305 @@
//! Three-Line-Break bar builder — line-break chart segments driven by close prices.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::BarBuilder;
/// One completed line-break line.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LineBreakBar {
/// Price where the line began (the previous line's far edge).
pub open: f64,
/// Price where the line ended (the new close that drew it).
pub close: f64,
/// `+1` for a rising line, `-1` for a falling line.
pub direction: i8,
}
/// Three-Line-Break bar builder using the classic close-based reversal rule.
///
/// A line-break chart draws a new line in the trend direction whenever the close
/// makes a new extreme, and only reverses when the close breaks the extreme of the
/// previous `lines` lines (three by default — hence "three-line break"). This filters
/// minor noise: a pullback that fails to exceed the last three lines is ignored
/// entirely, so the chart isolates meaningful reversals.
///
/// This is the **bar-builder** counterpart of the
/// [`ThreeLineBreak`](crate::ThreeLineBreak) indicator: the indicator reports the
/// current line *state* as a streaming value, whereas this builder emits each
/// completed line as a [`LineBreakBar`] so you can reconstruct the full line-break
/// chart. At most one line forms per candle, so [`BarBuilder::update`] returns either
/// an empty vector or a single bar.
///
/// Construction rules:
///
/// - The first candle seeds a reference close and prints nothing.
/// - The first subsequent move (up or down) draws the first line.
/// - In an up-trend a close above the last line's top extends it (a new up line); a
/// close below the lowest low of the last `lines` lines reverses to a down line.
/// The down-trend is symmetric.
///
/// # Example
///
/// ```
/// use wickra_core::{BarBuilder, Candle, ThreeLineBreakBars};
///
/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap();
/// let mut bars = ThreeLineBreakBars::new(3).unwrap();
/// bars.update(flat(10.0)); // seed
/// let first = bars.update(flat(11.0)); // first up line
/// assert_eq!(first.len(), 1);
/// assert_eq!(first[0].direction, 1);
/// ```
#[derive(Debug, Clone)]
pub struct ThreeLineBreakBars {
lines: usize,
seed: Option<f64>,
recent: VecDeque<LineBreakBar>,
}
impl ThreeLineBreakBars {
/// Construct a line-break builder that reverses on a break of the last `lines`
/// lines (3 for the classic three-line break).
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `lines == 0`.
pub fn new(lines: usize) -> Result<Self> {
if lines == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
lines,
seed: None,
recent: VecDeque::with_capacity(lines),
})
}
/// Configured number of lines a reversal must break.
pub const fn lines(&self) -> usize {
self.lines
}
/// Number of recent lines currently tracked for the reversal test.
pub fn tracked(&self) -> usize {
self.recent.len()
}
fn push_line(&mut self, bar: LineBreakBar) {
if self.recent.len() == self.lines {
self.recent.pop_front();
}
self.recent.push_back(bar);
}
fn lowest_low(&self) -> f64 {
self.recent
.iter()
.map(|bar| bar.open.min(bar.close))
.fold(f64::INFINITY, f64::min)
}
fn highest_high(&self) -> f64 {
self.recent
.iter()
.map(|bar| bar.open.max(bar.close))
.fold(f64::NEG_INFINITY, f64::max)
}
}
impl BarBuilder for ThreeLineBreakBars {
type Bar = LineBreakBar;
fn update(&mut self, candle: Candle) -> Vec<LineBreakBar> {
let close = candle.close;
let Some(last) = self.recent.back().copied() else {
// No line yet: seed, then draw the first line on the first move.
let Some(seed) = self.seed else {
self.seed = Some(close);
return Vec::new();
};
let bar = if close > seed {
LineBreakBar {
open: seed,
close,
direction: 1,
}
} else if close < seed {
LineBreakBar {
open: seed,
close,
direction: -1,
}
} else {
return Vec::new();
};
self.push_line(bar);
return vec![bar];
};
let new_bar = if last.direction > 0 {
if close > last.close {
Some(LineBreakBar {
open: last.close,
close,
direction: 1,
})
} else if close < self.lowest_low() {
Some(LineBreakBar {
open: last.close,
close,
direction: -1,
})
} else {
None
}
} else if close < last.close {
Some(LineBreakBar {
open: last.close,
close,
direction: -1,
})
} else if close > self.highest_high() {
Some(LineBreakBar {
open: last.close,
close,
direction: 1,
})
} else {
None
};
if let Some(bar) = new_bar {
self.push_line(bar);
vec![bar]
} else {
Vec::new()
}
}
fn reset(&mut self) {
self.seed = None;
self.recent.clear();
}
fn name(&self) -> &'static str {
"ThreeLineBreakBars"
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn flat(price: f64) -> Candle {
Candle::new(price, price, price, price, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_lines() {
assert!(matches!(ThreeLineBreakBars::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let bars = ThreeLineBreakBars::new(3).unwrap();
assert_eq!(bars.lines(), 3);
assert_eq!(bars.tracked(), 0);
assert_eq!(bars.name(), "ThreeLineBreakBars");
}
#[test]
fn seed_then_first_line() {
let mut bars = ThreeLineBreakBars::new(3).unwrap();
assert!(bars.update(flat(10.0)).is_empty()); // seed
let first = bars.update(flat(11.0));
assert_eq!(first.len(), 1);
assert_eq!(first[0].direction, 1);
assert_relative_eq!(first[0].open, 10.0, epsilon = 1e-12);
assert_relative_eq!(first[0].close, 11.0, epsilon = 1e-12);
}
#[test]
fn new_high_extends_up_line() {
let mut bars = ThreeLineBreakBars::new(3).unwrap();
bars.update(flat(10.0));
bars.update(flat(11.0)); // line 1 up
let cont = bars.update(flat(12.0)); // new high -> extend
assert_eq!(cont.len(), 1);
assert_eq!(cont[0].direction, 1);
assert_relative_eq!(cont[0].open, 11.0, epsilon = 1e-12);
}
#[test]
fn small_pullback_prints_nothing() {
let mut bars = ThreeLineBreakBars::new(3).unwrap();
bars.update(flat(10.0));
bars.update(flat(11.0)); // line 1
bars.update(flat(12.0)); // line 2
bars.update(flat(13.0)); // line 3, lows are 10/11/12
assert!(bars.update(flat(10.5)).is_empty()); // not > 13, not < 10
}
#[test]
fn reversal_breaks_three_lines() {
let mut bars = ThreeLineBreakBars::new(3).unwrap();
bars.update(flat(10.0));
bars.update(flat(11.0)); // line 1, low 10
bars.update(flat(12.0)); // line 2, low 11
bars.update(flat(13.0)); // line 3, low 12
let rev = bars.update(flat(9.0)); // 9 < lowest low 10 -> reverse
assert_eq!(rev.len(), 1);
assert_eq!(rev[0].direction, -1);
assert_relative_eq!(rev[0].open, 13.0, epsilon = 1e-12);
assert_relative_eq!(rev[0].close, 9.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut bars = ThreeLineBreakBars::new(3).unwrap();
bars.update(flat(10.0));
bars.update(flat(11.0));
bars.reset();
assert_eq!(bars.tracked(), 0);
assert!(bars.update(flat(50.0)).is_empty()); // re-seeds
}
#[test]
fn flat_first_move_prints_nothing() {
let mut bars = ThreeLineBreakBars::new(3).unwrap();
assert!(bars.update(flat(10.0)).is_empty()); // seed
assert!(bars.update(flat(10.0)).is_empty()); // equal to seed -> no line
}
#[test]
fn first_line_down_then_down_trend_and_reversal() {
let mut bars = ThreeLineBreakBars::new(3).unwrap();
assert!(bars.update(flat(10.0)).is_empty()); // seed
let first = bars.update(flat(9.0)); // first line down
assert_eq!(first.len(), 1);
assert_eq!(first[0].direction, -1);
assert_relative_eq!(first[0].open, 10.0, epsilon = 1e-12);
assert_relative_eq!(first[0].close, 9.0, epsilon = 1e-12);
let cont = bars.update(flat(8.0)); // new low extends the down line
assert_eq!(cont.len(), 1);
assert_eq!(cont[0].direction, -1);
assert_relative_eq!(cont[0].open, 9.0, epsilon = 1e-12);
bars.update(flat(7.0)); // third down line; highs are 10/9/8
assert!(bars.update(flat(7.5)).is_empty()); // not < 7, not > highest high 10
let rev = bars.update(flat(11.0)); // > highest high 10 -> reverse up
assert_eq!(rev.len(), 1);
assert_eq!(rev[0].direction, 1);
assert_relative_eq!(rev[0].open, 7.0, epsilon = 1e-12);
}
#[test]
fn batch_concatenates_completed_lines() {
let mut bars = ThreeLineBreakBars::new(3).unwrap();
let candles = [flat(10.0), flat(11.0), flat(12.0), flat(13.0)];
let out = bars.batch(&candles);
// seed at 10, then three rising lines.
assert_eq!(out.len(), 3);
assert!(out.iter().all(|b| b.direction == 1));
}
}
@@ -0,0 +1,209 @@
//! Tick bar builder — aggregate a fixed number of candles into one OHLCV bar.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::BarBuilder;
/// One completed tick bar (an OHLCV aggregate of `ticks` input candles).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TickBar {
/// Open of the first candle in the group.
pub open: f64,
/// Highest high across the group.
pub high: f64,
/// Lowest low across the group.
pub low: f64,
/// Close of the last candle in the group.
pub close: f64,
/// Summed volume across the group.
pub volume: f64,
}
/// Tick bar builder — emits one OHLCV bar for every `ticks` input candles.
///
/// Classic time bars (1-minute, 1-hour) sample the market on a clock; tick bars
/// sample it on *activity* by grouping a fixed number of trades — here modelled as a
/// fixed number of input candles. In fast markets a tick bar closes quickly; in
/// quiet markets it takes longer, so each bar carries roughly equal information
/// content. This is the simplest of the information-driven bar types; the
/// [`VolumeBars`](crate::VolumeBars) and [`DollarBars`](crate::DollarBars) builders
/// extend the idea to equal traded volume and equal traded value respectively.
///
/// The open is the first candle's open, the high and low are the extremes across the
/// group, the close is the last candle's close, and the volume is the group sum.
/// Exactly one bar completes every `ticks` candles, so [`BarBuilder::update`]
/// returns either an empty vector or a single [`TickBar`].
///
/// # Example
///
/// ```
/// use wickra_core::{BarBuilder, Candle, TickBars};
///
/// let c = |o, h, l, cl, v| Candle::new(o, h, l, cl, v, 0).unwrap();
/// let mut bars = TickBars::new(3).unwrap();
/// assert!(bars.update(c(10.0, 11.0, 9.0, 10.5, 100.0)).is_empty());
/// assert!(bars.update(c(10.5, 12.0, 10.0, 11.0, 150.0)).is_empty());
/// let out = bars.update(c(11.0, 11.5, 10.8, 11.2, 120.0));
/// assert_eq!(out.len(), 1);
/// assert_eq!(out[0].volume, 370.0);
/// ```
#[derive(Debug, Clone)]
pub struct TickBars {
ticks: usize,
count: usize,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
}
impl TickBars {
/// Construct a tick-bar builder that groups `ticks` candles per bar.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `ticks == 0`.
pub fn new(ticks: usize) -> Result<Self> {
if ticks == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
ticks,
count: 0,
open: 0.0,
high: 0.0,
low: 0.0,
close: 0.0,
volume: 0.0,
})
}
/// Configured number of candles per bar.
pub const fn ticks(&self) -> usize {
self.ticks
}
/// Number of candles accumulated into the in-progress bar.
pub const fn count(&self) -> usize {
self.count
}
}
impl BarBuilder for TickBars {
type Bar = TickBar;
fn update(&mut self, candle: Candle) -> Vec<TickBar> {
if self.count == 0 {
self.open = candle.open;
self.high = candle.high;
self.low = candle.low;
self.volume = 0.0;
} else {
self.high = self.high.max(candle.high);
self.low = self.low.min(candle.low);
}
self.close = candle.close;
self.volume += candle.volume;
self.count += 1;
if self.count < self.ticks {
return Vec::new();
}
self.count = 0;
vec![TickBar {
open: self.open,
high: self.high,
low: self.low,
close: self.close,
volume: self.volume,
}]
}
fn reset(&mut self) {
self.count = 0;
self.volume = 0.0;
}
fn name(&self) -> &'static str {
"TickBars"
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64) -> Candle {
Candle::new(open, high, low, close, volume, 0).unwrap()
}
#[test]
fn rejects_zero_ticks() {
assert!(matches!(TickBars::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let bars = TickBars::new(5).unwrap();
assert_eq!(bars.ticks(), 5);
assert_eq!(bars.count(), 0);
assert_eq!(bars.name(), "TickBars");
}
#[test]
fn emits_every_n_candles() {
let mut bars = TickBars::new(2).unwrap();
assert!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)).is_empty());
assert_eq!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)).len(), 1);
assert!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)).is_empty());
assert_eq!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)).len(), 1);
}
#[test]
fn aggregates_ohlcv() {
let mut bars = TickBars::new(3).unwrap();
bars.update(candle(10.0, 11.0, 9.0, 10.5, 100.0));
bars.update(candle(10.5, 12.0, 10.0, 11.0, 150.0));
let out = bars.update(candle(11.0, 11.5, 10.8, 11.2, 120.0));
assert_eq!(out.len(), 1);
assert_relative_eq!(out[0].open, 10.0, epsilon = 1e-12);
assert_relative_eq!(out[0].high, 12.0, epsilon = 1e-12);
assert_relative_eq!(out[0].low, 9.0, epsilon = 1e-12);
assert_relative_eq!(out[0].close, 11.2, epsilon = 1e-12);
assert_relative_eq!(out[0].volume, 370.0, epsilon = 1e-12);
}
#[test]
fn partial_group_emits_nothing() {
let mut bars = TickBars::new(4).unwrap();
bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0));
bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0));
assert_eq!(bars.count(), 2);
}
#[test]
fn reset_clears_state() {
let mut bars = TickBars::new(3).unwrap();
bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0));
bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0));
bars.reset();
assert_eq!(bars.count(), 0);
// After reset the next candle starts a fresh group.
assert!(bars.update(candle(20.0, 20.0, 20.0, 20.0, 5.0)).is_empty());
assert_eq!(bars.count(), 1);
}
#[test]
fn batch_concatenates_completed_bars() {
let mut bars = TickBars::new(2).unwrap();
let candles = [
candle(10.0, 10.0, 10.0, 10.0, 1.0),
candle(10.0, 10.0, 10.0, 10.0, 1.0),
candle(10.0, 10.0, 10.0, 10.0, 1.0),
candle(10.0, 10.0, 10.0, 10.0, 1.0),
];
let out = bars.batch(&candles);
assert_eq!(out.len(), 2);
}
}
@@ -0,0 +1,217 @@
//! Volume bar builder — close a bar each time accumulated volume reaches a threshold.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::BarBuilder;
/// One completed volume bar (an OHLCV aggregate spanning ~`volume_per_bar` of volume).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VolumeBar {
/// Open of the first candle in the bar.
pub open: f64,
/// Highest high across the bar.
pub high: f64,
/// Lowest low across the bar.
pub low: f64,
/// Close of the candle that closed the bar.
pub close: f64,
/// Accumulated volume in the bar (`>= volume_per_bar`; the crossing candle's
/// overshoot is kept in the bar that closes).
pub volume: f64,
}
/// Volume bar builder — emits a bar each time accumulated volume reaches
/// `volume_per_bar`.
///
/// Where [`TickBars`](crate::TickBars) sample on trade *count*, volume bars sample on
/// traded *quantity*: a bar closes once the candles fed into it have accumulated at
/// least `volume_per_bar` of volume. This gives each bar roughly equal participation,
/// which de-emphasises quiet periods and resolves bursts of heavy trading into more
/// bars. The companion [`DollarBars`](crate::DollarBars) builder uses traded *value*
/// (`price × volume`) instead, which is more robust to price-level drift over long
/// histories.
///
/// The bar is candle-granular: at most one bar closes per candle, and the candle
/// that crosses the threshold closes the bar with its overshoot included (the next
/// bar starts fresh). [`BarBuilder::update`] therefore returns either an empty vector
/// or a single [`VolumeBar`].
///
/// # Example
///
/// ```
/// use wickra_core::{BarBuilder, Candle, VolumeBars};
///
/// let c = |cl, v| Candle::new(cl, cl, cl, cl, v, 0).unwrap();
/// let mut bars = VolumeBars::new(100.0).unwrap();
/// assert!(bars.update(c(10.0, 60.0)).is_empty());
/// let out = bars.update(c(10.5, 60.0)); // 120 >= 100 -> close
/// assert_eq!(out.len(), 1);
/// assert_eq!(out[0].volume, 120.0);
/// ```
#[derive(Debug, Clone)]
pub struct VolumeBars {
volume_per_bar: f64,
count: usize,
open: f64,
high: f64,
low: f64,
close: f64,
accumulated: f64,
}
impl VolumeBars {
/// Construct a volume-bar builder with the given volume threshold.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `volume_per_bar` is not finite and positive.
pub fn new(volume_per_bar: f64) -> Result<Self> {
if !volume_per_bar.is_finite() || volume_per_bar <= 0.0 {
return Err(Error::InvalidPeriod {
message: "volume_per_bar must be finite and positive",
});
}
Ok(Self {
volume_per_bar,
count: 0,
open: 0.0,
high: 0.0,
low: 0.0,
close: 0.0,
accumulated: 0.0,
})
}
/// Configured volume threshold per bar.
pub const fn volume_per_bar(&self) -> f64 {
self.volume_per_bar
}
/// Volume accumulated into the in-progress bar.
pub const fn accumulated(&self) -> f64 {
self.accumulated
}
}
impl BarBuilder for VolumeBars {
type Bar = VolumeBar;
fn update(&mut self, candle: Candle) -> Vec<VolumeBar> {
if self.count == 0 {
self.open = candle.open;
self.high = candle.high;
self.low = candle.low;
} else {
self.high = self.high.max(candle.high);
self.low = self.low.min(candle.low);
}
self.close = candle.close;
self.accumulated += candle.volume;
self.count += 1;
if self.accumulated < self.volume_per_bar {
return Vec::new();
}
let bar = VolumeBar {
open: self.open,
high: self.high,
low: self.low,
close: self.close,
volume: self.accumulated,
};
self.count = 0;
self.accumulated = 0.0;
vec![bar]
}
fn reset(&mut self) {
self.count = 0;
self.accumulated = 0.0;
}
fn name(&self) -> &'static str {
"VolumeBars"
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64) -> Candle {
Candle::new(open, high, low, close, volume, 0).unwrap()
}
#[test]
fn rejects_invalid_threshold() {
assert!(matches!(
VolumeBars::new(0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
VolumeBars::new(-100.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
VolumeBars::new(f64::INFINITY),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let bars = VolumeBars::new(1000.0).unwrap();
assert_relative_eq!(bars.volume_per_bar(), 1000.0, epsilon = 1e-12);
assert_relative_eq!(bars.accumulated(), 0.0, epsilon = 1e-12);
assert_eq!(bars.name(), "VolumeBars");
}
#[test]
fn closes_when_threshold_reached() {
let mut bars = VolumeBars::new(100.0).unwrap();
assert!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 60.0)).is_empty());
let out = bars.update(candle(10.5, 10.5, 10.5, 10.5, 60.0));
assert_eq!(out.len(), 1);
assert_relative_eq!(out[0].volume, 120.0, epsilon = 1e-12);
}
#[test]
fn aggregates_ohlc() {
let mut bars = VolumeBars::new(100.0).unwrap();
bars.update(candle(10.0, 11.0, 9.0, 10.5, 50.0));
let out = bars.update(candle(10.5, 12.0, 10.0, 11.0, 60.0));
assert_relative_eq!(out[0].open, 10.0, epsilon = 1e-12);
assert_relative_eq!(out[0].high, 12.0, epsilon = 1e-12);
assert_relative_eq!(out[0].low, 9.0, epsilon = 1e-12);
assert_relative_eq!(out[0].close, 11.0, epsilon = 1e-12);
}
#[test]
fn below_threshold_emits_nothing() {
let mut bars = VolumeBars::new(100.0).unwrap();
bars.update(candle(10.0, 10.0, 10.0, 10.0, 30.0));
assert_relative_eq!(bars.accumulated(), 30.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut bars = VolumeBars::new(100.0).unwrap();
bars.update(candle(10.0, 10.0, 10.0, 10.0, 60.0));
bars.reset();
assert_relative_eq!(bars.accumulated(), 0.0, epsilon = 1e-12);
assert!(bars.update(candle(20.0, 20.0, 20.0, 20.0, 60.0)).is_empty());
}
#[test]
fn batch_concatenates_completed_bars() {
let mut bars = VolumeBars::new(100.0).unwrap();
let candles = [
candle(10.0, 10.0, 10.0, 10.0, 60.0),
candle(10.0, 10.0, 10.0, 10.0, 60.0),
candle(10.0, 10.0, 10.0, 10.0, 60.0),
candle(10.0, 10.0, 10.0, 10.0, 60.0),
];
let out = bars.batch(&candles);
assert_eq!(out.len(), 2);
}
}