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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user