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