feat: add Alt-Chart Bars (Renko, Kagi, Point & Figure) via a BarBuilder trait (#146)

Introduces a BarBuilder trait for price-driven chart constructors that emit a variable number of bars per candle (deliberately not Indicator). Adds Renko (box-size bricks, 2-box reversal), Kagi (reversal-amount segments) and Point & Figure (box-size X/O columns, N-box reversal) in a new Alt-Chart Bars family, with custom Python/Node/WASM bindings, a dedicated fuzz target, tests and docs. Indicator count 292 -> 295.
This commit is contained in:
kingchenc
2026-06-02 21:56:00 +02:00
committed by GitHub
parent f37eedd44e
commit d4b3f9dbd1
20 changed files with 1692 additions and 43 deletions
@@ -0,0 +1,283 @@
//! Kagi bar builder — reversal-amount line segments on close prices.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::BarBuilder;
/// One completed Kagi line segment (the vertical run between two reversals).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct KagiBar {
/// Price where the segment began (the previous reversal point).
pub start: f64,
/// Extreme price the segment reached before reversing.
pub end: f64,
/// `+1` for a rising segment, `-1` for a falling segment.
pub direction: i8,
}
/// Kagi bar builder using the fixed reversal-amount method on close prices.
///
/// A Kagi chart is one continuous line that extends in its current direction as
/// long as price makes new extremes, and turns when price retraces by at least
/// `reversal` from the latest extreme. This builder emits the **completed
/// segment** each time the line turns:
///
/// - The first candle seeds the start price; the first subsequent move (of any
/// size) sets the initial direction.
/// - While the trend holds, new extremes extend the current segment silently.
/// - A retracement of `>= reversal` closes the current segment (returned from
/// [`BarBuilder::update`]) and starts a new one in the opposite direction.
///
/// At most one segment completes per candle, so `update` returns either an empty
/// vector or a single [`KagiBar`].
///
/// # Example
///
/// ```
/// use wickra_core::{BarBuilder, Candle, KagiBars};
///
/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap();
/// let mut kagi = KagiBars::new(2.0).unwrap();
/// kagi.update(flat(10.0)); // seed
/// kagi.update(flat(15.0)); // rise to 15
/// let bars = kagi.update(flat(12.0)); // retrace >= 2 -> closes the up segment
/// assert_eq!(bars.len(), 1);
/// assert_eq!(bars[0].direction, 1);
/// ```
#[derive(Debug, Clone)]
pub struct KagiBars {
reversal: f64,
dir: i8,
extreme: Option<f64>,
segment_start: f64,
}
impl KagiBars {
/// Construct a Kagi builder with the given reversal amount.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `reversal` is not finite and positive.
pub fn new(reversal: f64) -> Result<Self> {
if !reversal.is_finite() || reversal <= 0.0 {
return Err(Error::InvalidPeriod {
message: "reversal must be finite and positive",
});
}
Ok(Self {
reversal,
dir: 0,
extreme: None,
segment_start: 0.0,
})
}
/// Configured reversal amount.
pub const fn reversal(&self) -> f64 {
self.reversal
}
/// Current extreme price (or the seed price before any move).
pub const fn extreme(&self) -> Option<f64> {
self.extreme
}
}
impl BarBuilder for KagiBars {
type Bar = KagiBar;
fn update(&mut self, candle: Candle) -> Vec<KagiBar> {
let close = candle.close;
let Some(mut ext) = self.extreme else {
self.extreme = Some(close);
self.segment_start = close;
return Vec::new();
};
let mut bars = Vec::new();
match self.dir {
0 => {
if close > ext {
self.dir = 1;
ext = close;
} else if close < ext {
self.dir = -1;
ext = close;
}
}
1 => {
if close > ext {
ext = close;
} else if close <= ext - self.reversal {
bars.push(KagiBar {
start: self.segment_start,
end: ext,
direction: 1,
});
self.segment_start = ext;
self.dir = -1;
ext = close;
}
}
_ => {
if close < ext {
ext = close;
} else if close >= ext + self.reversal {
bars.push(KagiBar {
start: self.segment_start,
end: ext,
direction: -1,
});
self.segment_start = ext;
self.dir = 1;
ext = close;
}
}
}
self.extreme = Some(ext);
bars
}
fn reset(&mut self) {
self.dir = 0;
self.extreme = None;
self.segment_start = 0.0;
}
fn name(&self) -> &'static str {
"KagiBars"
}
}
#[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_reversal() {
assert!(matches!(
KagiBars::new(0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
KagiBars::new(-2.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
KagiBars::new(f64::INFINITY),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let kagi = KagiBars::new(2.0).unwrap();
assert_eq!(kagi.name(), "KagiBars");
assert_relative_eq!(kagi.reversal(), 2.0, epsilon = 1e-12);
assert_eq!(kagi.extreme(), None);
}
#[test]
fn seeds_then_establishes_up_direction() {
let mut kagi = KagiBars::new(2.0).unwrap();
assert!(kagi.update(flat(10.0)).is_empty()); // seed
assert_eq!(kagi.extreme(), Some(10.0));
assert!(kagi.update(flat(11.0)).is_empty()); // first move sets dir up
assert_eq!(kagi.extreme(), Some(11.0));
}
#[test]
fn establishes_down_direction_from_seed() {
let mut kagi = KagiBars::new(2.0).unwrap();
kagi.update(flat(10.0));
assert!(kagi.update(flat(9.0)).is_empty()); // first move sets dir down
assert_eq!(kagi.extreme(), Some(9.0));
}
#[test]
fn extends_without_emitting() {
let mut kagi = KagiBars::new(2.0).unwrap();
kagi.update(flat(10.0));
kagi.update(flat(11.0));
assert!(kagi.update(flat(15.0)).is_empty()); // new high, extend
assert_eq!(kagi.extreme(), Some(15.0));
}
#[test]
fn reversal_closes_up_segment() {
let mut kagi = KagiBars::new(2.0).unwrap();
kagi.update(flat(10.0));
kagi.update(flat(11.0));
kagi.update(flat(15.0));
let bars = kagi.update(flat(12.0)); // retrace 3 >= 2
assert_eq!(bars.len(), 1);
assert_eq!(bars[0].direction, 1);
assert_relative_eq!(bars[0].start, 10.0, epsilon = 1e-12);
assert_relative_eq!(bars[0].end, 15.0, epsilon = 1e-12);
assert_eq!(kagi.extreme(), Some(12.0));
}
#[test]
fn reversal_closes_down_segment() {
let mut kagi = KagiBars::new(2.0).unwrap();
kagi.update(flat(10.0));
kagi.update(flat(11.0));
kagi.update(flat(15.0));
kagi.update(flat(12.0)); // now dir down, segment_start 15, extreme 12
let bars = kagi.update(flat(20.0)); // rise 8 >= 2 -> closes down segment
assert_eq!(bars.len(), 1);
assert_eq!(bars[0].direction, -1);
assert_relative_eq!(bars[0].start, 15.0, epsilon = 1e-12);
assert_relative_eq!(bars[0].end, 12.0, epsilon = 1e-12);
}
#[test]
fn small_pullback_does_not_reverse() {
let mut kagi = KagiBars::new(2.0).unwrap();
kagi.update(flat(10.0));
kagi.update(flat(11.0));
kagi.update(flat(15.0));
assert!(kagi.update(flat(14.0)).is_empty()); // retrace 1 < 2
assert_eq!(kagi.extreme(), Some(15.0));
}
#[test]
fn down_trend_small_bounce_does_not_reverse() {
let mut kagi = KagiBars::new(2.0).unwrap();
kagi.update(flat(10.0));
kagi.update(flat(9.0)); // dir down
kagi.update(flat(5.0)); // extreme 5
assert!(kagi.update(flat(6.0)).is_empty()); // bounce 1 < 2
assert_eq!(kagi.extreme(), Some(5.0));
}
#[test]
fn reset_clears_state() {
let mut kagi = KagiBars::new(2.0).unwrap();
kagi.update(flat(10.0));
kagi.update(flat(15.0));
kagi.reset();
assert_eq!(kagi.extreme(), None);
assert!(kagi.update(flat(99.0)).is_empty());
assert_eq!(kagi.extreme(), Some(99.0));
}
#[test]
fn batch_collects_completed_segments() {
let mut kagi = KagiBars::new(2.0).unwrap();
let candles = [
flat(10.0),
flat(15.0),
flat(12.0), // closes up segment
flat(20.0), // closes down segment
];
let bars = kagi.batch(&candles);
assert_eq!(bars.len(), 2);
assert_eq!(bars[0].direction, 1);
assert_eq!(bars[1].direction, -1);
}
}
+11 -1
View File
@@ -123,6 +123,7 @@ mod instantaneous_trendline;
mod inverse_fisher_transform;
mod inverted_hammer;
mod jma;
mod kagi_bars;
mod kama;
mod kelly_criterion;
mod keltner;
@@ -183,6 +184,7 @@ mod percentage_trailing_stop;
mod pgo;
mod piercing_dark_cloud;
mod pmo;
mod point_and_figure_bars;
mod ppo;
mod profit_factor;
mod psar;
@@ -192,6 +194,7 @@ mod r_squared;
mod realized_spread;
mod recovery_factor;
mod relative_strength_ab;
mod renko_bars;
mod renko_trailing_stop;
mod rickshaw_man;
mod rising_three_methods;
@@ -415,6 +418,7 @@ pub use instantaneous_trendline::InstantaneousTrendline;
pub use inverse_fisher_transform::InverseFisherTransform;
pub use inverted_hammer::InvertedHammer;
pub use jma::Jma;
pub use kagi_bars::{KagiBar, KagiBars};
pub use kama::Kama;
pub use kelly_criterion::KellyCriterion;
pub use keltner::{Keltner, KeltnerOutput};
@@ -475,6 +479,7 @@ pub use percentage_trailing_stop::PercentageTrailingStop;
pub use pgo::Pgo;
pub use piercing_dark_cloud::PiercingDarkCloud;
pub use pmo::Pmo;
pub use point_and_figure_bars::{PnfColumn, PointAndFigureBars};
pub use ppo::Ppo;
pub use profit_factor::ProfitFactor;
pub use psar::Psar;
@@ -484,6 +489,7 @@ pub use r_squared::RSquared;
pub use realized_spread::RealizedSpread;
pub use recovery_factor::RecoveryFactor;
pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
pub use renko_bars::{RenkoBars, RenkoBrick};
pub use renko_trailing_stop::RenkoTrailingStop;
pub use rickshaw_man::RickshawMan;
pub use rising_three_methods::RisingThreeMethods;
@@ -966,6 +972,10 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"Alpha",
],
),
(
"Alt-Chart Bars",
&["RenkoBars", "KagiBars", "PointAndFigureBars"],
),
];
#[cfg(test)]
@@ -994,6 +1004,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, 287, "FAMILIES total drifted from indicator count");
assert_eq!(total, 290, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,296 @@
//! Point-and-Figure bar builder — box-size columns with an N-box reversal.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::BarBuilder;
/// One completed Point-and-Figure column.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PnfColumn {
/// `+1` for a rising (X) column, `-1` for a falling (O) column.
pub direction: i8,
/// Upper box edge of the column.
pub high: f64,
/// Lower box edge of the column.
pub low: f64,
}
/// Point-and-Figure bar builder using the fixed box-size, N-box reversal method.
///
/// Price is quantised to a `box_size` grid (each close maps to the box that
/// contains it). An X column extends upward while price makes new box highs; an
/// O column extends downward while price makes new box lows. A reversal needs
/// price to move `reversal` boxes against the column, at which point the current
/// column is closed (returned from [`BarBuilder::update`]) and a new column
/// starts one box offset from the prior extreme.
///
/// - The first candle seeds the grid box and prints no column.
/// - The first one-box move sets the initial column direction.
/// - At most one column completes per candle, so `update` returns an empty
/// vector or a single [`PnfColumn`].
///
/// Closes are mapped to their containing box via `floor(close / box_size)` for
/// both directions, so the construction is fully deterministic.
///
/// # Example
///
/// ```
/// use wickra_core::{BarBuilder, Candle, PointAndFigureBars};
///
/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap();
/// let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
/// pnf.update(flat(10.0)); // seed
/// pnf.update(flat(15.0)); // X column up to 15
/// let cols = pnf.update(flat(12.0)); // 3-box reversal closes the X column
/// assert_eq!(cols.len(), 1);
/// assert_eq!(cols[0].direction, 1);
/// ```
#[derive(Debug, Clone)]
pub struct PointAndFigureBars {
box_size: f64,
reversal: usize,
dir: i8,
col_top: f64,
col_bottom: f64,
seeded: bool,
}
impl PointAndFigureBars {
/// Construct a Point-and-Figure builder with the given box size and reversal
/// (in boxes).
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `box_size` is not finite and positive,
/// and [`Error::PeriodZero`] if `reversal` is zero.
pub fn new(box_size: f64, reversal: usize) -> Result<Self> {
if !box_size.is_finite() || box_size <= 0.0 {
return Err(Error::InvalidPeriod {
message: "box_size must be finite and positive",
});
}
if reversal == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
box_size,
reversal,
dir: 0,
col_top: 0.0,
col_bottom: 0.0,
seeded: false,
})
}
/// Configured box size.
pub const fn box_size(&self) -> f64 {
self.box_size
}
/// Configured reversal, in boxes.
pub const fn reversal(&self) -> usize {
self.reversal
}
fn floor_box(&self, price: f64) -> f64 {
(price / self.box_size).floor() * self.box_size
}
}
impl BarBuilder for PointAndFigureBars {
type Bar = PnfColumn;
fn update(&mut self, candle: Candle) -> Vec<PnfColumn> {
let box_price = self.floor_box(candle.close);
if !self.seeded {
self.seeded = true;
self.col_top = box_price;
self.col_bottom = box_price;
return Vec::new();
}
let box_size = self.box_size;
let reversal = self.reversal as f64 * box_size;
let mut cols = Vec::new();
match self.dir {
0 => {
if box_price >= self.col_top + box_size {
self.dir = 1;
self.col_top = box_price;
} else if box_price <= self.col_bottom - box_size {
self.dir = -1;
self.col_bottom = box_price;
}
}
1 => {
if box_price > self.col_top {
self.col_top = box_price;
} else if box_price <= self.col_top - reversal {
cols.push(PnfColumn {
direction: 1,
high: self.col_top,
low: self.col_bottom,
});
self.dir = -1;
self.col_top -= box_size;
self.col_bottom = box_price;
}
}
_ => {
if box_price < self.col_bottom {
self.col_bottom = box_price;
} else if box_price >= self.col_bottom + reversal {
cols.push(PnfColumn {
direction: -1,
high: self.col_top,
low: self.col_bottom,
});
self.dir = 1;
self.col_bottom += box_size;
self.col_top = box_price;
}
}
}
cols
}
fn reset(&mut self) {
self.dir = 0;
self.col_top = 0.0;
self.col_bottom = 0.0;
self.seeded = false;
}
fn name(&self) -> &'static str {
"PointAndFigureBars"
}
}
#[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_box_size() {
assert!(matches!(
PointAndFigureBars::new(0.0, 3),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
PointAndFigureBars::new(f64::NAN, 3),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn rejects_zero_reversal() {
assert!(matches!(
PointAndFigureBars::new(1.0, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let pnf = PointAndFigureBars::new(0.5, 3).unwrap();
assert_eq!(pnf.name(), "PointAndFigureBars");
assert_relative_eq!(pnf.box_size(), 0.5, epsilon = 1e-12);
assert_eq!(pnf.reversal(), 3);
}
#[test]
fn first_candle_seeds_without_column() {
let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
assert!(pnf.update(flat(10.0)).is_empty());
}
#[test]
fn establishes_up_then_extends() {
let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
pnf.update(flat(10.0));
assert!(pnf.update(flat(13.0)).is_empty()); // start X column
assert!(pnf.update(flat(15.0)).is_empty()); // extend up, no completed column
}
#[test]
fn establishes_down_direction() {
let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
pnf.update(flat(10.0));
assert!(pnf.update(flat(7.0)).is_empty()); // start O column
}
#[test]
fn reversal_closes_x_column() {
let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
pnf.update(flat(10.0));
pnf.update(flat(13.0));
pnf.update(flat(15.0));
let cols = pnf.update(flat(12.0)); // 3-box drop from 15
assert_eq!(cols.len(), 1);
assert_eq!(cols[0].direction, 1);
assert_relative_eq!(cols[0].high, 15.0, epsilon = 1e-12);
assert_relative_eq!(cols[0].low, 10.0, epsilon = 1e-12);
}
#[test]
fn reversal_closes_o_column() {
let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
pnf.update(flat(10.0));
pnf.update(flat(13.0));
pnf.update(flat(15.0));
pnf.update(flat(12.0)); // now O column from 14 down
pnf.update(flat(10.0)); // extend O down to 10
let cols = pnf.update(flat(15.0)); // 3-box rise -> closes O column
assert_eq!(cols.len(), 1);
assert_eq!(cols[0].direction, -1);
assert_relative_eq!(cols[0].low, 10.0, epsilon = 1e-12);
}
#[test]
fn small_move_prints_nothing() {
let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
pnf.update(flat(10.0));
pnf.update(flat(13.0));
pnf.update(flat(15.0));
assert!(pnf.update(flat(14.0)).is_empty()); // 1-box pullback < 3
}
#[test]
fn down_column_small_bounce_prints_nothing() {
let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
pnf.update(flat(10.0));
pnf.update(flat(7.0)); // O column
pnf.update(flat(5.0)); // extend down
assert!(pnf.update(flat(6.0)).is_empty()); // 1-box bounce < 3
}
#[test]
fn reset_clears_state() {
let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
pnf.update(flat(10.0));
pnf.update(flat(15.0));
pnf.reset();
assert!(pnf.update(flat(99.0)).is_empty()); // re-seeds
assert!(pnf.update(flat(100.0)).is_empty()); // first move after reseed
}
#[test]
fn batch_collects_completed_columns() {
let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
let candles = [
flat(10.0),
flat(15.0), // X column
flat(12.0), // reversal -> closes X
flat(9.0), // extend O
flat(15.0), // reversal -> closes O
];
let cols = pnf.batch(&candles);
assert_eq!(cols.len(), 2);
assert_eq!(cols[0].direction, 1);
assert_eq!(cols[1].direction, -1);
}
}
@@ -0,0 +1,267 @@
//! Renko bar builder — fixed box-size bricks with the classic reversal rule.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::BarBuilder;
/// One completed Renko brick.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RenkoBrick {
/// Price at the brick's origin edge.
pub open: f64,
/// Price at the brick's far edge (`open ± box_size`).
pub close: f64,
/// `+1` for an up brick, `-1` for a down brick.
pub direction: i8,
}
/// Renko bar builder using the fixed box-size method on close prices.
///
/// Construction follows the classic Renko rules:
///
/// - The first candle seeds the reference level and prints no brick.
/// - While the trend continues, every additional `box_size` of close movement
/// prints one more brick in the trend direction.
/// - A reversal requires `2 * box_size` against the trend (one box to unwind the
/// last brick's body, one to print the first opposite brick); thereafter each
/// further `box_size` prints another brick.
///
/// A single candle whose close gaps several boxes prints all the bricks it
/// completes in one [`BarBuilder::update`] call. Bricks are perfectly aligned to
/// the `box_size` grid relative to the seed price.
///
/// # Example
///
/// ```
/// use wickra_core::{BarBuilder, Candle, RenkoBars};
///
/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap();
/// let mut renko = RenkoBars::new(1.0).unwrap();
/// assert!(renko.update(flat(10.0)).is_empty()); // seed
/// let bricks = renko.update(flat(13.0)); // +3 boxes
/// assert_eq!(bricks.len(), 3);
/// assert!(bricks.iter().all(|b| b.direction == 1));
/// ```
#[derive(Debug, Clone)]
pub struct RenkoBars {
box_size: f64,
level: Option<f64>,
dir: i8,
}
impl RenkoBars {
/// Construct a Renko builder with the given brick size.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `box_size` is not finite and positive.
pub fn new(box_size: f64) -> Result<Self> {
if !box_size.is_finite() || box_size <= 0.0 {
return Err(Error::InvalidPeriod {
message: "box_size must be finite and positive",
});
}
Ok(Self {
box_size,
level: None,
dir: 0,
})
}
/// Configured brick size.
pub const fn box_size(&self) -> f64 {
self.box_size
}
/// Current reference level (the close of the last completed brick, or the
/// seed price before any brick has formed).
pub const fn level(&self) -> Option<f64> {
self.level
}
}
impl BarBuilder for RenkoBars {
type Bar = RenkoBrick;
fn update(&mut self, candle: Candle) -> Vec<RenkoBrick> {
let close = candle.close;
let Some(mut level) = self.level else {
self.level = Some(close);
return Vec::new();
};
let box_size = self.box_size;
let two = 2.0 * box_size;
let mut bricks = Vec::new();
loop {
if self.dir >= 0 && close >= level + box_size {
bricks.push(RenkoBrick {
open: level,
close: level + box_size,
direction: 1,
});
level += box_size;
self.dir = 1;
} else if self.dir <= 0 && close <= level - box_size {
bricks.push(RenkoBrick {
open: level,
close: level - box_size,
direction: -1,
});
level -= box_size;
self.dir = -1;
} else if self.dir > 0 && close <= level - two {
bricks.push(RenkoBrick {
open: level - box_size,
close: level - two,
direction: -1,
});
level -= two;
self.dir = -1;
} else if self.dir < 0 && close >= level + two {
bricks.push(RenkoBrick {
open: level + box_size,
close: level + two,
direction: 1,
});
level += two;
self.dir = 1;
} else {
break;
}
}
self.level = Some(level);
bricks
}
fn reset(&mut self) {
self.level = None;
self.dir = 0;
}
fn name(&self) -> &'static str {
"RenkoBars"
}
}
#[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_box_size() {
assert!(matches!(
RenkoBars::new(0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
RenkoBars::new(-1.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
RenkoBars::new(f64::NAN),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let renko = RenkoBars::new(2.5).unwrap();
assert_eq!(renko.name(), "RenkoBars");
assert_relative_eq!(renko.box_size(), 2.5, epsilon = 1e-12);
assert_eq!(renko.level(), None);
}
#[test]
fn first_candle_seeds_without_brick() {
let mut renko = RenkoBars::new(1.0).unwrap();
assert!(renko.update(flat(10.0)).is_empty());
assert_eq!(renko.level(), Some(10.0));
}
#[test]
fn up_trend_prints_aligned_bricks() {
let mut renko = RenkoBars::new(1.0).unwrap();
renko.update(flat(10.0));
let bricks = renko.update(flat(13.0));
assert_eq!(bricks.len(), 3);
assert_relative_eq!(bricks[0].open, 10.0, epsilon = 1e-12);
assert_relative_eq!(bricks[0].close, 11.0, epsilon = 1e-12);
assert_relative_eq!(bricks[2].close, 13.0, epsilon = 1e-12);
assert!(bricks.iter().all(|b| b.direction == 1));
assert_eq!(renko.level(), Some(13.0));
}
#[test]
fn down_trend_prints_aligned_bricks() {
let mut renko = RenkoBars::new(1.0).unwrap();
renko.update(flat(10.0));
let bricks = renko.update(flat(7.0));
assert_eq!(bricks.len(), 3);
assert!(bricks.iter().all(|b| b.direction == -1));
assert_relative_eq!(bricks[2].close, 7.0, epsilon = 1e-12);
assert_eq!(renko.level(), Some(7.0));
}
#[test]
fn reversal_down_needs_two_boxes() {
let mut renko = RenkoBars::new(1.0).unwrap();
renko.update(flat(10.0));
renko.update(flat(13.0)); // level 13, dir up
let bricks = renko.update(flat(10.0)); // drop of 3 -> reversal eats one box
assert_eq!(bricks.len(), 2);
assert!(bricks.iter().all(|b| b.direction == -1));
assert_relative_eq!(bricks[0].open, 12.0, epsilon = 1e-12);
assert_relative_eq!(bricks[0].close, 11.0, epsilon = 1e-12);
assert_relative_eq!(bricks[1].close, 10.0, epsilon = 1e-12);
assert_eq!(renko.level(), Some(10.0));
}
#[test]
fn reversal_up_needs_two_boxes() {
let mut renko = RenkoBars::new(1.0).unwrap();
renko.update(flat(10.0));
renko.update(flat(7.0)); // level 7, dir down
let bricks = renko.update(flat(10.0)); // rise of 3 -> reversal
assert_eq!(bricks.len(), 2);
assert!(bricks.iter().all(|b| b.direction == 1));
assert_relative_eq!(bricks[0].open, 8.0, epsilon = 1e-12);
assert_relative_eq!(bricks[0].close, 9.0, epsilon = 1e-12);
assert_relative_eq!(bricks[1].close, 10.0, epsilon = 1e-12);
}
#[test]
fn small_move_prints_nothing() {
let mut renko = RenkoBars::new(1.0).unwrap();
renko.update(flat(10.0));
renko.update(flat(13.0));
assert!(renko.update(flat(12.5)).is_empty()); // less than a reversal
assert_eq!(renko.level(), Some(13.0));
}
#[test]
fn reset_clears_state() {
let mut renko = RenkoBars::new(1.0).unwrap();
renko.update(flat(10.0));
renko.update(flat(13.0));
renko.reset();
assert_eq!(renko.level(), None);
// After reset the next candle seeds again.
assert!(renko.update(flat(50.0)).is_empty());
assert_eq!(renko.level(), Some(50.0));
}
#[test]
fn batch_concatenates_completed_bricks() {
let mut renko = RenkoBars::new(1.0).unwrap();
let candles = [flat(10.0), flat(12.0), flat(13.0)];
let bricks = renko.batch(&candles);
// seed at 10, then +2 then +1 => 3 up bricks total.
assert_eq!(bricks.len(), 3);
assert!(bricks.iter().all(|b| b.direction == 1));
}
}