feat: TA-Lib candlestick patterns — Doji family (part 3 of 9) (#134)

* feat: add Doji-family candlestick patterns

Five single-/two-bar Doji patterns, all `Input = Candle`, `Output = f64`:

- Doji Star (CDLDOJISTAR) — a long body followed by a doji gapping away in the
  trend direction; bullish +1 (after a black bar), bearish -1 (after a white bar).
- Dragonfly Doji (CDLDRAGONFLYDOJI) — a doji opening and closing at the high with
  a long lower shadow; bullish +1.
- Gravestone Doji (CDLGRAVESTONEDOJI) — a doji opening and closing at the low with
  a long upper shadow; bearish -1.
- Long-Legged Doji (CDLLONGLEGGEDDOJI) — a doji with long shadows on both sides; a
  non-directional indecision flag, +1 on detection.
- Rickshaw Man (CDLRICKSHAWMAN) — a long-legged doji with the body centred in the
  range; a non-directional indecision flag, +1 on detection.

Counter 254 -> 259 (mod-count == lib counted block; FAMILIES total 249 -> 254).

* chore: sync indicator count to 259

---------

Co-authored-by: wickra-bot <wickra-bot@users.noreply.github.com>
This commit is contained in:
kingchenc
2026-06-02 16:45:08 +02:00
committed by GitHub
co-authored by wickra-bot
parent 03ceac1f3b
commit 244d754707
19 changed files with 1051 additions and 39 deletions
@@ -0,0 +1,205 @@
//! Doji Star candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Doji Star — a 2-bar reversal warning. A long trending body is followed by a
/// doji whose tiny body gaps away in the direction of the trend, the indecision
/// hinting the move is about to turn.
///
/// ```text
/// long body = |close open| >= 0.5 * (high low) (bar1)
/// doji = |close open| <= 0.1 * (high low) (bar2)
/// bullish (+1.0): bar1 black, doji body gaps DOWN below it (max(o2,c2) < close1)
/// bearish (1.0): bar1 white, doji body gaps UP above it (min(o2,c2) > close1)
/// ```
///
/// Output is `+1.0` (bullish star, after a black bar) or `1.0` (bearish star,
/// after a white bar) when the pattern completes, and `0.0` otherwise. The first
/// bar always returns `0.0` because the two-bar window is not yet filled. Doji
/// thresholds follow the geometric house style (fixed half-range body for the
/// long bar, tenth-range body for the doji) 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 bullish and
/// bearish variants occupy a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, DojiStar, Indicator};
///
/// let mut indicator = DojiStar::new();
/// // Long black bar, then a doji gapping down -> bullish star.
/// indicator.update(Candle::new(20.0, 20.2, 14.8, 15.0, 1.0, 0).unwrap());
/// let out = indicator
/// .update(Candle::new(13.0, 13.1, 12.9, 13.0, 1.0, 1).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct DojiStar {
prev: Option<Candle>,
has_emitted: bool,
}
impl DojiStar {
/// Construct a new Doji Star detector.
pub const fn new() -> Self {
Self {
prev: None,
has_emitted: false,
}
}
}
impl Indicator for DojiStar {
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);
}
let body1 = bar1.close - bar1.open;
if body1.abs() < 0.5 * range1 {
return Some(0.0);
}
if (candle.close - candle.open).abs() > 0.1 * range2 {
return Some(0.0);
}
let doji_top = candle.open.max(candle.close);
let doji_bottom = candle.open.min(candle.close);
// Bullish: long black bar, doji body gaps down below it.
if body1 < 0.0 && doji_top < bar1.close {
return Some(1.0);
}
// Bearish: long white bar, doji body gaps up above it.
if body1 > 0.0 && doji_bottom > bar1.close {
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 {
"DojiStar"
}
}
#[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 = DojiStar::new();
assert_eq!(t.name(), "DojiStar");
assert_eq!(t.warmup_period(), 2);
assert!(!t.is_ready());
}
#[test]
fn bullish_doji_star_is_plus_one() {
let mut t = DojiStar::new();
assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(13.0, 13.1, 12.9, 13.0, 1)), Some(1.0));
}
#[test]
fn bearish_doji_star_is_minus_one() {
let mut t = DojiStar::new();
assert_eq!(t.update(c(15.0, 20.2, 14.8, 20.0, 0)), Some(0.0));
assert_eq!(t.update(c(22.0, 22.1, 21.9, 22.0, 1)), Some(-1.0));
}
#[test]
fn second_bar_not_doji_yields_zero() {
let mut t = DojiStar::new();
t.update(c(20.0, 20.2, 14.8, 15.0, 0));
// Wide body, not a doji.
assert_eq!(t.update(c(13.0, 13.2, 11.0, 11.5, 1)), Some(0.0));
}
#[test]
fn no_gap_yields_zero() {
let mut t = DojiStar::new();
t.update(c(20.0, 20.2, 14.8, 15.0, 0));
// Doji overlaps bar1's body (no gap down).
assert_eq!(t.update(c(16.0, 16.1, 15.9, 16.0, 1)), Some(0.0));
}
#[test]
fn short_first_body_yields_zero() {
let mut t = DojiStar::new();
// First bar body too short to be the "long" leg.
t.update(c(20.0, 24.0, 16.0, 19.5, 0));
assert_eq!(t.update(c(13.0, 13.1, 12.9, 13.0, 1)), Some(0.0));
}
#[test]
fn first_bar_returns_zero() {
let mut t = DojiStar::new();
assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.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;
if i % 2 == 0 {
c(base + 5.0, base + 5.2, base - 0.2, base, i)
} else {
c(base - 3.0, base - 2.9, base - 3.1, base - 3.0, i)
}
})
.collect();
let mut a = DojiStar::new();
let mut b = DojiStar::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = DojiStar::new();
t.update(c(20.0, 20.2, 14.8, 15.0, 0));
t.update(c(13.0, 13.1, 12.9, 13.0, 1));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), Some(0.0));
}
}
@@ -0,0 +1,163 @@
//! Dragonfly Doji candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Dragonfly Doji — a single-bar bullish reversal. Open, close, and high sit at
/// the top of the bar while a long lower shadow shows price was driven down hard
/// and then bid all the way back to the open — buyers rejecting the lows.
///
/// ```text
/// range = high low
/// doji = |close open| <= 0.1 * range
/// no upper wick = high max(open, close) <= 0.1 * range
/// long lower = min(open, close) low >= 0.5 * range
/// ```
///
/// Output is `+1.0` when the dragonfly prints and `0.0` otherwise. Dragonfly Doji
/// is a single-direction (bullish-only) shape, so it never emits `1.0`. Body and
/// shadow thresholds follow the geometric house style (fixed fractions of the bar
/// range) 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, DragonflyDoji, Indicator};
///
/// let mut indicator = DragonflyDoji::new();
/// // Body at the top, long lower shadow.
/// let candle = Candle::new(10.0, 10.05, 6.0, 10.0, 1.0, 0).unwrap();
/// assert_eq!(indicator.update(candle), Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct DragonflyDoji {
has_emitted: bool,
}
impl DragonflyDoji {
/// Construct a new Dragonfly Doji detector.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for DragonflyDoji {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let range = candle.high - candle.low;
if range <= 0.0 {
return Some(0.0);
}
if (candle.close - candle.open).abs() > 0.1 * range {
return Some(0.0);
}
let upper = candle.high - candle.open.max(candle.close);
let lower = candle.open.min(candle.close) - candle.low;
if upper <= 0.1 * range && lower >= 0.5 * range {
return Some(1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"DragonflyDoji"
}
}
#[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 = DragonflyDoji::new();
assert_eq!(t.name(), "DragonflyDoji");
assert_eq!(t.warmup_period(), 1);
assert!(!t.is_ready());
}
#[test]
fn dragonfly_is_plus_one() {
let mut t = DragonflyDoji::new();
assert_eq!(t.update(c(10.0, 10.05, 6.0, 10.0, 0)), Some(1.0));
}
#[test]
fn upper_shadow_yields_zero() {
let mut t = DragonflyDoji::new();
// Long upper shadow -> not a dragonfly (this is a gravestone shape).
assert_eq!(t.update(c(10.0, 14.0, 9.95, 10.0, 0)), Some(0.0));
}
#[test]
fn short_lower_shadow_yields_zero() {
let mut t = DragonflyDoji::new();
// Body at the top but the lower shadow is too short.
assert_eq!(t.update(c(10.0, 10.05, 9.6, 10.0, 0)), Some(0.0));
}
#[test]
fn non_doji_yields_zero() {
let mut t = DragonflyDoji::new();
assert_eq!(t.update(c(10.0, 12.0, 6.0, 11.5, 0)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut t = DragonflyDoji::new();
assert_eq!(t.update(c(10.0, 10.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;
c(base, base + 0.05, base - 4.0, base, i)
})
.collect();
let mut a = DragonflyDoji::new();
let mut b = DragonflyDoji::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = DragonflyDoji::new();
t.update(c(10.0, 10.05, 6.0, 10.0, 0));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
}
}
@@ -0,0 +1,163 @@
//! Gravestone Doji candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Gravestone Doji — a single-bar bearish reversal. Open, close, and low sit at
/// the bottom of the bar while a long upper shadow shows price was pushed up hard
/// and then sold all the way back to the open — sellers rejecting the highs.
///
/// ```text
/// range = high low
/// doji = |close open| <= 0.1 * range
/// no lower wick = min(open, close) low <= 0.1 * range
/// long upper = high max(open, close) >= 0.5 * range
/// ```
///
/// Output is `1.0` when the gravestone prints and `0.0` otherwise. Gravestone
/// Doji is a single-direction (bearish-only) shape, so it never emits `+1.0`.
/// Body and shadow thresholds follow the geometric house style (fixed fractions
/// of the bar range) 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, GravestoneDoji, Indicator};
///
/// let mut indicator = GravestoneDoji::new();
/// // Body at the bottom, long upper shadow.
/// let candle = Candle::new(10.0, 14.0, 9.95, 10.0, 1.0, 0).unwrap();
/// assert_eq!(indicator.update(candle), Some(-1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct GravestoneDoji {
has_emitted: bool,
}
impl GravestoneDoji {
/// Construct a new Gravestone Doji detector.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for GravestoneDoji {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let range = candle.high - candle.low;
if range <= 0.0 {
return Some(0.0);
}
if (candle.close - candle.open).abs() > 0.1 * range {
return Some(0.0);
}
let upper = candle.high - candle.open.max(candle.close);
let lower = candle.open.min(candle.close) - candle.low;
if lower <= 0.1 * range && upper >= 0.5 * range {
return Some(-1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"GravestoneDoji"
}
}
#[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 = GravestoneDoji::new();
assert_eq!(t.name(), "GravestoneDoji");
assert_eq!(t.warmup_period(), 1);
assert!(!t.is_ready());
}
#[test]
fn gravestone_is_minus_one() {
let mut t = GravestoneDoji::new();
assert_eq!(t.update(c(10.0, 14.0, 9.95, 10.0, 0)), Some(-1.0));
}
#[test]
fn lower_shadow_yields_zero() {
let mut t = GravestoneDoji::new();
// Long lower shadow -> not a gravestone (this is a dragonfly shape).
assert_eq!(t.update(c(10.0, 10.05, 6.0, 10.0, 0)), Some(0.0));
}
#[test]
fn short_upper_shadow_yields_zero() {
let mut t = GravestoneDoji::new();
// Body at the bottom but the upper shadow is too short.
assert_eq!(t.update(c(10.0, 10.4, 9.95, 10.0, 0)), Some(0.0));
}
#[test]
fn non_doji_yields_zero() {
let mut t = GravestoneDoji::new();
assert_eq!(t.update(c(10.0, 14.0, 9.5, 13.5, 0)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut t = GravestoneDoji::new();
assert_eq!(t.update(c(10.0, 10.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;
c(base, base + 4.0, base - 0.05, base, i)
})
.collect();
let mut a = GravestoneDoji::new();
let mut b = GravestoneDoji::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = GravestoneDoji::new();
t.update(c(10.0, 14.0, 9.95, 10.0, 0));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
}
}
@@ -0,0 +1,157 @@
//! Long-Legged Doji candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Long-Legged Doji — a single-bar indecision signal. A doji with long shadows on
/// *both* sides: price ranged widely up and down yet closed essentially where it
/// opened, a tug-of-war that often precedes a turn.
///
/// ```text
/// range = high low
/// doji = |close open| <= 0.1 * range
/// long upper = high max(open, close) >= 0.3 * range
/// long lower = min(open, close) low >= 0.3 * range
/// ```
///
/// Output is `+1.0` when the long-legged doji prints and `0.0` otherwise. This is
/// a non-directional indecision flag — it never emits `1.0` (use
/// `DragonflyDoji` / `GravestoneDoji` for the directional single-shadow variants).
/// Body and shadow thresholds follow the geometric house style (fixed fractions
/// of the bar range) 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` detected, `0.0` no pattern — so it drops straight into
/// a machine-learning feature matrix as a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, LongLeggedDoji, Indicator};
///
/// let mut indicator = LongLeggedDoji::new();
/// // Tiny body, long shadows on both sides.
/// let candle = Candle::new(10.0, 12.0, 8.0, 10.05, 1.0, 0).unwrap();
/// assert_eq!(indicator.update(candle), Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct LongLeggedDoji {
has_emitted: bool,
}
impl LongLeggedDoji {
/// Construct a new Long-Legged Doji detector.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for LongLeggedDoji {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let range = candle.high - candle.low;
if range <= 0.0 {
return Some(0.0);
}
if (candle.close - candle.open).abs() > 0.1 * range {
return Some(0.0);
}
let upper = candle.high - candle.open.max(candle.close);
let lower = candle.open.min(candle.close) - candle.low;
if upper >= 0.3 * range && lower >= 0.3 * range {
return Some(1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"LongLeggedDoji"
}
}
#[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 = LongLeggedDoji::new();
assert_eq!(t.name(), "LongLeggedDoji");
assert_eq!(t.warmup_period(), 1);
assert!(!t.is_ready());
}
#[test]
fn long_legged_is_plus_one() {
let mut t = LongLeggedDoji::new();
assert_eq!(t.update(c(10.0, 12.0, 8.0, 10.05, 0)), Some(1.0));
}
#[test]
fn one_sided_shadow_yields_zero() {
let mut t = LongLeggedDoji::new();
// Dragonfly shape: long lower shadow but no upper -> not long-legged.
assert_eq!(t.update(c(10.0, 10.05, 6.0, 10.0, 0)), Some(0.0));
}
#[test]
fn non_doji_yields_zero() {
let mut t = LongLeggedDoji::new();
assert_eq!(t.update(c(10.0, 12.0, 8.0, 11.5, 0)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut t = LongLeggedDoji::new();
assert_eq!(t.update(c(10.0, 10.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;
c(base, base + 3.0, base - 3.0, base + 0.05, i)
})
.collect();
let mut a = LongLeggedDoji::new();
let mut b = LongLeggedDoji::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = LongLeggedDoji::new();
t.update(c(10.0, 12.0, 8.0, 10.05, 0));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
}
}
+16 -1
View File
@@ -63,10 +63,12 @@ mod demark_pivots;
mod depth_slope;
mod detrended_std_dev;
mod doji;
mod doji_star;
mod donchian;
mod donchian_stop;
mod double_bollinger;
mod dpo;
mod dragonfly_doji;
mod drawdown_duration;
mod ease_of_movement;
mod effective_spread;
@@ -89,6 +91,7 @@ mod funding_rate_mean;
mod funding_rate_zscore;
mod gain_loss_ratio;
mod garman_klass;
mod gravestone_doji;
mod hammer;
mod hanging_man;
mod harami;
@@ -122,6 +125,7 @@ mod linreg_angle;
mod linreg_channel;
mod linreg_slope;
mod liquidation_features;
mod long_legged_doji;
mod long_short_ratio;
mod ma_envelope;
mod macd;
@@ -168,6 +172,7 @@ mod realized_spread;
mod recovery_factor;
mod relative_strength_ab;
mod renko_trailing_stop;
mod rickshaw_man;
mod roc;
mod rogers_satchell;
mod roofing_filter;
@@ -317,10 +322,12 @@ pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput};
pub use depth_slope::DepthSlope;
pub use detrended_std_dev::DetrendedStdDev;
pub use doji::Doji;
pub use doji_star::DojiStar;
pub use donchian::{Donchian, DonchianOutput};
pub use donchian_stop::{DonchianStop, DonchianStopOutput};
pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput};
pub use dpo::Dpo;
pub use dragonfly_doji::DragonflyDoji;
pub use drawdown_duration::DrawdownDuration;
pub use ease_of_movement::EaseOfMovement;
pub use effective_spread::EffectiveSpread;
@@ -343,6 +350,7 @@ pub use funding_rate_mean::FundingRateMean;
pub use funding_rate_zscore::FundingRateZScore;
pub use gain_loss_ratio::GainLossRatio;
pub use garman_klass::GarmanKlassVolatility;
pub use gravestone_doji::GravestoneDoji;
pub use hammer::Hammer;
pub use hanging_man::HangingMan;
pub use harami::Harami;
@@ -376,6 +384,7 @@ pub use linreg_angle::LinRegAngle;
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_short_ratio::LongShortRatio;
pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput};
pub use macd::{MacdIndicator, MacdOutput};
@@ -422,6 +431,7 @@ pub use realized_spread::RealizedSpread;
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 roc::Roc;
pub use rogers_satchell::RogersSatchellVolatility;
pub use roofing_filter::RoofingFilter;
@@ -785,6 +795,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"BeltHold",
"Breakaway",
"Counterattack",
"DojiStar",
"DragonflyDoji",
"GravestoneDoji",
"LongLeggedDoji",
"RickshawMan",
],
),
(
@@ -876,6 +891,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, 249, "FAMILIES total drifted from indicator count");
assert_eq!(total, 254, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,167 @@
//! Rickshaw Man candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Rickshaw Man — a single-bar indecision signal. A long-legged doji whose tiny
/// body sits near the *middle* of a wide range, the most balanced form of
/// indecision: neither side controlled the close and the midpoint pins it.
///
/// ```text
/// range = high low
/// doji = |close open| <= 0.1 * range
/// long upper = high max(open, close) >= 0.3 * range
/// long lower = min(open, close) low >= 0.3 * range
/// centred body = body midpoint within the central 4060 % of the range
/// ```
///
/// Output is `+1.0` when the rickshaw man prints and `0.0` otherwise. This is a
/// non-directional indecision flag — it never emits `1.0`. A rickshaw man is a
/// special case of a long-legged doji (the body additionally sits at the centre),
/// so both detectors may flag the same bar. Body and shadow thresholds follow the
/// geometric house style (fixed fractions of the bar range) 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` detected, `0.0` no pattern — so it drops straight into
/// a machine-learning feature matrix as a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, RickshawMan};
///
/// let mut indicator = RickshawMan::new();
/// // Tiny body centred in a wide range, long shadows both sides.
/// let candle = Candle::new(10.0, 12.0, 8.0, 10.0, 1.0, 0).unwrap();
/// assert_eq!(indicator.update(candle), Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct RickshawMan {
has_emitted: bool,
}
impl RickshawMan {
/// Construct a new Rickshaw Man detector.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for RickshawMan {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let range = candle.high - candle.low;
if range <= 0.0 {
return Some(0.0);
}
if (candle.close - candle.open).abs() > 0.1 * range {
return Some(0.0);
}
let upper = candle.high - candle.open.max(candle.close);
let lower = candle.open.min(candle.close) - candle.low;
let body_mid = 0.5 * (candle.open + candle.close);
let pos = (body_mid - candle.low) / range;
if upper >= 0.3 * range && lower >= 0.3 * range && (0.4..=0.6).contains(&pos) {
return Some(1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"RickshawMan"
}
}
#[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 = RickshawMan::new();
assert_eq!(t.name(), "RickshawMan");
assert_eq!(t.warmup_period(), 1);
assert!(!t.is_ready());
}
#[test]
fn rickshaw_is_plus_one() {
let mut t = RickshawMan::new();
assert_eq!(t.update(c(10.0, 12.0, 8.0, 10.0, 0)), Some(1.0));
}
#[test]
fn off_centre_body_yields_zero() {
let mut t = RickshawMan::new();
// Long-legged but the body sits near the top, not the middle.
assert_eq!(t.update(c(11.4, 12.0, 8.0, 11.45, 0)), Some(0.0));
}
#[test]
fn one_sided_shadow_yields_zero() {
let mut t = RickshawMan::new();
// Dragonfly shape: no upper shadow -> not a rickshaw man.
assert_eq!(t.update(c(10.0, 10.05, 6.0, 10.0, 0)), Some(0.0));
}
#[test]
fn non_doji_yields_zero() {
let mut t = RickshawMan::new();
assert_eq!(t.update(c(9.0, 12.0, 8.0, 11.0, 0)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut t = RickshawMan::new();
assert_eq!(t.update(c(10.0, 10.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;
c(base, base + 2.0, base - 2.0, base + 0.05, i)
})
.collect();
let mut a = RickshawMan::new();
let mut b = RickshawMan::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = RickshawMan::new();
t.update(c(10.0, 12.0, 8.0, 10.0, 0));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
}
}