feat: TA-Lib candlestick patterns — separating/kicking/ladder/mat-hold (part 6 of 9) (#138)

This commit is contained in:
kingchenc
2026-06-02 17:06:40 +02:00
committed by GitHub
parent e4ca9c3f8f
commit 04ae145126
19 changed files with 1274 additions and 39 deletions
@@ -0,0 +1,189 @@
//! Kicking candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Kicking — a 2-bar reversal of two opposite-coloured marubozu separated by a
/// gap. A shadowless candle is "kicked" the other way by a shadowless candle of
/// the opposite colour that gaps clear of it — a violent change of control. It is
/// trend-agnostic: the gap direction alone defines the signal.
///
/// ```text
/// marubozu = |close open| >= 0.95 * (high low) (no meaningful shadows)
/// bullish (+1.0): black marubozu, then a white marubozu gapping UP (low2 > high1)
/// bearish (1.0): white marubozu, then a black marubozu gapping DOWN (high2 < low1)
/// ```
///
/// Output is `+1.0` (bullish) or `1.0` (bearish) when the pattern completes and
/// `0.0` otherwise. The first bar always returns `0.0` because the two-bar window
/// is not yet filled. The marubozu threshold follows the geometric house style
/// rather than TA-Lib's rolling averages. Pattern-shape check only — no trend
/// filter is applied; combine with a trend indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector emits the uniform candlestick sign convention shared across the
/// pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no pattern — so it
/// drops straight into a machine-learning feature matrix where the two directions
/// occupy a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Kicking};
///
/// let mut indicator = Kicking::new();
/// indicator.update(Candle::new(12.0, 12.0, 10.0, 10.0, 1.0, 0).unwrap());
/// let out = indicator
/// .update(Candle::new(14.0, 16.0, 14.0, 16.0, 1.0, 1).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct Kicking {
prev: Option<Candle>,
has_emitted: bool,
}
impl Kicking {
/// Construct a new Kicking detector.
pub const fn new() -> Self {
Self {
prev: None,
has_emitted: false,
}
}
}
/// Whether `candle` is a marubozu (body fills at least 95 % of its range).
fn is_marubozu(candle: &Candle) -> bool {
let range = candle.high - candle.low;
range > 0.0 && (candle.close - candle.open).abs() >= 0.95 * range
}
impl Indicator for Kicking {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let prev = self.prev;
self.prev = Some(candle);
let Some(bar1) = prev else {
return Some(0.0);
};
if !is_marubozu(&bar1) || !is_marubozu(&candle) {
return Some(0.0);
}
// Bullish: black marubozu kicked up by a white marubozu gapping above it.
if bar1.close < bar1.open && candle.close > candle.open && candle.low > bar1.high {
return Some(1.0);
}
// Bearish: white marubozu kicked down by a black marubozu gapping below it.
if bar1.close > bar1.open && candle.close < candle.open && candle.high < bar1.low {
return Some(-1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.prev = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Kicking"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn accessors_and_metadata() {
let t = Kicking::new();
assert_eq!(t.name(), "Kicking");
assert_eq!(t.warmup_period(), 2);
assert!(!t.is_ready());
}
#[test]
fn bullish_kicking_is_plus_one() {
let mut t = Kicking::new();
assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.0, 0)), Some(0.0));
assert_eq!(t.update(c(14.0, 16.0, 14.0, 16.0, 1)), Some(1.0));
}
#[test]
fn bearish_kicking_is_minus_one() {
let mut t = Kicking::new();
assert_eq!(t.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(8.0, 8.0, 6.0, 6.0, 1)), Some(-1.0));
}
#[test]
fn not_marubozu_yields_zero() {
let mut t = Kicking::new();
// bar1 has long shadows -> not a marubozu.
t.update(c(12.0, 14.0, 8.0, 10.0, 0));
assert_eq!(t.update(c(14.0, 16.0, 14.0, 16.0, 1)), Some(0.0));
}
#[test]
fn no_gap_yields_zero() {
let mut t = Kicking::new();
t.update(c(12.0, 12.0, 10.0, 10.0, 0));
// White marubozu but it overlaps bar1 (no gap up).
assert_eq!(t.update(c(11.0, 13.0, 11.0, 13.0, 1)), Some(0.0));
}
#[test]
fn first_bar_returns_zero() {
let mut t = Kicking::new();
assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.0, 0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64 * 5.0;
if i % 2 == 0 {
c(base + 2.0, base + 2.0, base, base, i)
} else {
c(base + 3.0, base + 5.0, base + 3.0, base + 5.0, i)
}
})
.collect();
let mut a = Kicking::new();
let mut b = Kicking::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = Kicking::new();
t.update(c(12.0, 12.0, 10.0, 10.0, 0));
t.update(c(14.0, 16.0, 14.0, 16.0, 1));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.0, 0)), Some(0.0));
}
}
@@ -0,0 +1,197 @@
//! Kicking-by-Length candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Kicking-by-Length — the [`Kicking`](crate::Kicking) pattern with the signal
/// taken from the *longer* of the two marubozu rather than from the gap direction.
/// When the two shadowless candles differ in size, the bigger one is treated as
/// the dominant force.
///
/// ```text
/// marubozu = |close open| >= 0.95 * (high low)
/// setup: two opposite-coloured marubozu separated by a gap
/// black then white gapping UP, or white then black gapping DOWN
/// signal = colour of the LONGER marubozu (white -> +1.0, black -> 1.0)
/// ```
///
/// Output is `+1.0` or `1.0` when the kicking setup is present and `0.0`
/// otherwise. Note this can disagree with [`Kicking`](crate::Kicking): a black
/// marubozu kicked up by a *shorter* white marubozu reports `1.0` here. The first
/// bar always returns `0.0` because the two-bar window is not yet filled. The
/// marubozu threshold follows the geometric house style rather than TA-Lib's
/// rolling averages. Pattern-shape check only — no trend filter is applied;
/// combine with a trend indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector emits the uniform candlestick sign convention shared across the
/// pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no pattern — so it
/// drops straight into a machine-learning feature matrix as a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, KickingByLength};
///
/// let mut indicator = KickingByLength::new();
/// indicator.update(Candle::new(12.0, 12.0, 10.0, 10.0, 1.0, 0).unwrap());
/// // White marubozu gaps up and is the longer body -> +1.
/// let out = indicator
/// .update(Candle::new(14.0, 20.0, 14.0, 20.0, 1.0, 1).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct KickingByLength {
prev: Option<Candle>,
has_emitted: bool,
}
impl KickingByLength {
/// Construct a new Kicking-by-Length detector.
pub const fn new() -> Self {
Self {
prev: None,
has_emitted: false,
}
}
}
fn is_marubozu(candle: &Candle) -> bool {
let range = candle.high - candle.low;
range > 0.0 && (candle.close - candle.open).abs() >= 0.95 * range
}
impl Indicator for KickingByLength {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let prev = self.prev;
self.prev = Some(candle);
let Some(bar1) = prev else {
return Some(0.0);
};
if !is_marubozu(&bar1) || !is_marubozu(&candle) {
return Some(0.0);
}
let body1 = bar1.close - bar1.open;
let body2 = candle.close - candle.open;
let bullish_setup = body1 < 0.0 && body2 > 0.0 && candle.low > bar1.high;
let bearish_setup = body1 > 0.0 && body2 < 0.0 && candle.high < bar1.low;
if !(bullish_setup || bearish_setup) {
return Some(0.0);
}
// The longer marubozu's colour is the signal.
let longer_is_white = if body1.abs() >= body2.abs() {
body1 > 0.0
} else {
body2 > 0.0
};
Some(if longer_is_white { 1.0 } else { -1.0 })
}
fn reset(&mut self) {
self.prev = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"KickingByLength"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn accessors_and_metadata() {
let t = KickingByLength::new();
assert_eq!(t.name(), "KickingByLength");
assert_eq!(t.warmup_period(), 2);
assert!(!t.is_ready());
}
#[test]
fn longer_white_is_plus_one() {
let mut t = KickingByLength::new();
assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.0, 0)), Some(0.0));
// White marubozu (length 6) longer than the black one (length 2).
assert_eq!(t.update(c(14.0, 20.0, 14.0, 20.0, 1)), Some(1.0));
}
#[test]
fn longer_black_is_minus_one() {
let mut t = KickingByLength::new();
// Black marubozu (length 6), then a shorter white marubozu (length 2)
// gapping up -> the longer black body wins, so -1.
assert_eq!(t.update(c(16.0, 16.0, 10.0, 10.0, 0)), Some(0.0));
assert_eq!(t.update(c(18.0, 20.0, 18.0, 20.0, 1)), Some(-1.0));
}
#[test]
fn not_marubozu_yields_zero() {
let mut t = KickingByLength::new();
t.update(c(12.0, 14.0, 8.0, 10.0, 0));
assert_eq!(t.update(c(14.0, 20.0, 14.0, 20.0, 1)), Some(0.0));
}
#[test]
fn no_gap_yields_zero() {
let mut t = KickingByLength::new();
t.update(c(12.0, 12.0, 10.0, 10.0, 0));
assert_eq!(t.update(c(11.0, 13.0, 11.0, 13.0, 1)), Some(0.0));
}
#[test]
fn first_bar_returns_zero() {
let mut t = KickingByLength::new();
assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.0, 0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64 * 5.0;
if i % 2 == 0 {
c(base + 2.0, base + 2.0, base, base, i)
} else {
c(base + 3.0, base + 5.0, base + 3.0, base + 5.0, i)
}
})
.collect();
let mut a = KickingByLength::new();
let mut b = KickingByLength::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = KickingByLength::new();
t.update(c(12.0, 12.0, 10.0, 10.0, 0));
t.update(c(14.0, 20.0, 14.0, 20.0, 1));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.0, 0)), Some(0.0));
}
}
@@ -0,0 +1,207 @@
//! Ladder Bottom candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Ladder Bottom — a 5-bar bullish reversal. Three long black candles step the
/// market down like rungs of a ladder, a fourth black candle finally shows an
/// upper shadow (the first sign of buying), and a white candle then gaps up into
/// its body to confirm the turn.
///
/// ```text
/// bar1, bar2, bar3 black, with consecutively lower opens AND closes
/// bar4 black with an upper shadow (high4 > open4)
/// bar5 white, opens above bar4's body (open5 > open4) and closes up
/// ```
///
/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Ladder Bottom
/// is a single-direction (bullish-only) reversal, so it never emits `1.0`. The
/// first four bars always return `0.0` because the five-bar window is not yet
/// filled. Pattern-shape check only — no trend filter is applied; combine with a
/// trend indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector emits the uniform candlestick sign convention shared across the
/// pattern family — `+1.0` bullish, `0.0` no pattern — so it drops straight into
/// a machine-learning feature matrix as a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, LadderBottom};
///
/// let mut indicator = LadderBottom::new();
/// indicator.update(Candle::new(20.0, 20.1, 17.9, 18.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(18.0, 18.1, 15.9, 16.0, 1.0, 1).unwrap());
/// indicator.update(Candle::new(16.0, 16.1, 13.9, 14.0, 1.0, 2).unwrap());
/// indicator.update(Candle::new(14.0, 15.0, 12.4, 12.5, 1.0, 3).unwrap());
/// let out = indicator
/// .update(Candle::new(15.0, 17.1, 14.9, 17.0, 1.0, 4).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct LadderBottom {
c1: Option<Candle>,
c2: Option<Candle>,
c3: Option<Candle>,
c4: Option<Candle>,
has_emitted: bool,
}
impl LadderBottom {
/// Construct a new Ladder Bottom detector.
pub const fn new() -> Self {
Self {
c1: None,
c2: None,
c3: None,
c4: None,
has_emitted: false,
}
}
}
impl Indicator for LadderBottom {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let bar1 = self.c1;
let bar2 = self.c2;
let bar3 = self.c3;
let bar4 = self.c4;
self.c1 = self.c2;
self.c2 = self.c3;
self.c3 = self.c4;
self.c4 = Some(candle);
let (Some(bar1), Some(bar2), Some(bar3), Some(bar4)) = (bar1, bar2, bar3, bar4) else {
return Some(0.0);
};
if bar1.close < bar1.open
&& bar2.close < bar2.open
&& bar3.close < bar3.open
&& bar2.open < bar1.open
&& bar2.close < bar1.close
&& bar3.open < bar2.open
&& bar3.close < bar2.close
&& bar4.close < bar4.open
&& bar4.high > bar4.open
&& candle.close > candle.open
&& candle.open > bar4.open
{
return Some(1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.c1 = None;
self.c2 = None;
self.c3 = None;
self.c4 = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
5
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"LadderBottom"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn accessors_and_metadata() {
let t = LadderBottom::new();
assert_eq!(t.name(), "LadderBottom");
assert_eq!(t.warmup_period(), 5);
assert!(!t.is_ready());
}
#[test]
fn ladder_bottom_is_plus_one() {
let mut t = LadderBottom::new();
assert_eq!(t.update(c(20.0, 20.1, 17.9, 18.0, 0)), Some(0.0));
assert_eq!(t.update(c(18.0, 18.1, 15.9, 16.0, 1)), Some(0.0));
assert_eq!(t.update(c(16.0, 16.1, 13.9, 14.0, 2)), Some(0.0));
assert_eq!(t.update(c(14.0, 15.0, 12.4, 12.5, 3)), Some(0.0));
assert_eq!(t.update(c(15.0, 17.1, 14.9, 17.0, 4)), Some(1.0));
}
#[test]
fn fourth_bar_without_upper_shadow_yields_zero() {
let mut t = LadderBottom::new();
t.update(c(20.0, 20.1, 17.9, 18.0, 0));
t.update(c(18.0, 18.1, 15.9, 16.0, 1));
t.update(c(16.0, 16.1, 13.9, 14.0, 2));
// bar4 opens at its high -> no upper shadow.
t.update(c(14.0, 14.0, 12.4, 12.5, 3));
assert_eq!(t.update(c(15.0, 17.1, 14.9, 17.0, 4)), Some(0.0));
}
#[test]
fn not_three_descending_blacks_yields_zero() {
let mut t = LadderBottom::new();
// bar2 is not lower than bar1.
t.update(c(20.0, 20.1, 17.9, 18.0, 0));
t.update(c(21.0, 21.1, 18.9, 19.0, 1));
t.update(c(16.0, 16.1, 13.9, 14.0, 2));
t.update(c(14.0, 15.0, 12.4, 12.5, 3));
assert_eq!(t.update(c(15.0, 17.1, 14.9, 17.0, 4)), Some(0.0));
}
#[test]
fn first_four_bars_return_zero() {
let mut t = LadderBottom::new();
assert_eq!(t.update(c(20.0, 20.1, 17.9, 18.0, 0)), Some(0.0));
assert_eq!(t.update(c(18.0, 18.1, 15.9, 16.0, 1)), Some(0.0));
assert_eq!(t.update(c(16.0, 16.1, 13.9, 14.0, 2)), Some(0.0));
assert_eq!(t.update(c(14.0, 15.0, 12.4, 12.5, 3)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 200.0 - i as f64;
c(base, base + 0.1, base - 2.1, base - 2.0, i)
})
.collect();
let mut a = LadderBottom::new();
let mut b = LadderBottom::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = LadderBottom::new();
t.update(c(20.0, 20.1, 17.9, 18.0, 0));
t.update(c(18.0, 18.1, 15.9, 16.0, 1));
t.update(c(16.0, 16.1, 13.9, 14.0, 2));
t.update(c(14.0, 15.0, 12.4, 12.5, 3));
t.update(c(15.0, 17.1, 14.9, 17.0, 4));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(20.0, 20.1, 17.9, 18.0, 0)), Some(0.0));
}
}
@@ -0,0 +1,274 @@
//! Mat Hold candlestick pattern.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Mat Hold — a 5-bar bullish continuation. A long white candle is followed by a
/// brief three-bar pullback that gaps up and then drifts on small bodies *without*
/// surrendering much ground, after which a white candle breaks to a new high and
/// the uptrend resumes.
///
/// ```text
/// long body = |close open| >= 0.5 * (high low)
/// bar1 white & long
/// bar2 small body gapping up above bar1 (min(o2,c2) > close1)
/// bar2, bar3, bar4 each small (|body| <= 0.5 · body1)
/// the pullback holds (min low of bars 2..4 > close1 penetration·body1)
/// bar5 white, closing at a new high (close5 > max high of bars 1..4)
/// ```
///
/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Mat Hold is a
/// single-direction (bullish-only) continuation, so it never emits `1.0`. The
/// first four bars always return `0.0` because the five-bar window is not yet
/// filled. `penetration` is how far the pullback may retrace into the first body;
/// it defaults to `0.5` (TA-Lib's `CDLMATHOLD` default) and must lie in `[0, 1)`.
/// Body thresholds follow the geometric house style rather than TA-Lib's rolling
/// averages. Pattern-shape check only — no trend filter is applied; combine with a
/// trend indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector emits the uniform candlestick sign convention shared across the
/// pattern family — `+1.0` bullish, `0.0` no pattern — so it drops straight into
/// a machine-learning feature matrix as a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, MatHold};
///
/// let mut indicator = MatHold::new();
/// indicator.update(Candle::new(10.0, 15.1, 9.9, 15.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(16.0, 16.1, 15.4, 15.5, 1.0, 1).unwrap());
/// indicator.update(Candle::new(15.5, 15.6, 14.9, 15.0, 1.0, 2).unwrap());
/// indicator.update(Candle::new(15.0, 15.1, 14.4, 14.5, 1.0, 3).unwrap());
/// let out = indicator
/// .update(Candle::new(14.5, 17.1, 14.4, 17.0, 1.0, 4).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone)]
pub struct MatHold {
penetration: f64,
c1: Option<Candle>,
c2: Option<Candle>,
c3: Option<Candle>,
c4: Option<Candle>,
has_emitted: bool,
}
impl Default for MatHold {
fn default() -> Self {
Self::new()
}
}
impl MatHold {
/// Construct a Mat Hold detector with the default 0.5 penetration.
pub const fn new() -> Self {
Self {
penetration: 0.5,
c1: None,
c2: None,
c3: None,
c4: None,
has_emitted: false,
}
}
/// Construct a Mat Hold detector with a custom penetration fraction.
///
/// `penetration` must lie in `[0, 1)`.
pub fn with_penetration(penetration: f64) -> Result<Self> {
if !(0.0..1.0).contains(&penetration) {
return Err(Error::InvalidPeriod {
message: "mat hold penetration must lie in [0, 1)",
});
}
Ok(Self {
penetration,
c1: None,
c2: None,
c3: None,
c4: None,
has_emitted: false,
})
}
/// Configured penetration fraction.
pub fn penetration(&self) -> f64 {
self.penetration
}
}
impl Indicator for MatHold {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let bar1 = self.c1;
let bar2 = self.c2;
let bar3 = self.c3;
let bar4 = self.c4;
self.c1 = self.c2;
self.c2 = self.c3;
self.c3 = self.c4;
self.c4 = Some(candle);
let (Some(bar1), Some(bar2), Some(bar3), Some(bar4)) = (bar1, bar2, bar3, bar4) else {
return Some(0.0);
};
let range1 = bar1.high - bar1.low;
if range1 <= 0.0 {
return Some(0.0);
}
let body1 = bar1.close - bar1.open;
if body1 < 0.5 * range1 {
return Some(0.0); // bar1 must be a long white body
}
let small = 0.5 * body1;
if (bar2.close - bar2.open).abs() > small
|| (bar3.close - bar3.open).abs() > small
|| (bar4.close - bar4.open).abs() > small
{
return Some(0.0); // the three pullback bars must be small
}
// bar2 gaps up above bar1's body.
if bar2.open.min(bar2.close) <= bar1.close {
return Some(0.0);
}
// The pullback must hold above the penetration line.
let hold_line = bar1.close - self.penetration * body1;
if bar2.low.min(bar3.low).min(bar4.low) <= hold_line {
return Some(0.0);
}
// bar5 breaks to a new high on a white body.
let max_high = bar1.high.max(bar2.high).max(bar3.high).max(bar4.high);
if candle.close > candle.open && candle.close > max_high {
return Some(1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.c1 = None;
self.c2 = None;
self.c3 = None;
self.c4 = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
5
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"MatHold"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn rejects_invalid_penetration() {
assert!(MatHold::with_penetration(-0.01).is_err());
assert!(MatHold::with_penetration(1.0).is_err());
}
#[test]
fn accepts_valid_penetration() {
let t = MatHold::with_penetration(0.3).unwrap();
assert!((t.penetration() - 0.3).abs() < 1e-12);
}
#[test]
fn accessors_and_metadata() {
let t = MatHold::default();
assert_eq!(t.name(), "MatHold");
assert_eq!(t.warmup_period(), 5);
assert!(!t.is_ready());
assert!((t.penetration() - 0.5).abs() < 1e-12);
}
#[test]
fn mat_hold_is_plus_one() {
let mut t = MatHold::new();
assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(16.0, 16.1, 15.4, 15.5, 1)), Some(0.0));
assert_eq!(t.update(c(15.5, 15.6, 14.9, 15.0, 2)), Some(0.0));
assert_eq!(t.update(c(15.0, 15.1, 14.4, 14.5, 3)), Some(0.0));
assert_eq!(t.update(c(14.5, 17.1, 14.4, 17.0, 4)), Some(1.0));
}
#[test]
fn pullback_breaks_hold_yields_zero() {
let mut t = MatHold::new();
t.update(c(10.0, 15.1, 9.9, 15.0, 0));
t.update(c(16.0, 16.1, 15.4, 15.5, 1));
t.update(c(15.5, 15.6, 14.9, 15.0, 2));
// bar4 dips below the hold line (close1 - 0.5*body1 = 12.5).
t.update(c(13.0, 13.1, 12.0, 12.4, 3));
assert_eq!(t.update(c(14.5, 17.1, 12.0, 17.0, 4)), Some(0.0));
}
#[test]
fn no_new_high_yields_zero() {
let mut t = MatHold::new();
t.update(c(10.0, 15.1, 9.9, 15.0, 0));
t.update(c(16.0, 16.1, 15.4, 15.5, 1));
t.update(c(15.5, 15.6, 14.9, 15.0, 2));
t.update(c(15.0, 15.1, 14.4, 14.5, 3));
// bar5 white but closes below the prior max high (16.1).
assert_eq!(t.update(c(14.5, 16.0, 14.4, 15.9, 4)), Some(0.0));
}
#[test]
fn first_four_bars_return_zero() {
let mut t = MatHold::new();
assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(16.0, 16.1, 15.4, 15.5, 1)), Some(0.0));
assert_eq!(t.update(c(15.5, 15.6, 14.9, 15.0, 2)), Some(0.0));
assert_eq!(t.update(c(15.0, 15.1, 14.4, 14.5, 3)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
c(base, base + 5.2, base - 0.1, base + 5.0, i)
})
.collect();
let mut a = MatHold::new();
let mut b = MatHold::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = MatHold::new();
t.update(c(10.0, 15.1, 9.9, 15.0, 0));
t.update(c(16.0, 16.1, 15.4, 15.5, 1));
t.update(c(15.5, 15.6, 14.9, 15.0, 2));
t.update(c(15.0, 15.1, 14.4, 14.5, 3));
t.update(c(14.5, 17.1, 14.4, 17.0, 4));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0));
}
}
+16 -1
View File
@@ -121,10 +121,13 @@ mod jma;
mod kama;
mod kelly_criterion;
mod keltner;
mod kicking;
mod kicking_by_length;
mod kst;
mod kurtosis;
mod kvo;
mod kyles_lambda;
mod ladder_bottom;
mod laguerre_rsi;
mod lead_lag_cross_correlation;
mod linreg;
@@ -140,6 +143,7 @@ mod mama;
mod market_facilitation_index;
mod marubozu;
mod mass_index;
mod mat_hold;
mod max_drawdown;
mod mcginley_dynamic;
mod median_absolute_deviation;
@@ -189,6 +193,7 @@ mod rsi;
mod rvi;
mod rvi_volatility;
mod rwi;
mod separating_lines;
mod sharpe_ratio;
mod shooting_star;
mod signed_volume;
@@ -390,10 +395,13 @@ pub use jma::Jma;
pub use kama::Kama;
pub use kelly_criterion::KellyCriterion;
pub use keltner::{Keltner, KeltnerOutput};
pub use kicking::Kicking;
pub use kicking_by_length::KickingByLength;
pub use kst::{Kst, KstOutput};
pub use kurtosis::Kurtosis;
pub use kvo::Kvo;
pub use kyles_lambda::KylesLambda;
pub use ladder_bottom::LadderBottom;
pub use laguerre_rsi::LaguerreRsi;
pub use lead_lag_cross_correlation::{LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput};
pub use linreg::LinearRegression;
@@ -409,6 +417,7 @@ pub use mama::{Mama, MamaOutput};
pub use market_facilitation_index::MarketFacilitationIndex;
pub use marubozu::Marubozu;
pub use mass_index::MassIndex;
pub use mat_hold::MatHold;
pub use max_drawdown::MaxDrawdown;
pub use mcginley_dynamic::McGinleyDynamic;
pub use median_absolute_deviation::MedianAbsoluteDeviation;
@@ -458,6 +467,7 @@ pub use rsi::Rsi;
pub use rvi::Rvi;
pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
pub use separating_lines::SeparatingLines;
pub use sharpe_ratio::SharpeRatio;
pub use shooting_star::ShootingStar;
pub use signed_volume::SignedVolume;
@@ -830,6 +840,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"OnNeck",
"InNeck",
"Thrusting",
"SeparatingLines",
"Kicking",
"KickingByLength",
"LadderBottom",
"MatHold",
],
),
(
@@ -921,6 +936,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, 264, "FAMILIES total drifted from indicator count");
assert_eq!(total, 269, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,202 @@
//! Separating Lines candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Separating Lines — a 2-bar continuation. After a counter-trend candle, the next
/// candle of the *opposite* colour opens right back at the prior open and runs as
/// an opening marubozu in the trend direction, so the trend "separates" from the
/// pullback and resumes.
///
/// ```text
/// long body = |close open| >= 0.5 * (high low)
/// bar1, bar2 opposite colours
/// bar2 opens at bar1's open (|open2 open1| <= 0.05 · range1)
/// bar2 is a long opening marubozu in its direction
/// white bar2: open2 == low2 (no lower shadow) -> +1.0
/// black bar2: open2 == high2 (no upper shadow) -> 1.0
/// ```
///
/// Output is `+1.0` (bullish continuation) or `1.0` (bearish continuation) when
/// the pattern completes and `0.0` otherwise. The first bar always returns `0.0`
/// because the two-bar window is not yet filled. Open-equality and marubozu
/// thresholds follow the geometric house style rather than TA-Lib's rolling
/// averages. Pattern-shape check only — no trend filter is applied; combine with
/// a trend indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector emits the uniform candlestick sign convention shared across the
/// pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no pattern — so it
/// drops straight into a machine-learning feature matrix where the two directions
/// occupy a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, SeparatingLines};
///
/// let mut indicator = SeparatingLines::new();
/// indicator.update(Candle::new(12.0, 12.1, 9.9, 10.0, 1.0, 0).unwrap());
/// let out = indicator
/// .update(Candle::new(12.0, 14.1, 12.0, 14.0, 1.0, 1).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct SeparatingLines {
prev: Option<Candle>,
has_emitted: bool,
}
impl SeparatingLines {
/// Construct a new Separating Lines detector.
pub const fn new() -> Self {
Self {
prev: None,
has_emitted: false,
}
}
}
impl Indicator for SeparatingLines {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let prev = self.prev;
self.prev = Some(candle);
let Some(bar1) = prev else {
return Some(0.0);
};
let range1 = bar1.high - bar1.low;
let range2 = candle.high - candle.low;
if range1 <= 0.0 || range2 <= 0.0 {
return Some(0.0);
}
// Opens must coincide.
if (candle.open - bar1.open).abs() > 0.05 * range1 {
return Some(0.0);
}
let body2 = candle.close - candle.open;
if body2.abs() < 0.5 * range2 {
return Some(0.0); // bar2 must be a long body
}
let tol = 0.05 * range2;
// Bullish: bar1 black, bar2 a long white opening marubozu (no lower wick).
if bar1.close < bar1.open && body2 > 0.0 && candle.open - candle.low <= tol {
return Some(1.0);
}
// Bearish: bar1 white, bar2 a long black opening marubozu (no upper wick).
if bar1.close > bar1.open && body2 < 0.0 && candle.high - candle.open <= tol {
return Some(-1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.prev = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"SeparatingLines"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn accessors_and_metadata() {
let t = SeparatingLines::new();
assert_eq!(t.name(), "SeparatingLines");
assert_eq!(t.warmup_period(), 2);
assert!(!t.is_ready());
}
#[test]
fn bullish_separating_lines_is_plus_one() {
let mut t = SeparatingLines::new();
assert_eq!(t.update(c(12.0, 12.1, 9.9, 10.0, 0)), Some(0.0));
assert_eq!(t.update(c(12.0, 14.1, 12.0, 14.0, 1)), Some(1.0));
}
#[test]
fn bearish_separating_lines_is_minus_one() {
let mut t = SeparatingLines::new();
assert_eq!(t.update(c(10.0, 12.1, 9.9, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(10.0, 10.0, 7.9, 8.0, 1)), Some(-1.0));
}
#[test]
fn same_color_yields_zero() {
let mut t = SeparatingLines::new();
// Both white -> not separating (need opposite colours).
t.update(c(12.0, 14.1, 11.9, 14.0, 0));
assert_eq!(t.update(c(12.0, 14.1, 12.0, 14.0, 1)), Some(0.0));
}
#[test]
fn different_open_yields_zero() {
let mut t = SeparatingLines::new();
t.update(c(12.0, 12.1, 9.9, 10.0, 0));
// bar2 opens far from bar1's open.
assert_eq!(t.update(c(13.0, 15.1, 13.0, 15.0, 1)), Some(0.0));
}
#[test]
fn opening_shadow_yields_zero() {
let mut t = SeparatingLines::new();
t.update(c(12.0, 12.1, 9.9, 10.0, 0));
// White bar2 but it has a lower shadow -> not an opening marubozu.
assert_eq!(t.update(c(12.0, 14.1, 11.0, 14.0, 1)), Some(0.0));
}
#[test]
fn first_bar_returns_zero() {
let mut t = SeparatingLines::new();
assert_eq!(t.update(c(12.0, 12.1, 9.9, 10.0, 0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
c(base, base + 2.0, base, base + 1.9, i)
})
.collect();
let mut a = SeparatingLines::new();
let mut b = SeparatingLines::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = SeparatingLines::new();
t.update(c(12.0, 12.1, 9.9, 10.0, 0));
t.update(c(12.0, 14.1, 12.0, 14.0, 1));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(12.0, 12.1, 9.9, 10.0, 0)), Some(0.0));
}
}
+30 -30
View File
@@ -75,36 +75,36 @@ pub use indicators::{
HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma, HomingPigeon, HurstChannel,
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck,
Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
InverseFisherTransform, InvertedHammer, Jma, Kama, KellyCriterion, Keltner, KeltnerOutput, Kst,
KstOutput, Kurtosis, Kvo, KylesLambda, LaguerreRsi, LeadLagCrossCorrelation,
LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope,
LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput, LongLeggedDoji,
LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput,
MarketFacilitationIndex, Marubozu, MassIndex, MaxDrawdown, McGinleyDynamic,
MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, Mom, MorningDojiStar,
MorningEveningStar, Natr, Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck,
OpenInterestDelta, OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull,
OrderBookImbalanceTop1, OrderBookImbalanceTopN, PainIndex, PairSpreadZScore, PairwiseBeta,
ParkinsonVolatility, PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo,
PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, RealizedSpread,
RecoveryFactor, RelativeStrengthAB, RelativeStrengthOutput, RenkoTrailingStop, RickshawMan,
Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi,
RwiOutput, SharpeRatio, ShootingStar, SignedVolume, SineWave, Skewness, Sma, Smi, Smma,
SortinoRatio, SpearmanCorrelation, SpinningTop, StandardError, StandardErrorBands,
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput,
TakerBuySellRatio, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput,
TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel,
TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, TermStructureBasis,
ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting,
Tii, TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze,
TtmSqueezeOutput, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter,
Vidya, VoltyStop, VolumeOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap,
VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, WeightedClose,
WilliamsFractals, WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput,
YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput,
Zlema, FAMILIES, T3,
InverseFisherTransform, InvertedHammer, Jma, Kama, KellyCriterion, Keltner, KeltnerOutput,
Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom,
LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle,
LinRegChannel, LinRegChannelOutput, LinRegSlope, LinearRegression, LiquidationFeatures,
LiquidationFeaturesOutput, LongLeggedDoji, LongShortRatio, MaEnvelope, MaEnvelopeOutput,
MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex,
MatHold, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice,
Mom, MorningDojiStar, MorningEveningStar, Natr, Nvi, OIPriceDivergence, OIWeighted, Obv,
OmegaRatio, OnNeck, OpenInterestDelta, OpeningRange, OpeningRangeOutput,
OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, PainIndex,
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentB,
PercentageTrailingStop, Pgo, PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi,
QuotedSpread, RSquared, RealizedSpread, RecoveryFactor, RelativeStrengthAB,
RelativeStrengthOutput, RenkoTrailingStop, RickshawMan, Roc, RogersSatchellVolatility,
RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SeparatingLines,
SharpeRatio, ShootingStar, SignedVolume, SineWave, Skewness, Sma, Smi, Smma, SortinoRatio,
SpearmanCorrelation, SpinningTop, StandardError, StandardErrorBands, StandardErrorBandsOutput,
StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, StochRsi, Stochastic,
StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, TakerBuySellRatio, TdCombo,
TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure,
TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput,
TdSequential, TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeInside,
ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, Tii,
TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput,
Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator, UpsideGapTwoCrows, ValueArea,
ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop,
VolumeOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands,
VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsFractals,
WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility,
YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
};
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
// line so the indicator-count tooling (which scans the braced block above and