feat: TA-Lib candlestick patterns — matching-low/lines/three-methods (part 7 of 9) (#139)

Adds five TA-Lib candlestick patterns, all `Input = Candle`, `Output = f64`
(`+1.0` bullish / `-1.0` bearish / `0.0` no pattern), wired across core,
Python/Node/WASM bindings, fuzz, and tests.

- **Matching Low** (`CDLMATCHINGLOW`) — 2-bar bullish reversal: two black candles in a decline share the same close, signalling selling pressure is exhausting; bullish +1.
- **Long Line** (`CDLLONGLINE`) — a candle whose range beats a rolling average of recent ranges with a body-dominated range; bullish +1 (white) / bearish -1 (black).
- **Short Line** (`CDLSHORTLINE`) — a compact candle whose range falls below the rolling average with a body-dominated range; bullish +1 (white) / bearish -1 (black).
- **Rising Three Methods** (`CDLRISEFALL3METHODS`) — 5-bar bullish continuation: a long white candle, three small bars holding within its range, then a white breakout to new highs; bullish +1.
- **Falling Three Methods** (`CDLRISEFALL3METHODS`) — the bearish mirror: a long black candle, three small bars within its range, then a black breakdown to new lows; bearish -1.

Counter 274 → 279 (mod-count == lib counted block; FAMILIES total 269 → 274).

Stacked on #138 (part 6 of 9); base retargets to `main` as the chain merges.
This commit is contained in:
kingchenc
2026-06-02 17:16:15 +02:00
committed by GitHub
parent 04ae145126
commit c2c85c7ecf
19 changed files with 1285 additions and 41 deletions
@@ -0,0 +1,213 @@
//! Falling Three Methods candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Falling Three Methods — a 5-bar bearish continuation. A long black candle is
/// followed by three small bars that drift up but stay inside its range (a brief
/// rest), then a second long black candle closes below the first, resuming the
/// decline.
///
/// ```text
/// long body = |close open| >= 0.5 * (high low)
/// bar1 black & long
/// bar2, bar3, bar4 small bodies, each contained within bar1's high/low range
/// bar5 black, closing below bar1's close
/// ```
///
/// Output is `1.0` when the pattern completes and `0.0` otherwise. Falling Three
/// Methods is a single-direction (bearish-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. 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` bearish, `0.0` no pattern — so it drops straight into
/// a machine-learning feature matrix as a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, FallingThreeMethods, Indicator};
///
/// let mut indicator = FallingThreeMethods::new();
/// indicator.update(Candle::new(15.0, 15.1, 9.9, 10.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(11.0, 12.1, 10.9, 12.0, 1.0, 1).unwrap());
/// indicator.update(Candle::new(11.5, 12.6, 11.4, 12.5, 1.0, 2).unwrap());
/// indicator.update(Candle::new(12.0, 13.1, 11.9, 13.0, 1.0, 3).unwrap());
/// let out = indicator
/// .update(Candle::new(12.5, 12.6, 8.9, 9.0, 1.0, 4).unwrap());
/// assert_eq!(out, Some(-1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct FallingThreeMethods {
c1: Option<Candle>,
c2: Option<Candle>,
c3: Option<Candle>,
c4: Option<Candle>,
has_emitted: bool,
}
impl FallingThreeMethods {
/// Construct a new Falling Three Methods detector.
pub const fn new() -> Self {
Self {
c1: None,
c2: None,
c3: None,
c4: None,
has_emitted: false,
}
}
}
impl Indicator for FallingThreeMethods {
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.open - bar1.close;
if body1 < 0.5 * range1 {
return Some(0.0); // bar1 must be a long black body
}
// The three middle bars stay within bar1's range with smaller bodies.
for mid in [bar2, bar3, bar4] {
if (mid.close - mid.open).abs() >= body1 || mid.high > bar1.high || mid.low < bar1.low {
return Some(0.0);
}
}
// bar5 is a black candle closing below bar1's close.
if candle.close < candle.open && candle.close < bar1.close {
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 {
"FallingThreeMethods"
}
}
#[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 = FallingThreeMethods::new();
assert_eq!(t.name(), "FallingThreeMethods");
assert_eq!(t.warmup_period(), 5);
assert!(!t.is_ready());
}
#[test]
fn falling_three_methods_is_minus_one() {
let mut t = FallingThreeMethods::new();
assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 12.1, 10.9, 12.0, 1)), Some(0.0));
assert_eq!(t.update(c(11.5, 12.6, 11.4, 12.5, 2)), Some(0.0));
assert_eq!(t.update(c(12.0, 13.1, 11.9, 13.0, 3)), Some(0.0));
assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(-1.0));
}
#[test]
fn middle_bar_breaks_range_yields_zero() {
let mut t = FallingThreeMethods::new();
t.update(c(15.0, 15.1, 9.9, 10.0, 0));
t.update(c(11.0, 12.1, 10.9, 12.0, 1));
// bar3 pokes below bar1's low.
t.update(c(11.5, 12.6, 9.0, 12.5, 2));
t.update(c(12.0, 13.1, 11.9, 13.0, 3));
assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(0.0));
}
#[test]
fn bar5_not_new_low_yields_zero() {
let mut t = FallingThreeMethods::new();
t.update(c(15.0, 15.1, 9.9, 10.0, 0));
t.update(c(11.0, 12.1, 10.9, 12.0, 1));
t.update(c(11.5, 12.6, 11.4, 12.5, 2));
t.update(c(12.0, 13.1, 11.9, 13.0, 3));
// bar5 black but closes above bar1's close.
assert_eq!(t.update(c(12.5, 12.6, 10.4, 10.5, 4)), Some(0.0));
}
#[test]
fn first_four_bars_return_zero() {
let mut t = FallingThreeMethods::new();
assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 12.1, 10.9, 12.0, 1)), Some(0.0));
assert_eq!(t.update(c(11.5, 12.6, 11.4, 12.5, 2)), Some(0.0));
assert_eq!(t.update(c(12.0, 13.1, 11.9, 13.0, 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 + 5.0, base + 5.1, base - 0.1, base, i)
})
.collect();
let mut a = FallingThreeMethods::new();
let mut b = FallingThreeMethods::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = FallingThreeMethods::new();
t.update(c(15.0, 15.1, 9.9, 10.0, 0));
t.update(c(11.0, 12.1, 10.9, 12.0, 1));
t.update(c(11.5, 12.6, 11.4, 12.5, 2));
t.update(c(12.0, 13.1, 11.9, 13.0, 3));
t.update(c(12.5, 12.6, 8.9, 9.0, 4));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
}
}
@@ -0,0 +1,229 @@
//! Long Line candlestick pattern.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
use std::collections::VecDeque;
/// Long Line — a single candle whose range is *longer* than the recent average and
/// whose body dominates that range (a solid directional bar). Because "long" only
/// has meaning relative to recent activity, the detector compares each candle's
/// range against a rolling average of the previous `period` ranges.
///
/// ```text
/// avg = mean range of the previous `period` candles
/// long line = range > avg AND |close open| >= 0.5 * range
/// white -> +1.0, black -> 1.0
/// ```
///
/// Output is `+1.0` (long white line), `1.0` (long black line), or `0.0`
/// otherwise. The first `period` candles return `0.0` while the rolling average
/// fills. `period` defaults to `5` and must be at least `1`. This rolling baseline
/// is the one place the family departs from a purely intra-candle rule, since a
/// short/long classification is inherently scale-relative. 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, LongLine};
///
/// let mut indicator = LongLine::new();
/// // Five quiet bars fill the rolling average.
/// for ts in 0..5 {
/// indicator.update(Candle::new(10.0, 10.5, 9.5, 10.2, 1.0, ts).unwrap());
/// }
/// // A wide solid white bar is a long white line.
/// let out = indicator
/// .update(Candle::new(10.0, 13.0, 9.9, 12.9, 1.0, 5).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone)]
pub struct LongLine {
period: usize,
ranges: VecDeque<f64>,
}
impl Default for LongLine {
fn default() -> Self {
Self::new()
}
}
impl LongLine {
/// Construct a Long Line detector with the default 5-candle rolling average.
pub const fn new() -> Self {
Self {
period: 5,
ranges: VecDeque::new(),
}
}
/// Construct a Long Line detector with a custom averaging period.
///
/// `period` must be at least `1`.
pub fn with_period(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
ranges: VecDeque::new(),
})
}
/// Configured averaging period.
pub fn period(&self) -> usize {
self.period
}
}
impl Indicator for LongLine {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let range = candle.high - candle.low;
let body = candle.close - candle.open;
if self.ranges.len() < self.period {
self.ranges.push_back(range);
return Some(0.0);
}
let avg = self.ranges.iter().sum::<f64>() / self.period as f64;
self.ranges.push_back(range);
self.ranges.pop_front();
if range > avg && body.abs() >= 0.5 * range {
return Some(if body > 0.0 { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.ranges.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.ranges.len() >= self.period
}
fn name(&self) -> &'static str {
"LongLine"
}
}
#[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()
}
fn warm(t: &mut LongLine) {
for ts in 0..5 {
assert_eq!(t.update(c(10.0, 10.5, 9.5, 10.2, ts)), Some(0.0));
}
}
#[test]
fn rejects_zero_period() {
assert!(LongLine::with_period(0).is_err());
}
#[test]
fn accepts_valid_period() {
let t = LongLine::with_period(10).unwrap();
assert_eq!(t.period(), 10);
}
#[test]
fn accessors_and_metadata() {
let t = LongLine::new();
assert_eq!(t.name(), "LongLine");
assert_eq!(t.warmup_period(), 5);
assert!(!t.is_ready());
assert_eq!(t.period(), 5);
}
#[test]
fn long_white_line_is_plus_one() {
let mut t = LongLine::new();
warm(&mut t);
assert!(t.is_ready());
assert_eq!(t.update(c(10.0, 13.0, 9.9, 12.9, 5)), Some(1.0));
}
#[test]
fn long_black_line_is_minus_one() {
let mut t = LongLine::new();
warm(&mut t);
assert_eq!(t.update(c(13.0, 13.1, 9.9, 10.0, 5)), Some(-1.0));
}
#[test]
fn short_range_yields_zero() {
let mut t = LongLine::new();
warm(&mut t);
// Range no bigger than the average -> not a long line.
assert_eq!(t.update(c(10.0, 10.5, 9.5, 10.2, 5)), Some(0.0));
}
#[test]
fn wide_range_small_body_yields_zero() {
let mut t = LongLine::new();
warm(&mut t);
// Wide range but a tiny body -> a spinning top, not a long line.
assert_eq!(t.update(c(10.5, 13.0, 9.9, 10.6, 5)), Some(0.0));
}
#[test]
fn warmup_returns_zero() {
let mut t = LongLine::new();
for ts in 0..5 {
assert_eq!(t.update(c(10.0, 13.0, 9.9, 12.9, ts)), Some(0.0));
}
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
if i % 7 == 0 {
c(base, base + 4.0, base - 0.1, base + 3.9, i)
} else {
c(base, base + 0.5, base - 0.5, base + 0.2, i)
}
})
.collect();
let mut a = LongLine::new();
let mut b = LongLine::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = LongLine::new();
warm(&mut t);
t.update(c(10.0, 13.0, 9.9, 12.9, 5));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(10.0, 13.0, 9.9, 12.9, 0)), Some(0.0));
}
}
@@ -0,0 +1,166 @@
//! Matching Low candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Matching Low — a 2-bar bullish reversal. Two black candles in a decline close
/// at the *same* level: the second sell-off cannot push price any lower, so the
/// matching closes mark a support floor.
///
/// ```text
/// bar1, bar2 both black
/// equal closes = |close2 close1| <= 0.05 · mean(range1, range2)
/// ```
///
/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Matching Low
/// is a single-direction (bullish-only) reversal, so it never emits `1.0`. The
/// first bar always returns `0.0` because the two-bar window is not yet filled.
/// The close-equality tolerance 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, `0.0` no pattern — so it drops straight into
/// a machine-learning feature matrix as a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, MatchingLow};
///
/// let mut indicator = MatchingLow::new();
/// indicator.update(Candle::new(15.0, 15.1, 9.9, 10.0, 1.0, 0).unwrap());
/// let out = indicator
/// .update(Candle::new(13.0, 13.1, 9.9, 10.0, 1.0, 1).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct MatchingLow {
prev: Option<Candle>,
has_emitted: bool,
}
impl MatchingLow {
/// Construct a new Matching Low detector.
pub const fn new() -> Self {
Self {
prev: None,
has_emitted: false,
}
}
}
impl Indicator for MatchingLow {
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 mean_range = 0.5 * ((bar1.high - bar1.low) + (candle.high - candle.low));
let tol = 0.05 * mean_range;
if bar1.close < bar1.open
&& candle.close < candle.open
&& (candle.close - bar1.close).abs() <= 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 {
"MatchingLow"
}
}
#[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 = MatchingLow::new();
assert_eq!(t.name(), "MatchingLow");
assert_eq!(t.warmup_period(), 2);
assert!(!t.is_ready());
}
#[test]
fn matching_low_is_plus_one() {
let mut t = MatchingLow::new();
assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
assert_eq!(t.update(c(13.0, 13.1, 9.9, 10.0, 1)), Some(1.0));
}
#[test]
fn different_close_yields_zero() {
let mut t = MatchingLow::new();
t.update(c(15.0, 15.1, 9.9, 10.0, 0));
// Second close well away from the first.
assert_eq!(t.update(c(13.0, 13.1, 11.4, 11.5, 1)), Some(0.0));
}
#[test]
fn second_bar_white_yields_zero() {
let mut t = MatchingLow::new();
t.update(c(15.0, 15.1, 9.9, 10.0, 0));
assert_eq!(t.update(c(9.0, 10.1, 8.9, 10.0, 1)), Some(0.0));
}
#[test]
fn first_bar_returns_zero() {
let mut t = MatchingLow::new();
assert_eq!(t.update(c(15.0, 15.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 + 2.0, base + 2.1, base - 0.1, base, i)
})
.collect();
let mut a = MatchingLow::new();
let mut b = MatchingLow::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = MatchingLow::new();
t.update(c(15.0, 15.1, 9.9, 10.0, 0));
t.update(c(13.0, 13.1, 9.9, 10.0, 1));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
}
}
+16 -1
View File
@@ -79,6 +79,7 @@ mod empirical_mode_decomposition;
mod engulfing;
mod evening_doji_star;
mod evwma;
mod falling_three_methods;
mod fama;
mod fibonacci_pivots;
mod fisher_transform;
@@ -136,6 +137,7 @@ mod linreg_channel;
mod linreg_slope;
mod liquidation_features;
mod long_legged_doji;
mod long_line;
mod long_short_ratio;
mod ma_envelope;
mod macd;
@@ -144,6 +146,7 @@ mod market_facilitation_index;
mod marubozu;
mod mass_index;
mod mat_hold;
mod matching_low;
mod max_drawdown;
mod mcginley_dynamic;
mod median_absolute_deviation;
@@ -186,6 +189,7 @@ mod recovery_factor;
mod relative_strength_ab;
mod renko_trailing_stop;
mod rickshaw_man;
mod rising_three_methods;
mod roc;
mod rogers_satchell;
mod roofing_filter;
@@ -196,6 +200,7 @@ mod rwi;
mod separating_lines;
mod sharpe_ratio;
mod shooting_star;
mod short_line;
mod signed_volume;
mod sine_wave;
mod skewness;
@@ -353,6 +358,7 @@ pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
pub use engulfing::Engulfing;
pub use evening_doji_star::EveningDojiStar;
pub use evwma::Evwma;
pub use falling_three_methods::FallingThreeMethods;
pub use fama::Fama;
pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput};
pub use fisher_transform::FisherTransform;
@@ -410,6 +416,7 @@ pub use linreg_channel::{LinRegChannel, LinRegChannelOutput};
pub use linreg_slope::LinRegSlope;
pub use liquidation_features::{LiquidationFeatures, LiquidationFeaturesOutput};
pub use long_legged_doji::LongLeggedDoji;
pub use long_line::LongLine;
pub use long_short_ratio::LongShortRatio;
pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput};
pub use macd::{MacdIndicator, MacdOutput};
@@ -418,6 +425,7 @@ pub use market_facilitation_index::MarketFacilitationIndex;
pub use marubozu::Marubozu;
pub use mass_index::MassIndex;
pub use mat_hold::MatHold;
pub use matching_low::MatchingLow;
pub use max_drawdown::MaxDrawdown;
pub use mcginley_dynamic::McGinleyDynamic;
pub use median_absolute_deviation::MedianAbsoluteDeviation;
@@ -460,6 +468,7 @@ pub use recovery_factor::RecoveryFactor;
pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
pub use renko_trailing_stop::RenkoTrailingStop;
pub use rickshaw_man::RickshawMan;
pub use rising_three_methods::RisingThreeMethods;
pub use roc::Roc;
pub use rogers_satchell::RogersSatchellVolatility;
pub use roofing_filter::RoofingFilter;
@@ -470,6 +479,7 @@ pub use rwi::{Rwi, RwiOutput};
pub use separating_lines::SeparatingLines;
pub use sharpe_ratio::SharpeRatio;
pub use shooting_star::ShootingStar;
pub use short_line::ShortLine;
pub use signed_volume::SignedVolume;
pub use sine_wave::SineWave;
pub use skewness::Skewness;
@@ -845,6 +855,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"KickingByLength",
"LadderBottom",
"MatHold",
"MatchingLow",
"LongLine",
"ShortLine",
"RisingThreeMethods",
"FallingThreeMethods",
],
),
(
@@ -936,6 +951,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, 269, "FAMILIES total drifted from indicator count");
assert_eq!(total, 274, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,213 @@
//! Rising Three Methods candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Rising Three Methods — a 5-bar bullish continuation. A long white candle is
/// followed by three small bars that drift back but stay inside its range (a brief
/// rest), then a second long white candle closes above the first, resuming the
/// advance.
///
/// ```text
/// long body = |close open| >= 0.5 * (high low)
/// bar1 white & long
/// bar2, bar3, bar4 small bodies, each contained within bar1's high/low range
/// bar5 white, closing above bar1's close
/// ```
///
/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Rising Three
/// Methods 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. 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, RisingThreeMethods};
///
/// let mut indicator = RisingThreeMethods::new();
/// indicator.update(Candle::new(10.0, 15.1, 9.9, 15.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(14.0, 14.1, 12.9, 13.0, 1.0, 1).unwrap());
/// indicator.update(Candle::new(13.5, 13.6, 12.4, 12.5, 1.0, 2).unwrap());
/// indicator.update(Candle::new(13.0, 13.1, 11.9, 12.0, 1.0, 3).unwrap());
/// let out = indicator
/// .update(Candle::new(12.5, 16.1, 12.4, 16.0, 1.0, 4).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct RisingThreeMethods {
c1: Option<Candle>,
c2: Option<Candle>,
c3: Option<Candle>,
c4: Option<Candle>,
has_emitted: bool,
}
impl RisingThreeMethods {
/// Construct a new Rising Three Methods detector.
pub const fn new() -> Self {
Self {
c1: None,
c2: None,
c3: None,
c4: None,
has_emitted: false,
}
}
}
impl Indicator for RisingThreeMethods {
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
}
// The three middle bars stay within bar1's range with smaller bodies.
for mid in [bar2, bar3, bar4] {
if (mid.close - mid.open).abs() >= body1 || mid.high > bar1.high || mid.low < bar1.low {
return Some(0.0);
}
}
// bar5 is a white candle closing above bar1's close.
if candle.close > candle.open && candle.close > bar1.close {
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 {
"RisingThreeMethods"
}
}
#[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 = RisingThreeMethods::new();
assert_eq!(t.name(), "RisingThreeMethods");
assert_eq!(t.warmup_period(), 5);
assert!(!t.is_ready());
}
#[test]
fn rising_three_methods_is_plus_one() {
let mut t = RisingThreeMethods::new();
assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(14.0, 14.1, 12.9, 13.0, 1)), Some(0.0));
assert_eq!(t.update(c(13.5, 13.6, 12.4, 12.5, 2)), Some(0.0));
assert_eq!(t.update(c(13.0, 13.1, 11.9, 12.0, 3)), Some(0.0));
assert_eq!(t.update(c(12.5, 16.1, 12.4, 16.0, 4)), Some(1.0));
}
#[test]
fn middle_bar_breaks_range_yields_zero() {
let mut t = RisingThreeMethods::new();
t.update(c(10.0, 15.1, 9.9, 15.0, 0));
t.update(c(14.0, 14.1, 12.9, 13.0, 1));
// bar3 pokes above bar1's high.
t.update(c(13.5, 16.0, 12.4, 12.5, 2));
t.update(c(13.0, 13.1, 11.9, 12.0, 3));
assert_eq!(t.update(c(12.5, 16.1, 12.4, 16.0, 4)), Some(0.0));
}
#[test]
fn bar5_not_new_high_yields_zero() {
let mut t = RisingThreeMethods::new();
t.update(c(10.0, 15.1, 9.9, 15.0, 0));
t.update(c(14.0, 14.1, 12.9, 13.0, 1));
t.update(c(13.5, 13.6, 12.4, 12.5, 2));
t.update(c(13.0, 13.1, 11.9, 12.0, 3));
// bar5 white but closes below bar1's close.
assert_eq!(t.update(c(12.5, 14.6, 12.4, 14.5, 4)), Some(0.0));
}
#[test]
fn first_four_bars_return_zero() {
let mut t = RisingThreeMethods::new();
assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(14.0, 14.1, 12.9, 13.0, 1)), Some(0.0));
assert_eq!(t.update(c(13.5, 13.6, 12.4, 12.5, 2)), Some(0.0));
assert_eq!(t.update(c(13.0, 13.1, 11.9, 12.0, 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 = RisingThreeMethods::new();
let mut b = RisingThreeMethods::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = RisingThreeMethods::new();
t.update(c(10.0, 15.1, 9.9, 15.0, 0));
t.update(c(14.0, 14.1, 12.9, 13.0, 1));
t.update(c(13.5, 13.6, 12.4, 12.5, 2));
t.update(c(13.0, 13.1, 11.9, 12.0, 3));
t.update(c(12.5, 16.1, 12.4, 16.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));
}
}
@@ -0,0 +1,228 @@
//! Short Line candlestick pattern.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
use std::collections::VecDeque;
/// Short Line — a single candle whose range is *shorter* than the recent average
/// while its body still dominates that (small) range: a compact directional bar.
/// As with [`LongLine`](crate::LongLine), "short" only has meaning relative to
/// recent activity, so the detector compares each candle's range against a rolling
/// average of the previous `period` ranges.
///
/// ```text
/// avg = mean range of the previous `period` candles
/// short line = range < avg AND |close open| >= 0.5 * range
/// white -> +1.0, black -> 1.0
/// ```
///
/// Output is `+1.0` (short white line), `1.0` (short black line), or `0.0`
/// otherwise. The first `period` candles return `0.0` while the rolling average
/// fills. `period` defaults to `5` and must be at least `1`. 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, ShortLine};
///
/// let mut indicator = ShortLine::new();
/// // Five wide bars fill the rolling average.
/// for ts in 0..5 {
/// indicator.update(Candle::new(10.0, 13.0, 9.5, 12.9, 1.0, ts).unwrap());
/// }
/// // A compact solid white bar is a short white line.
/// let out = indicator
/// .update(Candle::new(10.0, 11.0, 9.9, 10.9, 1.0, 5).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone)]
pub struct ShortLine {
period: usize,
ranges: VecDeque<f64>,
}
impl Default for ShortLine {
fn default() -> Self {
Self::new()
}
}
impl ShortLine {
/// Construct a Short Line detector with the default 5-candle rolling average.
pub const fn new() -> Self {
Self {
period: 5,
ranges: VecDeque::new(),
}
}
/// Construct a Short Line detector with a custom averaging period.
///
/// `period` must be at least `1`.
pub fn with_period(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
ranges: VecDeque::new(),
})
}
/// Configured averaging period.
pub fn period(&self) -> usize {
self.period
}
}
impl Indicator for ShortLine {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let range = candle.high - candle.low;
let body = candle.close - candle.open;
if self.ranges.len() < self.period {
self.ranges.push_back(range);
return Some(0.0);
}
let avg = self.ranges.iter().sum::<f64>() / self.period as f64;
self.ranges.push_back(range);
self.ranges.pop_front();
if range < avg && body.abs() >= 0.5 * range {
return Some(if body > 0.0 { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.ranges.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.ranges.len() >= self.period
}
fn name(&self) -> &'static str {
"ShortLine"
}
}
#[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()
}
fn warm(t: &mut ShortLine) {
for ts in 0..5 {
assert_eq!(t.update(c(10.0, 13.0, 9.5, 12.9, ts)), Some(0.0));
}
}
#[test]
fn rejects_zero_period() {
assert!(ShortLine::with_period(0).is_err());
}
#[test]
fn accepts_valid_period() {
let t = ShortLine::with_period(10).unwrap();
assert_eq!(t.period(), 10);
}
#[test]
fn accessors_and_metadata() {
let t = ShortLine::new();
assert_eq!(t.name(), "ShortLine");
assert_eq!(t.warmup_period(), 5);
assert!(!t.is_ready());
assert_eq!(t.period(), 5);
}
#[test]
fn short_white_line_is_plus_one() {
let mut t = ShortLine::new();
warm(&mut t);
assert!(t.is_ready());
assert_eq!(t.update(c(10.0, 11.0, 9.9, 10.9, 5)), Some(1.0));
}
#[test]
fn short_black_line_is_minus_one() {
let mut t = ShortLine::new();
warm(&mut t);
assert_eq!(t.update(c(10.9, 11.0, 9.9, 10.0, 5)), Some(-1.0));
}
#[test]
fn wide_range_yields_zero() {
let mut t = ShortLine::new();
warm(&mut t);
// Range as wide as the average -> not a short line.
assert_eq!(t.update(c(10.0, 13.0, 9.5, 12.9, 5)), Some(0.0));
}
#[test]
fn short_range_small_body_yields_zero() {
let mut t = ShortLine::new();
warm(&mut t);
// Compact range but a tiny body -> not a solid short line.
assert_eq!(t.update(c(10.4, 11.0, 9.9, 10.5, 5)), Some(0.0));
}
#[test]
fn warmup_returns_zero() {
let mut t = ShortLine::new();
for ts in 0..5 {
assert_eq!(t.update(c(10.0, 11.0, 9.9, 10.9, ts)), Some(0.0));
}
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
if i % 7 == 0 {
c(base, base + 0.6, base - 0.1, base + 0.5, i)
} else {
c(base, base + 3.0, base - 1.0, base + 2.8, i)
}
})
.collect();
let mut a = ShortLine::new();
let mut b = ShortLine::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = ShortLine::new();
warm(&mut t);
t.update(c(10.0, 11.0, 9.9, 10.9, 5));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(10.0, 11.0, 9.9, 10.9, 0)), Some(0.0));
}
}