feat: add Ichimoku & Charts deepening (B13, 5 indicators) (#207)
B13 of the family-deepening roadmap — five alternative-chart indicators (474 -> 479), all in the **Ichimoku & Charts** family.
- **Smoothed Heikin-Ashi** (`candle -> struct {open, high, low, close}`) — a Heikin-Ashi candle computed from EMA-smoothed OHLC.
- **Heikin-Ashi Oscillator** (`candle -> f64`) — the HA body (`ha_close - ha_open`), optionally EMA-smoothed, as a zero-line oscillator.
- **Three Line Break** (`candle -> f64`) — line-break ("kakushi") chart trend direction; reverses only when the close breaks the extreme of the last N lines. Distinct from the candlestick `ThreeLineStrike`.
- **Equivolume** (`candle -> struct {height, width}`) — a box whose height is the bar range and width is volume-relative.
- **CandleVolume** (`candle -> struct {body, width}`) — a candle whose body is close-minus-open and width is volume-relative.
All bindings hand-written (3 struct-output + 2 candle-input-with-open / non-period-ctor). Wiring complete across core, Python, Node, WASM, fuzz, tests, README + docs counter (479) and CHANGELOG. Verified: core 3915 + doc 432, clippy clean, node 554, python 913.
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
#![allow(clippy::doc_markdown)]
|
||||
//! CandleVolume — candlestick body with a volume-scaled width.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`CandleVolume`]: the signed candle body and its volume-relative width.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CandleVolumeOutput {
|
||||
/// Signed body `close − open` (positive = bullish candle).
|
||||
pub body: f64,
|
||||
/// Box width — volume relative to its `period` average (`1.0` = average).
|
||||
pub width: f64,
|
||||
}
|
||||
|
||||
/// CandleVolume — the candlestick analogue of [`Equivolume`](crate::Equivolume):
|
||||
/// each bar's **body** (`close − open`) paired with a **width** proportional to its
|
||||
/// volume relative to the recent average.
|
||||
///
|
||||
/// ```text
|
||||
/// body = close − open (signed; + bullish, − bearish)
|
||||
/// width = volume / SMA(volume, period) (1.0 = average volume)
|
||||
/// ```
|
||||
///
|
||||
/// Where Equivolume uses the high-low *range* for the box height, CandleVolume uses
|
||||
/// the candlestick *body*, preserving direction: a wide bullish body (long up
|
||||
/// candle on heavy volume) is strong demand, a wide bearish body strong supply, and
|
||||
/// a narrow body on heavy volume (wide but short) is churn. The signed body plus
|
||||
/// the normalised width capture both the move's direction and the participation
|
||||
/// behind it.
|
||||
///
|
||||
/// The first value lands after `period` inputs (to seed the volume average). Each
|
||||
/// `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, CandleVolume};
|
||||
///
|
||||
/// let mut indicator = CandleVolume::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0 + f64::from(i), 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CandleVolume {
|
||||
period: usize,
|
||||
vol_sma: Sma,
|
||||
last: Option<CandleVolumeOutput>,
|
||||
}
|
||||
|
||||
impl CandleVolume {
|
||||
/// Construct a CandleVolume with the given volume-averaging `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
vol_sma: Sma::new(period)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured volume-averaging period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<CandleVolumeOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CandleVolume {
|
||||
type Input = Candle;
|
||||
type Output = CandleVolumeOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<CandleVolumeOutput> {
|
||||
let avg_vol = self.vol_sma.update(candle.volume)?;
|
||||
let body = candle.close - candle.open;
|
||||
let width = if avg_vol > 0.0 {
|
||||
candle.volume / avg_vol
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let out = CandleVolumeOutput { body, width };
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.vol_sma.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CandleVolume"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(open: f64, close: f64, volume: f64) -> Candle {
|
||||
let high = open.max(close) + 1.0;
|
||||
let low = open.min(close) - 1.0;
|
||||
Candle::new_unchecked(open, high, low, close, volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(CandleVolume::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let cv = CandleVolume::new(14).unwrap();
|
||||
assert_eq!(cv.period(), 14);
|
||||
assert_eq!(cv.warmup_period(), 14);
|
||||
assert_eq!(cv.name(), "CandleVolume");
|
||||
assert!(!cv.is_ready());
|
||||
assert_eq!(cv.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut cv = CandleVolume::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|_| c(100.0, 101.0, 1_000.0)).collect();
|
||||
let out = cv.batch(&candles);
|
||||
for v in out.iter().take(2) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_body_positive() {
|
||||
let mut cv = CandleVolume::new(2).unwrap();
|
||||
let out = cv
|
||||
.batch(&[c(100.0, 103.0, 1_000.0), c(100.0, 103.0, 1_000.0)])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(out.body, 3.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_body_negative() {
|
||||
let mut cv = CandleVolume::new(2).unwrap();
|
||||
let out = cv
|
||||
.batch(&[c(103.0, 100.0, 1_000.0), c(103.0, 100.0, 1_000.0)])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(out.body, -3.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavy_bar_is_wide() {
|
||||
let mut cv = CandleVolume::new(3).unwrap();
|
||||
let candles = [
|
||||
c(100.0, 101.0, 1_000.0),
|
||||
c(100.0, 101.0, 1_000.0),
|
||||
c(100.0, 101.0, 4_000.0),
|
||||
];
|
||||
let out = cv.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(out.width > 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut cv = CandleVolume::new(3).unwrap();
|
||||
cv.batch(&[c(100.0, 101.0, 1_000.0); 6]);
|
||||
assert!(cv.is_ready());
|
||||
cv.reset();
|
||||
assert!(!cv.is_ready());
|
||||
assert_eq!(cv.value(), None);
|
||||
assert_eq!(cv.update(c(100.0, 101.0, 1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_gives_zero_width() {
|
||||
let mut cv = CandleVolume::new(2).unwrap();
|
||||
let out = cv
|
||||
.batch(&[c(10.0, 11.0, 0.0), c(11.0, 12.0, 0.0), c(12.0, 13.0, 0.0)])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(out.width, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let b = 100.0 + (f64::from(i) * 0.25).sin() * 5.0;
|
||||
c(b, b + 0.5, 1_000.0 + f64::from(i))
|
||||
})
|
||||
.collect();
|
||||
let batch = CandleVolume::new(14).unwrap().batch(&candles);
|
||||
let mut b = CandleVolume::new(14).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Equivolume — the price box height and its volume-scaled width.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`Equivolume`]: the box's price height and its volume-relative width.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct EquivolumeOutput {
|
||||
/// Box height — the bar's price range `high − low`.
|
||||
pub height: f64,
|
||||
/// Box width — volume relative to its `period` average (`1.0` = average).
|
||||
pub width: f64,
|
||||
}
|
||||
|
||||
/// Equivolume — Richard Arms' charting style rendered as numbers: each bar is a
|
||||
/// "box" whose **height** is its price range and whose **width** is its volume
|
||||
/// relative to the recent average.
|
||||
///
|
||||
/// ```text
|
||||
/// height = high − low
|
||||
/// width = volume / SMA(volume, period) (1.0 = average volume)
|
||||
/// ```
|
||||
///
|
||||
/// Equivolume discards time and substitutes volume for the horizontal axis: a tall
|
||||
/// narrow box is an easy move (big range on light volume), while a short wide box
|
||||
/// is churn (small range on heavy volume) that often marks support/resistance.
|
||||
/// Reporting the two dimensions lets you reconstruct that shape programmatically:
|
||||
/// the height/width relationship is Arms' "ease of movement" read. The width is
|
||||
/// normalised by the volume SMA so it self-scales across instruments.
|
||||
///
|
||||
/// The first value lands after `period` inputs (to seed the volume average). Each
|
||||
/// `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Equivolume};
|
||||
///
|
||||
/// let mut indicator = Equivolume::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 2.0, base - 2.0, base, 1_000.0 + f64::from(i), 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Equivolume {
|
||||
period: usize,
|
||||
vol_sma: Sma,
|
||||
last: Option<EquivolumeOutput>,
|
||||
}
|
||||
|
||||
impl Equivolume {
|
||||
/// Construct an Equivolume with the given volume-averaging `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
vol_sma: Sma::new(period)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured volume-averaging period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<EquivolumeOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Equivolume {
|
||||
type Input = Candle;
|
||||
type Output = EquivolumeOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<EquivolumeOutput> {
|
||||
let avg_vol = self.vol_sma.update(candle.volume)?;
|
||||
let height = candle.high - candle.low;
|
||||
let width = if avg_vol > 0.0 {
|
||||
candle.volume / avg_vol
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let out = EquivolumeOutput { height, width };
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.vol_sma.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Equivolume"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(high: f64, low: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(low, high, low, f64::midpoint(high, low), volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Equivolume::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let e = Equivolume::new(14).unwrap();
|
||||
assert_eq!(e.period(), 14);
|
||||
assert_eq!(e.warmup_period(), 14);
|
||||
assert_eq!(e.name(), "Equivolume");
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut e = Equivolume::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|_| c(102.0, 98.0, 1_000.0)).collect();
|
||||
let out = e.batch(&candles);
|
||||
for v in out.iter().take(2) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn height_is_range() {
|
||||
let mut e = Equivolume::new(2).unwrap();
|
||||
let out = e
|
||||
.batch(&[c(105.0, 100.0, 1_000.0), c(105.0, 100.0, 1_000.0)])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(out.height, 5.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn average_volume_width_is_one() {
|
||||
let mut e = Equivolume::new(3).unwrap();
|
||||
let out = e
|
||||
.batch(&[c(102.0, 98.0, 1_000.0); 6])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(out.width, 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavy_bar_is_wide() {
|
||||
let mut e = Equivolume::new(3).unwrap();
|
||||
let candles = [
|
||||
c(102.0, 98.0, 1_000.0),
|
||||
c(102.0, 98.0, 1_000.0),
|
||||
c(102.0, 98.0, 4_000.0),
|
||||
];
|
||||
let out = e.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
out.width > 1.0,
|
||||
"a heavy bar should be wider than average, got {}",
|
||||
out.width
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut e = Equivolume::new(3).unwrap();
|
||||
e.batch(&[c(102.0, 98.0, 1_000.0); 6]);
|
||||
assert!(e.is_ready());
|
||||
e.reset();
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
assert_eq!(e.update(c(102.0, 98.0, 1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_gives_zero_width() {
|
||||
let mut e = Equivolume::new(2).unwrap();
|
||||
let out = e
|
||||
.batch(&[c(11.0, 9.0, 0.0), c(12.0, 10.0, 0.0), c(13.0, 11.0, 0.0)])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(out.width, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
c(
|
||||
110.0 + (f64::from(i) * 0.25).sin() * 5.0,
|
||||
90.0,
|
||||
1_000.0 + f64::from(i),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = Equivolume::new(14).unwrap().batch(&candles);
|
||||
let mut b = Equivolume::new(14).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Heikin-Ashi Oscillator — the (smoothed) Heikin-Ashi candle body as a zero-line oscillator.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::indicators::heikin_ashi::HeikinAshi;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Heikin-Ashi Oscillator — the body of the [`HeikinAshi`](crate::HeikinAshi)
|
||||
/// candle (`ha_close − ha_open`), optionally EMA-smoothed, as an oscillator around
|
||||
/// zero.
|
||||
///
|
||||
/// ```text
|
||||
/// body = ha_close − ha_open
|
||||
/// HAO = EMA(body, period)
|
||||
/// ```
|
||||
///
|
||||
/// A Heikin-Ashi candle is bullish when its close is above its open and bearish
|
||||
/// when below; the size of that body measures conviction. Plotting the body as an
|
||||
/// oscillator turns the visual HA colour/strength into a number: positive =
|
||||
/// bullish HA candles, negative = bearish, and the magnitude is trend strength.
|
||||
/// Smoothing the body with an EMA (`period`) damps single-bar noise so zero-line
|
||||
/// crosses mark cleaner trend changes. With `period == 1` the oscillator is the raw
|
||||
/// HA body.
|
||||
///
|
||||
/// The output is centred on zero (price units). The first value lands after
|
||||
/// `period` inputs (the HA transform itself needs only one). Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, HeikinAshiOscillator};
|
||||
///
|
||||
/// let mut indicator = HeikinAshiOscillator::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeikinAshiOscillator {
|
||||
period: usize,
|
||||
ha: HeikinAshi,
|
||||
ema: Ema,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl HeikinAshiOscillator {
|
||||
/// Construct a Heikin-Ashi Oscillator with the given EMA smoothing `period`
|
||||
/// (use `1` for the raw body).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
ha: HeikinAshi::new(),
|
||||
ema: Ema::new(period)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured smoothing period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HeikinAshiOscillator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let ha = self.ha.update(candle).expect("HeikinAshi emits every bar");
|
||||
let body = ha.close - ha.open;
|
||||
let v = self.ema.update(body)?;
|
||||
self.last = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ha.reset();
|
||||
self.ema.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HeikinAshiOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(open: f64, high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(open, high, low, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
HeikinAshiOscillator::new(0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let h = HeikinAshiOscillator::new(5).unwrap();
|
||||
assert_eq!(h.period(), 5);
|
||||
assert_eq!(h.warmup_period(), 5);
|
||||
assert_eq!(h.name(), "HeikinAshiOscillator");
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut h = HeikinAshiOscillator::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6)
|
||||
.map(|i| {
|
||||
let b = 100.0 + f64::from(i);
|
||||
c(b, b + 1.0, b - 1.0, b + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let out = h.batch(&candles);
|
||||
for v in out.iter().take(2) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_is_positive() {
|
||||
let mut h = HeikinAshiOscillator::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let b = 100.0 + 2.0 * f64::from(i);
|
||||
c(b, b + 1.0, b - 1.0, b + 1.5)
|
||||
})
|
||||
.collect();
|
||||
let last = h.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last > 0.0,
|
||||
"uptrend should give a positive HA body, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downtrend_is_negative() {
|
||||
let mut h = HeikinAshiOscillator::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let b = 200.0 - 2.0 * f64::from(i);
|
||||
c(b, b + 1.0, b - 1.0, b - 1.5)
|
||||
})
|
||||
.collect();
|
||||
let last = h.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last < 0.0,
|
||||
"downtrend should give a negative HA body, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_near_zero() {
|
||||
let mut h = HeikinAshiOscillator::new(3).unwrap();
|
||||
let last = h
|
||||
.batch(&[c(100.0, 100.5, 99.5, 100.0); 30])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = HeikinAshiOscillator::new(3).unwrap();
|
||||
h.batch(
|
||||
&(0..10)
|
||||
.map(|i| {
|
||||
let b = 100.0 + f64::from(i);
|
||||
c(b, b + 1.0, b - 1.0, b)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.value(), None);
|
||||
assert_eq!(h.update(c(100.0, 101.0, 99.0, 100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let b = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
|
||||
c(b, b + 1.0, b - 1.0, b + 0.3)
|
||||
})
|
||||
.collect();
|
||||
let batch = HeikinAshiOscillator::new(5).unwrap().batch(&candles);
|
||||
let mut b = HeikinAshiOscillator::new(5).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ mod butterfly;
|
||||
mod calendar_spread;
|
||||
mod calmar_ratio;
|
||||
mod camarilla_pivots;
|
||||
mod candle_volume;
|
||||
mod cci;
|
||||
mod center_of_gravity;
|
||||
mod central_pivot_range;
|
||||
@@ -128,6 +129,7 @@ mod elder_safezone;
|
||||
mod ema;
|
||||
mod empirical_mode_decomposition;
|
||||
mod engulfing;
|
||||
mod equivolume;
|
||||
mod even_better_sinewave;
|
||||
mod evening_doji_star;
|
||||
mod evwma;
|
||||
@@ -171,6 +173,7 @@ mod hanging_man;
|
||||
mod harami;
|
||||
mod head_and_shoulders;
|
||||
mod heikin_ashi;
|
||||
mod heikin_ashi_oscillator;
|
||||
mod high_low_index;
|
||||
mod high_low_range;
|
||||
mod high_wave;
|
||||
@@ -356,6 +359,7 @@ mod skewness;
|
||||
mod sma;
|
||||
mod smi;
|
||||
mod smma;
|
||||
mod smoothed_heikin_ashi;
|
||||
mod sortino_ratio;
|
||||
mod spearman_correlation;
|
||||
mod spinning_top;
|
||||
@@ -402,6 +406,7 @@ mod tema;
|
||||
mod term_structure_basis;
|
||||
mod three_drives;
|
||||
mod three_inside;
|
||||
mod three_line_break;
|
||||
mod three_line_strike;
|
||||
mod three_outside;
|
||||
mod three_soldiers_or_crows;
|
||||
@@ -541,6 +546,7 @@ pub use butterfly::Butterfly;
|
||||
pub use calendar_spread::CalendarSpread;
|
||||
pub use calmar_ratio::CalmarRatio;
|
||||
pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput};
|
||||
pub use candle_volume::{CandleVolume, CandleVolumeOutput};
|
||||
pub use cci::Cci;
|
||||
pub use center_of_gravity::CenterOfGravity;
|
||||
pub use central_pivot_range::{CentralPivotRange, CentralPivotRangeOutput};
|
||||
@@ -602,6 +608,7 @@ pub use elder_safezone::{ElderSafeZone, ElderSafeZoneOutput};
|
||||
pub use ema::Ema;
|
||||
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
|
||||
pub use engulfing::Engulfing;
|
||||
pub use equivolume::{Equivolume, EquivolumeOutput};
|
||||
pub use even_better_sinewave::EvenBetterSinewave;
|
||||
pub use evening_doji_star::EveningDojiStar;
|
||||
pub use evwma::Evwma;
|
||||
@@ -645,6 +652,7 @@ pub use hanging_man::HangingMan;
|
||||
pub use harami::Harami;
|
||||
pub use head_and_shoulders::HeadAndShoulders;
|
||||
pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
|
||||
pub use heikin_ashi_oscillator::HeikinAshiOscillator;
|
||||
pub use high_low_index::HighLowIndex;
|
||||
pub use high_low_range::HighLowRange;
|
||||
pub use high_wave::HighWave;
|
||||
@@ -830,6 +838,7 @@ pub use skewness::Skewness;
|
||||
pub use sma::Sma;
|
||||
pub use smi::Smi;
|
||||
pub use smma::Smma;
|
||||
pub use smoothed_heikin_ashi::{SmoothedHeikinAshi, SmoothedHeikinAshiOutput};
|
||||
pub use sortino_ratio::SortinoRatio;
|
||||
pub use spearman_correlation::SpearmanCorrelation;
|
||||
pub use spinning_top::SpinningTop;
|
||||
@@ -876,6 +885,7 @@ pub use tema::Tema;
|
||||
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_strike::ThreeLineStrike;
|
||||
pub use three_outside::ThreeOutside;
|
||||
pub use three_soldiers_or_crows::ThreeSoldiersOrCrows;
|
||||
@@ -1327,7 +1337,18 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"TdMovingAverage",
|
||||
],
|
||||
),
|
||||
("Ichimoku & Charts", &["Ichimoku", "HeikinAshi"]),
|
||||
(
|
||||
"Ichimoku & Charts",
|
||||
&[
|
||||
"Ichimoku",
|
||||
"HeikinAshi",
|
||||
"HeikinAshiOscillator",
|
||||
"ThreeLineBreak",
|
||||
"SmoothedHeikinAshi",
|
||||
"Equivolume",
|
||||
"CandleVolume",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Candlestick Patterns",
|
||||
&[
|
||||
@@ -1576,6 +1597,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, 474, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 479, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Smoothed Heikin-Ashi — Heikin-Ashi computed on EMA-smoothed OHLC.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// One smoothed Heikin-Ashi candle.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SmoothedHeikinAshiOutput {
|
||||
/// Smoothed Heikin-Ashi open.
|
||||
pub open: f64,
|
||||
/// Smoothed Heikin-Ashi high.
|
||||
pub high: f64,
|
||||
/// Smoothed Heikin-Ashi low.
|
||||
pub low: f64,
|
||||
/// Smoothed Heikin-Ashi close.
|
||||
pub close: f64,
|
||||
}
|
||||
|
||||
/// Smoothed Heikin-Ashi — the [`HeikinAshi`](crate::HeikinAshi) transform applied
|
||||
/// to **EMA-smoothed** OHLC, for an even cleaner trend view.
|
||||
///
|
||||
/// ```text
|
||||
/// eo, eh, el, ec = EMA(open|high|low|close, period)
|
||||
/// ha_close = (eo + eh + el + ec) / 4
|
||||
/// ha_open = (prev_ha_open + prev_ha_close) / 2 (seeded with (eo + ec)/2)
|
||||
/// ha_high = max(eh, ha_open, ha_close)
|
||||
/// ha_low = min(el, ha_open, ha_close)
|
||||
/// ```
|
||||
///
|
||||
/// Standard Heikin-Ashi already averages the OHLC; smoothing each input series
|
||||
/// with an EMA *before* the transform removes still more noise, producing long,
|
||||
/// uninterrupted runs of same-colour candles in a trend and crisp colour flips at
|
||||
/// turns. The trade-off is added lag proportional to `period`. The output uses the
|
||||
/// same OHLC field layout as a candle so it can be charted directly.
|
||||
///
|
||||
/// The first value lands once the EMAs are seeded (`period` inputs). Each `update`
|
||||
/// is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, SmoothedHeikinAshi};
|
||||
///
|
||||
/// let mut indicator = SmoothedHeikinAshi::new(10).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SmoothedHeikinAshi {
|
||||
period: usize,
|
||||
ema_open: Ema,
|
||||
ema_high: Ema,
|
||||
ema_low: Ema,
|
||||
ema_close: Ema,
|
||||
prev: Option<SmoothedHeikinAshiOutput>,
|
||||
last: Option<SmoothedHeikinAshiOutput>,
|
||||
}
|
||||
|
||||
impl SmoothedHeikinAshi {
|
||||
/// Construct a smoothed Heikin-Ashi with the given EMA `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
ema_open: Ema::new(period)?,
|
||||
ema_high: Ema::new(period)?,
|
||||
ema_low: Ema::new(period)?,
|
||||
ema_close: Ema::new(period)?,
|
||||
prev: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured smoothing period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<SmoothedHeikinAshiOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for SmoothedHeikinAshi {
|
||||
type Input = Candle;
|
||||
type Output = SmoothedHeikinAshiOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<SmoothedHeikinAshiOutput> {
|
||||
let eo = self.ema_open.update(candle.open);
|
||||
let eh = self.ema_high.update(candle.high);
|
||||
let el = self.ema_low.update(candle.low);
|
||||
let ec = self.ema_close.update(candle.close);
|
||||
let (Some(eo), Some(eh), Some(el), Some(ec)) = (eo, eh, el, ec) else {
|
||||
return None;
|
||||
};
|
||||
let ha_close = (eo + eh + el + ec) / 4.0;
|
||||
let ha_open = match self.prev {
|
||||
Some(p) => f64::midpoint(p.open, p.close),
|
||||
None => f64::midpoint(eo, ec),
|
||||
};
|
||||
let ha_high = eh.max(ha_open).max(ha_close);
|
||||
let ha_low = el.min(ha_open).min(ha_close);
|
||||
let out = SmoothedHeikinAshiOutput {
|
||||
open: ha_open,
|
||||
high: ha_high,
|
||||
low: ha_low,
|
||||
close: ha_close,
|
||||
};
|
||||
self.prev = Some(out);
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ema_open.reset();
|
||||
self.ema_high.reset();
|
||||
self.ema_low.reset();
|
||||
self.ema_close.reset();
|
||||
self.prev = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"SmoothedHeikinAshi"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(open: f64, high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(open, high, low, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(SmoothedHeikinAshi::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let s = SmoothedHeikinAshi::new(10).unwrap();
|
||||
assert_eq!(s.period(), 10);
|
||||
assert_eq!(s.warmup_period(), 10);
|
||||
assert_eq!(s.name(), "SmoothedHeikinAshi");
|
||||
assert!(!s.is_ready());
|
||||
assert_eq!(s.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut s = SmoothedHeikinAshi::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6)
|
||||
.map(|i| {
|
||||
let b = 100.0 + f64::from(i);
|
||||
c(b, b + 1.0, b - 1.0, b + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let out = s.batch(&candles);
|
||||
for v in out.iter().take(2) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_brackets_open_close() {
|
||||
let mut s = SmoothedHeikinAshi::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let b = 100.0 + f64::from(i);
|
||||
c(b, b + 2.0, b - 2.0, b + 0.5)
|
||||
})
|
||||
.collect();
|
||||
for o in s.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.high >= o.open && o.high >= o.close);
|
||||
assert!(o.low <= o.open && o.low <= o.close);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_close_above_open() {
|
||||
let mut s = SmoothedHeikinAshi::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let b = 100.0 + 2.0 * f64::from(i);
|
||||
c(b, b + 1.0, b - 1.0, b + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let o = s.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
o.close > o.open,
|
||||
"an uptrend should print a bullish smoothed HA candle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut s = SmoothedHeikinAshi::new(3).unwrap();
|
||||
s.batch(
|
||||
&(0..10)
|
||||
.map(|i| {
|
||||
let b = 100.0 + f64::from(i);
|
||||
c(b, b + 1.0, b - 1.0, b)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(s.is_ready());
|
||||
s.reset();
|
||||
assert!(!s.is_ready());
|
||||
assert_eq!(s.value(), None);
|
||||
assert_eq!(s.update(c(100.0, 101.0, 99.0, 100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let b = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
|
||||
c(b, b + 1.0, b - 1.0, b + 0.3)
|
||||
})
|
||||
.collect();
|
||||
let batch = SmoothedHeikinAshi::new(10).unwrap().batch(&candles);
|
||||
let mut b = SmoothedHeikinAshi::new(10).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Three Line Break — the close-driven line-break chart trend, as a direction.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Three Line Break — the trend direction of a line-break ("kakushi") chart, where
|
||||
/// a reversal requires the close to break the extreme of the last `lines` lines.
|
||||
///
|
||||
/// ```text
|
||||
/// continue the trend when close exceeds the prior line's end
|
||||
/// reverse the trend when close breaks beyond the extreme of the last `lines` lines
|
||||
/// output = current line direction: +1 (up), −1 (down)
|
||||
/// ```
|
||||
///
|
||||
/// A line-break chart ignores time and small moves entirely: it draws a new line
|
||||
/// only when the close makes a new extreme in the trend, and flips direction only
|
||||
/// when the close reverses past the high (or low) of the last `lines` lines —
|
||||
/// classically **three**. This filters out minor pullbacks, so the emitted
|
||||
/// direction stays in a trend until a genuinely significant reversal. Distinct from
|
||||
/// the candlestick [`ThreeLineStrike`](crate::ThreeLineStrike) (a fixed four-bar
|
||||
/// pattern); this is the line-break *chart type* reduced to its trend state. See
|
||||
/// also the alt-chart "Three-Line-Break Bars" builder.
|
||||
///
|
||||
/// The output is `+1.0` / `−1.0`. The first bar seeds the reference price; the
|
||||
/// direction is emitted once the first line is drawn (data-dependent;
|
||||
/// `warmup_period` returns the minimum `2`). Each `update` is O(`lines`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ThreeLineBreak};
|
||||
///
|
||||
/// let mut indicator = ThreeLineBreak::new(3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// let close = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(close, close, close, close, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert_eq!(last, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThreeLineBreak {
|
||||
lines: usize,
|
||||
line_values: Vec<f64>,
|
||||
dir: i8,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl ThreeLineBreak {
|
||||
/// Construct a Three Line Break requiring `lines` lines to reverse (classic 3).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `lines == 0`.
|
||||
pub fn new(lines: usize) -> Result<Self> {
|
||||
if lines == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
lines,
|
||||
line_values: Vec::with_capacity(lines + 1),
|
||||
dir: 0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured number of lines required to reverse.
|
||||
pub const fn lines(&self) -> usize {
|
||||
self.lines
|
||||
}
|
||||
|
||||
/// Current direction if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn push_line(&mut self, close: f64, dir: i8) {
|
||||
self.dir = dir;
|
||||
self.line_values.push(close);
|
||||
if self.line_values.len() > self.lines {
|
||||
self.line_values.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ThreeLineBreak {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let close = candle.close;
|
||||
let Some(&prior) = self.line_values.last() else {
|
||||
// Seed the reference price; no line yet.
|
||||
self.line_values.push(close);
|
||||
return None;
|
||||
};
|
||||
if self.dir >= 0 {
|
||||
if close > prior {
|
||||
self.push_line(close, 1);
|
||||
} else {
|
||||
let low = self
|
||||
.line_values
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
if close < low {
|
||||
self.push_line(close, -1);
|
||||
}
|
||||
}
|
||||
} else if close < prior {
|
||||
self.push_line(close, -1);
|
||||
} else {
|
||||
let high = self
|
||||
.line_values
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
if close > high {
|
||||
self.push_line(close, 1);
|
||||
}
|
||||
}
|
||||
if self.dir == 0 {
|
||||
return None;
|
||||
}
|
||||
let v = f64::from(self.dir);
|
||||
self.last = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.line_values.clear();
|
||||
self.dir = 0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ThreeLineBreak"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(close: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_lines() {
|
||||
assert!(matches!(ThreeLineBreak::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let t = ThreeLineBreak::new(3).unwrap();
|
||||
assert_eq!(t.lines(), 3);
|
||||
assert_eq!(t.warmup_period(), 2);
|
||||
assert_eq!(t.name(), "ThreeLineBreak");
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_is_plus_one() {
|
||||
let mut t = ThreeLineBreak::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..20).map(|i| c(100.0 + f64::from(i))).collect();
|
||||
let out = t.batch(&candles);
|
||||
assert!(out[0].is_none());
|
||||
assert_eq!(out[1], Some(1.0));
|
||||
assert_eq!(out.last().unwrap(), &Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downtrend_is_minus_one() {
|
||||
let mut t = ThreeLineBreak::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..20).map(|i| c(100.0 - f64::from(i))).collect();
|
||||
let last = t.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_pullback_does_not_reverse() {
|
||||
// Rise to build 3 up-lines, then a small dip that does not break the
|
||||
// 3-line low keeps the direction up.
|
||||
let mut t = ThreeLineBreak::new(3).unwrap();
|
||||
t.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]); // up-lines at 101,102,103
|
||||
// close 102.5 is below the prior line (103) but above the 3-line low (101) -> no reversal.
|
||||
assert_eq!(t.update(c(102.5)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_of_three_line_extreme_reverses() {
|
||||
let mut t = ThreeLineBreak::new(3).unwrap();
|
||||
t.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]); // lines 101,102,103, dir up
|
||||
// close 100.5 breaks below the 3-line low (101) -> reverse to down.
|
||||
assert_eq!(t.update(c(100.5)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = ThreeLineBreak::new(3).unwrap();
|
||||
t.batch(&(0..10).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.value(), None);
|
||||
assert_eq!(t.update(c(100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_close_emits_none_until_a_line_forms() {
|
||||
let mut t = ThreeLineBreak::new(3).unwrap();
|
||||
assert_eq!(t.update(c(100.0)), None);
|
||||
// An identical close draws no line, so the direction stays unset.
|
||||
assert_eq!(t.update(c(100.0)), None);
|
||||
assert!(!t.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
|
||||
.collect();
|
||||
let batch = ThreeLineBreak::new(3).unwrap().batch(&candles);
|
||||
let mut b = ThreeLineBreak::new(3).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user