feat: TA-Lib candlestick patterns — gap-three-methods/stalled/stick-sandwich/takuri (part 8 of 9) (#140)

Adds five TA-Lib candlestick patterns, each a streaming `Indicator<Input = Candle, Output = f64>` emitting the family's uniform `±1.0 / 0.0` sign convention, fully wired across the Rust core, Python / Node / WASM bindings, fuzz target and reference tests.

- **Upside Gap Three Methods** (`CDLXSIDEGAP3METHODS`) — a 3-bar bullish continuation: two white candles gap up, then a black candle opens within the second body and closes within the first; bullish +1.
- **Downside Gap Three Methods** (`CDLXSIDEGAP3METHODS`) — the bearish mirror: two black candles gap down, then a white candle opens within the second body and closes within the first; bearish -1.
- **Stalled Pattern** (`CDLSTALLEDPATTERN`) — a 3-bar bearish reversal warning: two long white candles then a small white candle riding the shoulder, signalling the rally is stalling; bearish -1.
- **Stick Sandwich** (`CDLSTICKSANDWICH`) — a 3-bar bullish reversal: two black candles closing at the same level sandwich a white candle, marking a support floor; bullish +1.
- **Takuri** (`CDLTAKURI`) — a single-bar bullish reversal, a strict Dragonfly Doji with a negligible upper shadow and very long lower shadow; bullish +1.

Body and shadow thresholds follow the geometric house style (fixed fractions of the bar range) rather than TA-Lib's rolling averages. Upside / Downside Gap Three Methods share the `CDLXSIDEGAP3METHODS` code, so the second carries a manual CHANGELOG entry (as with Rising / Falling Three Methods).

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

Stacked on #139 (`feat/cdl-lines`); base retargets to `main` once the predecessor merges.
This commit is contained in:
kingchenc
2026-06-02 17:24:42 +02:00
committed by GitHub
parent c2c85c7ecf
commit 4d0bc08efd
19 changed files with 1262 additions and 36 deletions
@@ -0,0 +1,210 @@
//! Downside Gap Three Methods candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Downside Gap Three Methods — a 3-bar bearish continuation. Two black candles
/// decline with a downside body gap between them, then a white candle opens inside
/// the second body and closes inside the first body, partially filling the gap
/// without erasing the prior decline.
///
/// ```text
/// bar1 black, bar2 black
/// downside body gap: open2 < close1 (bar2's body sits entirely below bar1's)
/// bar3 white, opens within bar2's body and closes within bar1's body
/// ```
///
/// Output is `1.0` when the pattern completes and `0.0` otherwise. Downside Gap
/// Three Methods is a single-direction (bearish-only) continuation, so it never
/// emits `+1.0`; its bullish mirror is [`crate::UpsideGapThreeMethods`]. The first
/// two bars always return `0.0` because the three-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` bearish, `0.0` no pattern — so it drops straight into
/// a machine-learning feature matrix as a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, DownsideGapThreeMethods, Indicator};
///
/// let mut indicator = DownsideGapThreeMethods::new();
/// indicator.update(Candle::new(13.0, 13.2, 11.8, 12.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(11.0, 11.1, 9.8, 10.0, 1.0, 1).unwrap());
/// let out = indicator
/// .update(Candle::new(10.5, 12.6, 10.4, 12.5, 1.0, 2).unwrap());
/// assert_eq!(out, Some(-1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct DownsideGapThreeMethods {
c1: Option<Candle>,
c2: Option<Candle>,
has_emitted: bool,
}
impl DownsideGapThreeMethods {
/// Construct a new Downside Gap Three Methods detector.
pub const fn new() -> Self {
Self {
c1: None,
c2: None,
has_emitted: false,
}
}
}
impl Indicator for DownsideGapThreeMethods {
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;
self.c1 = self.c2;
self.c2 = Some(candle);
let (Some(bar1), Some(bar2)) = (bar1, bar2) else {
return Some(0.0);
};
// bar1 and bar2 are both black.
if bar1.close >= bar1.open || bar2.close >= bar2.open {
return Some(0.0);
}
// Downside body gap: bar2's body sits entirely below bar1's.
if bar2.open >= bar1.close {
return Some(0.0);
}
// bar3 is white.
if candle.close <= candle.open {
return Some(0.0);
}
// bar3 opens within bar2's body and closes within bar1's body.
if candle.open > bar2.close
&& candle.open < bar2.open
&& candle.close > bar1.close
&& candle.close < bar1.open
{
return Some(-1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.c1 = None;
self.c2 = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"DownsideGapThreeMethods"
}
}
#[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 = DownsideGapThreeMethods::new();
assert_eq!(t.name(), "DownsideGapThreeMethods");
assert_eq!(t.warmup_period(), 3);
assert!(!t.is_ready());
}
#[test]
fn downside_gap_three_methods_is_minus_one() {
let mut t = DownsideGapThreeMethods::new();
assert_eq!(t.update(c(13.0, 13.2, 11.8, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 11.1, 9.8, 10.0, 1)), Some(0.0));
assert_eq!(t.update(c(10.5, 12.6, 10.4, 12.5, 2)), Some(-1.0));
}
#[test]
fn first_two_bars_return_zero() {
let mut t = DownsideGapThreeMethods::new();
assert_eq!(t.update(c(13.0, 13.2, 11.8, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 11.1, 9.8, 10.0, 1)), Some(0.0));
}
#[test]
fn non_black_first_bars_yield_zero() {
let mut t = DownsideGapThreeMethods::new();
// bar1 is white.
t.update(c(11.0, 13.2, 10.8, 13.0, 0));
t.update(c(11.0, 11.1, 9.8, 10.0, 1));
assert_eq!(t.update(c(10.5, 12.6, 10.4, 12.5, 2)), Some(0.0));
}
#[test]
fn no_gap_yields_zero() {
let mut t = DownsideGapThreeMethods::new();
t.update(c(13.0, 13.2, 11.8, 12.0, 0));
// bar2 opens above bar1's close -> no downside body gap.
t.update(c(12.5, 12.6, 11.4, 11.5, 1));
assert_eq!(t.update(c(11.5, 12.6, 11.4, 12.0, 2)), Some(0.0));
}
#[test]
fn third_bar_not_white_yields_zero() {
let mut t = DownsideGapThreeMethods::new();
t.update(c(13.0, 13.2, 11.8, 12.0, 0));
t.update(c(11.0, 11.1, 9.8, 10.0, 1));
// bar3 black.
assert_eq!(t.update(c(12.5, 12.6, 10.4, 10.5, 2)), Some(0.0));
}
#[test]
fn third_bar_outside_bodies_yields_zero() {
let mut t = DownsideGapThreeMethods::new();
t.update(c(13.0, 13.2, 11.8, 12.0, 0));
t.update(c(11.0, 11.1, 9.8, 10.0, 1));
// bar3 white but closes above bar1's body.
assert_eq!(t.update(c(10.5, 14.0, 10.4, 13.5, 2)), 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 - 5.2, base - 5.0, i)
})
.collect();
let mut a = DownsideGapThreeMethods::new();
let mut b = DownsideGapThreeMethods::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = DownsideGapThreeMethods::new();
t.update(c(13.0, 13.2, 11.8, 12.0, 0));
t.update(c(11.0, 11.1, 9.8, 10.0, 1));
t.update(c(10.5, 12.6, 10.4, 12.5, 2));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(13.0, 13.2, 11.8, 12.0, 0)), Some(0.0));
}
}
+16 -1
View File
@@ -67,6 +67,7 @@ mod doji_star;
mod donchian;
mod donchian_stop;
mod double_bollinger;
mod downside_gap_three_methods;
mod dpo;
mod dragonfly_doji;
mod drawdown_duration;
@@ -210,18 +211,21 @@ mod smma;
mod sortino_ratio;
mod spearman_correlation;
mod spinning_top;
mod stalled_pattern;
mod standard_error;
mod standard_error_bands;
mod starc_bands;
mod stc;
mod std_dev;
mod step_trailing_stop;
mod stick_sandwich;
mod stoch_rsi;
mod stochastic;
mod super_smoother;
mod super_trend;
mod t3;
mod taker_buy_sell_ratio;
mod takuri;
mod td_combo;
mod td_countdown;
mod td_demarker;
@@ -256,6 +260,7 @@ mod two_crows;
mod typical_price;
mod ulcer_index;
mod ultimate_oscillator;
mod upside_gap_three_methods;
mod upside_gap_two_crows;
mod value_area;
mod value_at_risk;
@@ -346,6 +351,7 @@ pub use doji_star::DojiStar;
pub use donchian::{Donchian, DonchianOutput};
pub use donchian_stop::{DonchianStop, DonchianStopOutput};
pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput};
pub use downside_gap_three_methods::DownsideGapThreeMethods;
pub use dpo::Dpo;
pub use dragonfly_doji::DragonflyDoji;
pub use drawdown_duration::DrawdownDuration;
@@ -489,18 +495,21 @@ pub use smma::Smma;
pub use sortino_ratio::SortinoRatio;
pub use spearman_correlation::SpearmanCorrelation;
pub use spinning_top::SpinningTop;
pub use stalled_pattern::StalledPattern;
pub use standard_error::StandardError;
pub use standard_error_bands::{StandardErrorBands, StandardErrorBandsOutput};
pub use starc_bands::{StarcBands, StarcBandsOutput};
pub use stc::Stc;
pub use std_dev::StdDev;
pub use step_trailing_stop::StepTrailingStop;
pub use stick_sandwich::StickSandwich;
pub use stoch_rsi::StochRsi;
pub use stochastic::{Stochastic, StochasticOutput};
pub use super_smoother::SuperSmoother;
pub use super_trend::{SuperTrend, SuperTrendOutput};
pub use t3::T3;
pub use taker_buy_sell_ratio::TakerBuySellRatio;
pub use takuri::Takuri;
pub use td_combo::TdCombo;
pub use td_countdown::TdCountdown;
pub use td_demarker::TdDeMarker;
@@ -535,6 +544,7 @@ pub use two_crows::TwoCrows;
pub use typical_price::TypicalPrice;
pub use ulcer_index::UlcerIndex;
pub use ultimate_oscillator::UltimateOscillator;
pub use upside_gap_three_methods::UpsideGapThreeMethods;
pub use upside_gap_two_crows::UpsideGapTwoCrows;
pub use value_area::{ValueArea, ValueAreaOutput};
pub use value_at_risk::ValueAtRisk;
@@ -860,6 +870,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"ShortLine",
"RisingThreeMethods",
"FallingThreeMethods",
"UpsideGapThreeMethods",
"DownsideGapThreeMethods",
"StalledPattern",
"StickSandwich",
"Takuri",
],
),
(
@@ -951,6 +966,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, 274, "FAMILIES total drifted from indicator count");
assert_eq!(total, 279, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,237 @@
//! Stalled Pattern (Deliberation) candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Stalled Pattern (also called Deliberation) — a 3-bar bearish reversal warning.
/// Two long white candles push higher, then a small-bodied white candle opens at
/// or near the top of the second body and barely advances — the rally is running
/// out of breath, hinting that buyers are losing control.
///
/// ```text
/// long body = |close open| >= 0.5 * (high low)
/// small body = |close open| <= 0.3 * (high low)
/// bar1, bar2 long white; bar3 small white
/// rising closes: close3 > close2 > close1
/// bar3 rides the shoulder: open3 >= close2 0.1 * (high2 low2)
/// ```
///
/// Output is `1.0` when the pattern completes and `0.0` otherwise. Stalled Pattern
/// is a single-direction (bearish-only) warning, so it never emits `+1.0`. The
/// first two bars always return `0.0` because the three-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, Indicator, StalledPattern};
///
/// let mut indicator = StalledPattern::new();
/// indicator.update(Candle::new(10.0, 12.05, 9.9, 12.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(11.0, 14.05, 10.9, 14.0, 1.0, 1).unwrap());
/// let out = indicator
/// .update(Candle::new(14.0, 14.6, 13.95, 14.15, 1.0, 2).unwrap());
/// assert_eq!(out, Some(-1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct StalledPattern {
c1: Option<Candle>,
c2: Option<Candle>,
has_emitted: bool,
}
impl StalledPattern {
/// Construct a new Stalled Pattern detector.
pub const fn new() -> Self {
Self {
c1: None,
c2: None,
has_emitted: false,
}
}
}
impl Indicator for StalledPattern {
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;
self.c1 = self.c2;
self.c2 = Some(candle);
let (Some(bar1), Some(bar2)) = (bar1, bar2) else {
return Some(0.0);
};
let range1 = bar1.high - bar1.low;
let range2 = bar2.high - bar2.low;
let range3 = candle.high - candle.low;
if range1 <= 0.0 || range2 <= 0.0 || range3 <= 0.0 {
return Some(0.0);
}
// All three candles are white.
if bar1.close <= bar1.open || bar2.close <= bar2.open || candle.close <= candle.open {
return Some(0.0);
}
// Rising closes.
if candle.close <= bar2.close || bar2.close <= bar1.close {
return Some(0.0);
}
// bar1 and bar2 are long bodies.
if bar1.close - bar1.open < 0.5 * range1 || bar2.close - bar2.open < 0.5 * range2 {
return Some(0.0);
}
// bar3 is a small body.
if candle.close - candle.open > 0.3 * range3 {
return Some(0.0);
}
// bar3 opens at or near the top of bar2's body (rides the shoulder).
if candle.open >= bar2.close - 0.1 * range2 {
return Some(-1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.c1 = None;
self.c2 = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"StalledPattern"
}
}
#[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 = StalledPattern::new();
assert_eq!(t.name(), "StalledPattern");
assert_eq!(t.warmup_period(), 3);
assert!(!t.is_ready());
}
#[test]
fn stalled_pattern_is_minus_one() {
let mut t = StalledPattern::new();
assert_eq!(t.update(c(10.0, 12.05, 9.9, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 14.05, 10.9, 14.0, 1)), Some(0.0));
assert_eq!(t.update(c(14.0, 14.6, 13.95, 14.15, 2)), Some(-1.0));
}
#[test]
fn first_two_bars_return_zero() {
let mut t = StalledPattern::new();
assert_eq!(t.update(c(10.0, 12.05, 9.9, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 14.05, 10.9, 14.0, 1)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut t = StalledPattern::new();
t.update(c(10.0, 12.05, 9.9, 12.0, 0));
t.update(c(11.0, 14.05, 10.9, 14.0, 1));
// bar3 has zero range.
assert_eq!(t.update(c(14.0, 14.0, 14.0, 14.0, 2)), Some(0.0));
}
#[test]
fn non_white_yields_zero() {
let mut t = StalledPattern::new();
t.update(c(10.0, 12.05, 9.9, 12.0, 0));
t.update(c(11.0, 14.05, 10.9, 14.0, 1));
// bar3 is black.
assert_eq!(t.update(c(14.2, 14.6, 13.95, 14.05, 2)), Some(0.0));
}
#[test]
fn non_rising_closes_yield_zero() {
let mut t = StalledPattern::new();
t.update(c(10.0, 12.05, 9.9, 12.0, 0));
t.update(c(11.0, 14.05, 10.9, 14.0, 1));
// bar3 closes below bar2's close (white but not advancing).
assert_eq!(t.update(c(13.5, 14.0, 13.45, 13.6, 2)), Some(0.0));
}
#[test]
fn short_first_bodies_yield_zero() {
let mut t = StalledPattern::new();
// bar1 is white but its body is short relative to range.
t.update(c(11.5, 14.0, 10.0, 12.0, 0));
t.update(c(11.0, 14.05, 10.9, 14.0, 1));
assert_eq!(t.update(c(14.0, 14.6, 13.95, 14.15, 2)), Some(0.0));
}
#[test]
fn large_third_body_yields_zero() {
let mut t = StalledPattern::new();
t.update(c(10.0, 12.05, 9.9, 12.0, 0));
t.update(c(11.0, 14.05, 10.9, 14.0, 1));
// bar3 has a large body (not a small stalling candle).
assert_eq!(t.update(c(14.0, 16.05, 13.95, 16.0, 2)), Some(0.0));
}
#[test]
fn third_bar_off_shoulder_yields_zero() {
let mut t = StalledPattern::new();
t.update(c(10.0, 12.05, 9.9, 12.0, 0));
t.update(c(11.0, 14.05, 10.9, 14.0, 1));
// bar3 is a small white candle but opens well below bar2's close,
// so it is not riding the shoulder.
assert_eq!(t.update(c(13.6, 14.1, 12.55, 14.05, 2)), 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.05, base - 0.1, base + 2.0, i)
})
.collect();
let mut a = StalledPattern::new();
let mut b = StalledPattern::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = StalledPattern::new();
t.update(c(10.0, 12.05, 9.9, 12.0, 0));
t.update(c(11.0, 14.05, 10.9, 14.0, 1));
t.update(c(14.0, 14.6, 13.95, 14.15, 2));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(10.0, 12.05, 9.9, 12.0, 0)), Some(0.0));
}
}
@@ -0,0 +1,213 @@
//! Stick Sandwich candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Stick Sandwich — a 3-bar bullish reversal. A black candle is followed by a
/// white candle that trades entirely above the first close, then a second black
/// candle drives price back down to close at the same level as the first. The
/// matching closes "sandwich" the white candle and mark a support floor.
///
/// ```text
/// bar1 black, bar2 white, bar3 black
/// bar2 trades above bar1's close: low2 > close1
/// matching closes: |close3 close1| <= 0.1 * (high1 low1)
/// ```
///
/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Stick Sandwich
/// is a single-direction (bullish-only) reversal, so it never emits `1.0`. The
/// first two bars always return `0.0` because the three-bar window is not yet
/// filled. The matching-close tolerance follows the geometric house style (a fixed
/// fraction of the first bar's 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, Indicator, StickSandwich};
///
/// let mut indicator = StickSandwich::new();
/// indicator.update(Candle::new(12.0, 12.1, 9.9, 10.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(10.5, 11.6, 10.4, 11.5, 1.0, 1).unwrap());
/// let out = indicator
/// .update(Candle::new(11.5, 11.6, 9.9, 10.0, 1.0, 2).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct StickSandwich {
c1: Option<Candle>,
c2: Option<Candle>,
has_emitted: bool,
}
impl StickSandwich {
/// Construct a new Stick Sandwich detector.
pub const fn new() -> Self {
Self {
c1: None,
c2: None,
has_emitted: false,
}
}
}
impl Indicator for StickSandwich {
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;
self.c1 = self.c2;
self.c2 = Some(candle);
let (Some(bar1), Some(bar2)) = (bar1, bar2) else {
return Some(0.0);
};
// bar1 black, bar2 white, bar3 black.
if bar1.close >= bar1.open || bar2.close <= bar2.open || candle.close >= candle.open {
return Some(0.0);
}
// The white candle trades entirely above the first close.
if bar2.low <= bar1.close {
return Some(0.0);
}
// The two black candles close at the same level (the sandwich).
let range1 = bar1.high - bar1.low;
if (candle.close - bar1.close).abs() <= 0.1 * range1 {
return Some(1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.c1 = None;
self.c2 = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"StickSandwich"
}
}
#[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 = StickSandwich::new();
assert_eq!(t.name(), "StickSandwich");
assert_eq!(t.warmup_period(), 3);
assert!(!t.is_ready());
}
#[test]
fn stick_sandwich_is_plus_one() {
let mut t = StickSandwich::new();
assert_eq!(t.update(c(12.0, 12.1, 9.9, 10.0, 0)), Some(0.0));
assert_eq!(t.update(c(10.5, 11.6, 10.4, 11.5, 1)), Some(0.0));
assert_eq!(t.update(c(11.5, 11.6, 9.9, 10.0, 2)), Some(1.0));
}
#[test]
fn first_two_bars_return_zero() {
let mut t = StickSandwich::new();
assert_eq!(t.update(c(12.0, 12.1, 9.9, 10.0, 0)), Some(0.0));
assert_eq!(t.update(c(10.5, 11.6, 10.4, 11.5, 1)), Some(0.0));
}
#[test]
fn first_candle_not_black_yields_zero() {
let mut t = StickSandwich::new();
// bar1 white.
t.update(c(9.9, 12.1, 9.8, 10.0, 0));
t.update(c(10.5, 11.6, 10.4, 11.5, 1));
assert_eq!(t.update(c(11.5, 11.6, 9.9, 10.0, 2)), Some(0.0));
}
#[test]
fn middle_candle_not_white_yields_zero() {
let mut t = StickSandwich::new();
t.update(c(12.0, 12.1, 9.9, 10.0, 0));
// bar2 black.
t.update(c(11.5, 11.6, 10.4, 10.5, 1));
assert_eq!(t.update(c(11.5, 11.6, 9.9, 10.0, 2)), Some(0.0));
}
#[test]
fn third_candle_not_black_yields_zero() {
let mut t = StickSandwich::new();
t.update(c(12.0, 12.1, 9.9, 10.0, 0));
t.update(c(10.5, 11.6, 10.4, 11.5, 1));
// bar3 white.
assert_eq!(t.update(c(9.9, 11.6, 9.8, 10.0, 2)), Some(0.0));
}
#[test]
fn middle_low_not_above_first_close_yields_zero() {
let mut t = StickSandwich::new();
t.update(c(12.0, 12.1, 9.9, 10.0, 0));
// bar2 white but dips below bar1's close.
t.update(c(10.5, 11.6, 9.0, 11.5, 1));
assert_eq!(t.update(c(11.5, 11.6, 9.9, 10.0, 2)), Some(0.0));
}
#[test]
fn mismatched_closes_yield_zero() {
let mut t = StickSandwich::new();
t.update(c(12.0, 12.1, 9.9, 10.0, 0));
t.update(c(10.5, 11.6, 10.4, 11.5, 1));
// bar3 black but closes well away from bar1's close.
assert_eq!(t.update(c(11.5, 11.6, 7.9, 8.0, 2)), 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 = StickSandwich::new();
let mut b = StickSandwich::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = StickSandwich::new();
t.update(c(12.0, 12.1, 9.9, 10.0, 0));
t.update(c(10.5, 11.6, 10.4, 11.5, 1));
t.update(c(11.5, 11.6, 9.9, 10.0, 2));
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));
}
}
+167
View File
@@ -0,0 +1,167 @@
//! Takuri candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Takuri — a single-bar bullish reversal, a stricter Dragonfly Doji. Open, close,
/// and high sit at the very top of the bar with a negligible upper shadow, while an
/// exceptionally long lower shadow shows price was driven sharply down and then bid
/// all the way back — an emphatic rejection of the lows.
///
/// ```text
/// range = high low
/// doji = |close open| <= 0.1 * range
/// negligible upper = high max(open, close) <= 0.05 * range
/// very long lower = min(open, close) low >= 0.7 * range
/// ```
///
/// Output is `+1.0` when the Takuri prints and `0.0` otherwise. Takuri is a
/// single-direction (bullish-only) shape, so it never emits `1.0`. Its tighter
/// upper-shadow and longer lower-shadow thresholds make it a strict subset of
/// [`crate::DragonflyDoji`]. 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, Indicator, Takuri};
///
/// let mut indicator = Takuri::new();
/// // Body at the top, very long lower shadow.
/// let candle = Candle::new(10.0, 10.05, 7.0, 10.0, 1.0, 0).unwrap();
/// assert_eq!(indicator.update(candle), Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct Takuri {
has_emitted: bool,
}
impl Takuri {
/// Construct a new Takuri detector.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for Takuri {
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.05 * range && lower >= 0.7 * 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 {
"Takuri"
}
}
#[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 = Takuri::new();
assert_eq!(t.name(), "Takuri");
assert_eq!(t.warmup_period(), 1);
assert!(!t.is_ready());
}
#[test]
fn takuri_is_plus_one() {
let mut t = Takuri::new();
assert_eq!(t.update(c(10.0, 10.05, 7.0, 10.0, 0)), Some(1.0));
}
#[test]
fn non_doji_body_yields_zero() {
let mut t = Takuri::new();
// Large body -> not a doji.
assert_eq!(t.update(c(10.0, 12.0, 7.0, 11.5, 0)), Some(0.0));
}
#[test]
fn upper_shadow_yields_zero() {
let mut t = Takuri::new();
// Long upper shadow -> not a Takuri.
assert_eq!(t.update(c(10.0, 14.0, 7.0, 10.0, 0)), Some(0.0));
}
#[test]
fn dragonfly_but_not_takuri_yields_zero() {
let mut t = Takuri::new();
// Upper shadow ~0.07 of range: a Dragonfly Doji, but exceeds Takuri's
// tighter 0.05 ceiling.
assert_eq!(t.update(c(10.0, 10.24, 7.0, 10.0, 0)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut t = Takuri::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.02, base - 4.0, base, i)
})
.collect();
let mut a = Takuri::new();
let mut b = Takuri::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = Takuri::new();
t.update(c(10.0, 10.05, 7.0, 10.0, 0));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
}
}
@@ -0,0 +1,210 @@
//! Upside Gap Three Methods candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Upside Gap Three Methods — a 3-bar bullish continuation. Two white candles
/// advance with an upside body gap between them, then a black candle opens inside
/// the second body and closes inside the first body, partially filling the gap
/// without erasing the prior advance.
///
/// ```text
/// bar1 white, bar2 white
/// upside body gap: open2 > close1 (bar2's body sits entirely above bar1's)
/// bar3 black, opens within bar2's body and closes within bar1's body
/// ```
///
/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Upside Gap
/// Three Methods is a single-direction (bullish-only) continuation, so it never
/// emits `1.0`; its bearish mirror is [`crate::DownsideGapThreeMethods`]. The
/// first two bars always return `0.0` because the three-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, UpsideGapThreeMethods};
///
/// let mut indicator = UpsideGapThreeMethods::new();
/// indicator.update(Candle::new(10.0, 11.2, 9.8, 11.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(12.0, 13.2, 11.9, 13.0, 1.0, 1).unwrap());
/// let out = indicator
/// .update(Candle::new(12.5, 12.6, 10.4, 10.5, 1.0, 2).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct UpsideGapThreeMethods {
c1: Option<Candle>,
c2: Option<Candle>,
has_emitted: bool,
}
impl UpsideGapThreeMethods {
/// Construct a new Upside Gap Three Methods detector.
pub const fn new() -> Self {
Self {
c1: None,
c2: None,
has_emitted: false,
}
}
}
impl Indicator for UpsideGapThreeMethods {
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;
self.c1 = self.c2;
self.c2 = Some(candle);
let (Some(bar1), Some(bar2)) = (bar1, bar2) else {
return Some(0.0);
};
// bar1 and bar2 are both white.
if bar1.close <= bar1.open || bar2.close <= bar2.open {
return Some(0.0);
}
// Upside body gap: bar2's body sits entirely above bar1's.
if bar2.open <= bar1.close {
return Some(0.0);
}
// bar3 is black.
if candle.close >= candle.open {
return Some(0.0);
}
// bar3 opens within bar2's body and closes within bar1's body.
if candle.open > bar2.open
&& candle.open < bar2.close
&& candle.close > bar1.open
&& candle.close < bar1.close
{
return Some(1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.c1 = None;
self.c2 = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"UpsideGapThreeMethods"
}
}
#[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 = UpsideGapThreeMethods::new();
assert_eq!(t.name(), "UpsideGapThreeMethods");
assert_eq!(t.warmup_period(), 3);
assert!(!t.is_ready());
}
#[test]
fn upside_gap_three_methods_is_plus_one() {
let mut t = UpsideGapThreeMethods::new();
assert_eq!(t.update(c(10.0, 11.2, 9.8, 11.0, 0)), Some(0.0));
assert_eq!(t.update(c(12.0, 13.2, 11.9, 13.0, 1)), Some(0.0));
assert_eq!(t.update(c(12.5, 12.6, 10.4, 10.5, 2)), Some(1.0));
}
#[test]
fn first_two_bars_return_zero() {
let mut t = UpsideGapThreeMethods::new();
assert_eq!(t.update(c(10.0, 11.2, 9.8, 11.0, 0)), Some(0.0));
assert_eq!(t.update(c(12.0, 13.2, 11.9, 13.0, 1)), Some(0.0));
}
#[test]
fn non_white_first_bars_yield_zero() {
let mut t = UpsideGapThreeMethods::new();
// bar1 is black.
t.update(c(11.0, 11.2, 9.8, 10.0, 0));
t.update(c(12.0, 13.2, 11.9, 13.0, 1));
assert_eq!(t.update(c(12.5, 12.6, 10.4, 10.5, 2)), Some(0.0));
}
#[test]
fn no_gap_yields_zero() {
let mut t = UpsideGapThreeMethods::new();
t.update(c(10.0, 13.2, 9.8, 13.0, 0));
// bar2 opens below bar1's close -> no upside body gap.
t.update(c(11.0, 13.2, 10.9, 12.5, 1));
assert_eq!(t.update(c(12.0, 12.6, 10.4, 10.5, 2)), Some(0.0));
}
#[test]
fn third_bar_not_black_yields_zero() {
let mut t = UpsideGapThreeMethods::new();
t.update(c(10.0, 11.2, 9.8, 11.0, 0));
t.update(c(12.0, 13.2, 11.9, 13.0, 1));
// bar3 white.
assert_eq!(t.update(c(10.5, 12.6, 10.4, 12.5, 2)), Some(0.0));
}
#[test]
fn third_bar_outside_bodies_yields_zero() {
let mut t = UpsideGapThreeMethods::new();
t.update(c(10.0, 11.2, 9.8, 11.0, 0));
t.update(c(12.0, 13.2, 11.9, 13.0, 1));
// bar3 black but closes below bar1's body.
assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 2)), 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 = UpsideGapThreeMethods::new();
let mut b = UpsideGapThreeMethods::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = UpsideGapThreeMethods::new();
t.update(c(10.0, 11.2, 9.8, 11.0, 0));
t.update(c(12.0, 13.2, 11.9, 13.0, 1));
t.update(c(12.5, 12.6, 10.4, 10.5, 2));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(10.0, 11.2, 9.8, 11.0, 0)), Some(0.0));
}
}
+29 -27
View File
@@ -65,19 +65,20 @@ pub use indicators::{
ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack, CumulativeVolumeDelta,
CyberneticCycle, Decycler, DecyclerOscillator, Dema, DemandIndex, DemarkPivots,
DemarkPivotsOutput, DepthSlope, DetrendedStdDev, Doji, DojiStar, Donchian, DonchianOutput,
DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, DragonflyDoji,
DrawdownDuration, EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema,
EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, Fama,
FibonacciPivots, FibonacciPivotsOutput, FisherTransform, Footprint, FootprintOutput,
ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate,
FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite, GarmanKlassVolatility,
GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput, HiLoActivator,
HighWave, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma,
HomingPigeon, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput,
IdenticalThreeCrows, InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma, Kama, KellyCriterion,
Keltner, KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda,
LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle,
DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput,
DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, EaseOfMovement, EffectiveSpread,
EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Engulfing, EveningDojiStar,
Evwma, FallingThreeMethods, Fama, FibonacciPivots, FibonacciPivotsOutput, FisherTransform,
Footprint, FootprintOutput, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama,
FundingBasis, FundingRate, FundingRateMean, FundingRateZScore, GainLossRatio,
GapSideBySideWhite, GarmanKlassVolatility, GravestoneDoji, Hammer, HangingMan, Harami,
HeikinAshi, HeikinAshiOutput, HiLoActivator, HighWave, Hikkake, HikkakeModified,
HilbertDominantCycle, HistoricalVolatility, Hma, HomingPigeon, HurstChannel,
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck,
Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
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, LongLine, LongShortRatio, MaEnvelope,
MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex,
@@ -92,20 +93,21 @@ pub use indicators::{
RisingThreeMethods, Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter, Rsi, Rvi,
RviVolatility, Rwi, RwiOutput, SeparatingLines, SharpeRatio, ShootingStar, ShortLine,
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,
SpinningTop, StalledPattern, StandardError, StandardErrorBands, StandardErrorBandsOutput,
StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, StickSandwich, StochRsi,
Stochastic, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, TakerBuySellRatio,
Takuri, 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, UpsideGapThreeMethods,
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