feat: TA-Lib candlestick patterns — tasuki-gap/unique-three-river/marubozu-pair/concealing-baby-swallow (part 9 of 9) (#141)

The final batch of the TA-Lib candlestick roadmap. Adds five 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.

- **Tasuki Gap** (`CDLTASUKIGAP`) — a 3-bar continuation: two same-coloured candles gap in the trend direction, then an opposite candle opens within the second body and closes back into the gap without filling it; upside +1, downside -1.
- **Unique Three River** (`CDLUNIQUE3RIVER`) — a 3-bar bullish reversal: a long black candle, a black candle probing a new low with its body inside the first, then a small white candle held below it; bullish +1.
- **Closing Marubozu** (`CDLCLOSINGMARUBOZU`) — a single long-bodied candle with no shadow on the close end; +1 (white, closes at the high) or -1 (black, closes at the low).
- **Opening Marubozu** — a single long-bodied candle with no shadow on the open end; +1 (white, opens at the low) or -1 (black, opens at the high). No direct TA-Lib equivalent — completes the pair with the closing marubozu.
- **Concealing Baby Swallow** (`CDLCONCEALBABYSWALL`) — a rare 4-bar bullish capitulation: two black marubozu, a black candle gapping down with an upper shadow into the second, then a large black candle engulfing it entirely; bullish +1.

Body and shadow thresholds follow the geometric house style (fixed fractions of the bar range) rather than TA-Lib's rolling averages.

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

Stacked on #140 (`feat/cdl-gap-methods`); base retargets to `main` as the stack merges down.
This commit is contained in:
kingchenc
2026-06-02 17:27:39 +02:00
committed by GitHub
parent 4d0bc08efd
commit 124efb4432
19 changed files with 1385 additions and 40 deletions
@@ -0,0 +1,177 @@
//! Closing Marubozu candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Closing Marubozu — a single-bar strong-momentum candle with a long body and no
/// shadow on the *close* end. A white closing marubozu closes right at the high
/// (no upper shadow) and may carry an opening shadow below; a black one closes
/// right at the low (no lower shadow) and may carry an opening shadow above. The
/// shaved close end shows the move ran unopposed into the bell.
///
/// ```text
/// range = high low
/// long body: |close open| >= 0.7 * range
/// white: close > open and high close <= 0.05 * range (close at the high)
/// black: close < open and close low <= 0.05 * range (close at the low)
/// ```
///
/// Output is `+1.0` for a white closing marubozu, `1.0` for a black one, and
/// `0.0` otherwise. Body and shadow thresholds follow the geometric house style
/// rather than TA-Lib's rolling averages. The opposite shaved end is
/// [`crate::OpeningMarubozu`]. 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, ClosingMarubozu, Indicator};
///
/// let mut indicator = ClosingMarubozu::new();
/// // White: closes at the high, small opening shadow below.
/// let candle = Candle::new(10.5, 15.0, 10.0, 15.0, 1.0, 0).unwrap();
/// assert_eq!(indicator.update(candle), Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct ClosingMarubozu {
has_emitted: bool,
}
impl ClosingMarubozu {
/// Construct a new Closing Marubozu detector.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for ClosingMarubozu {
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);
}
let body = candle.close - candle.open;
if body.abs() < 0.7 * range {
return Some(0.0);
}
let tol = 0.05 * range;
if body > 0.0 && candle.high - candle.close <= tol {
return Some(1.0);
}
if body < 0.0 && candle.close - candle.low <= tol {
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 {
"ClosingMarubozu"
}
}
#[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 = ClosingMarubozu::new();
assert_eq!(t.name(), "ClosingMarubozu");
assert_eq!(t.warmup_period(), 1);
assert!(!t.is_ready());
}
#[test]
fn white_closing_marubozu_is_plus_one() {
let mut t = ClosingMarubozu::new();
// Closes at the high, opening shadow below.
assert_eq!(t.update(c(10.5, 15.0, 10.0, 15.0, 0)), Some(1.0));
}
#[test]
fn black_closing_marubozu_is_minus_one() {
let mut t = ClosingMarubozu::new();
// Closes at the low, opening shadow above.
assert_eq!(t.update(c(14.5, 15.0, 10.0, 10.0, 0)), Some(-1.0));
}
#[test]
fn white_with_upper_shadow_yields_zero() {
let mut t = ClosingMarubozu::new();
// Long white body but a clear upper shadow -> close is not at the high.
assert_eq!(t.update(c(10.5, 16.0, 10.0, 15.0, 0)), Some(0.0));
}
#[test]
fn black_with_lower_shadow_yields_zero() {
let mut t = ClosingMarubozu::new();
// Long black body but a clear lower shadow -> close is not at the low.
assert_eq!(t.update(c(14.5, 15.0, 9.0, 10.0, 0)), Some(0.0));
}
#[test]
fn short_body_yields_zero() {
let mut t = ClosingMarubozu::new();
// Body is short relative to range.
assert_eq!(t.update(c(12.0, 15.0, 10.0, 12.5, 0)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut t = ClosingMarubozu::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 + 0.5, base + 5.0, base, base + 5.0, i)
})
.collect();
let mut a = ClosingMarubozu::new();
let mut b = ClosingMarubozu::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = ClosingMarubozu::new();
t.update(c(10.5, 15.0, 10.0, 15.0, 0));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
}
}
@@ -0,0 +1,278 @@
//! Concealing Baby Swallow candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Returns `true` when `candle` is a black marubozu: a down candle whose body fills
/// the range with negligible shadows on both ends.
fn black_marubozu(candle: Candle) -> bool {
let range = candle.high - candle.low;
if range <= 0.0 {
return false;
}
let upper = candle.high - candle.open;
let lower = candle.close - candle.low;
candle.open > candle.close && upper <= 0.05 * range && lower <= 0.05 * range
}
/// Concealing Baby Swallow — a rare 4-bar bullish reversal. Two black marubozu lead
/// a steep decline; the third is a black candle that gaps down on the open yet
/// throws a long upper shadow back up into the second body; the fourth is a large
/// black candle that completely engulfs the third, shadows included. The relentless
/// selling that can no longer make ground signals capitulation.
///
/// ```text
/// bar1, bar2 black marubozu (body == range, negligible shadows)
/// bar3 black, opens below bar2's body (open3 < close2) with an upper
/// shadow into it (high3 > close2)
/// bar4 black, engulfs bar3 including shadows: open4 > high3 and close4 < low3
/// ```
///
/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Concealing Baby
/// Swallow is a single-direction (bullish-only) reversal, so it never emits `1.0`.
/// The first three bars always return `0.0` because the four-bar window is not yet
/// filled. Body and shadow 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, ConcealingBabySwallow, Indicator};
///
/// let mut indicator = ConcealingBabySwallow::new();
/// indicator.update(Candle::new(20.0, 20.1, 14.9, 15.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(16.0, 16.1, 11.9, 12.0, 1.0, 1).unwrap());
/// indicator.update(Candle::new(11.0, 13.0, 9.9, 10.0, 1.0, 2).unwrap());
/// let out = indicator
/// .update(Candle::new(14.0, 14.1, 8.9, 9.0, 1.0, 3).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct ConcealingBabySwallow {
c1: Option<Candle>,
c2: Option<Candle>,
c3: Option<Candle>,
has_emitted: bool,
}
impl ConcealingBabySwallow {
/// Construct a new Concealing Baby Swallow detector.
pub const fn new() -> Self {
Self {
c1: None,
c2: None,
c3: None,
has_emitted: false,
}
}
}
impl Indicator for ConcealingBabySwallow {
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;
self.c1 = self.c2;
self.c2 = self.c3;
self.c3 = Some(candle);
let (Some(bar1), Some(bar2), Some(bar3)) = (bar1, bar2, bar3) else {
return Some(0.0);
};
// bar1 and bar2 are black marubozu.
if !black_marubozu(bar1) || !black_marubozu(bar2) {
return Some(0.0);
}
// bar3 is black, gaps down on the open, throws an upper shadow into bar2.
if bar3.open <= bar3.close {
return Some(0.0);
}
if bar3.open >= bar2.close {
return Some(0.0); // no downside open gap
}
if bar3.high <= bar2.close {
return Some(0.0); // upper shadow does not reach into bar2's body
}
// bar4 is black and engulfs bar3 including its shadows.
if candle.open <= candle.close {
return Some(0.0);
}
if candle.open > bar3.high && candle.close < bar3.low {
return Some(1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.c1 = None;
self.c2 = None;
self.c3 = None;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
4
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"ConcealingBabySwallow"
}
}
#[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 = ConcealingBabySwallow::new();
assert_eq!(t.name(), "ConcealingBabySwallow");
assert_eq!(t.warmup_period(), 4);
assert!(!t.is_ready());
}
#[test]
fn concealing_baby_swallow_is_plus_one() {
let mut t = ConcealingBabySwallow::new();
assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(16.0, 16.1, 11.9, 12.0, 1)), Some(0.0));
assert_eq!(t.update(c(11.0, 13.0, 9.9, 10.0, 2)), Some(0.0));
assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(1.0));
}
#[test]
fn warmup_returns_zero() {
let mut t = ConcealingBabySwallow::new();
assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(16.0, 16.1, 11.9, 12.0, 1)), Some(0.0));
assert_eq!(t.update(c(11.0, 13.0, 9.9, 10.0, 2)), Some(0.0));
}
#[test]
fn first_bar_not_marubozu_yields_zero() {
let mut t = ConcealingBabySwallow::new();
// bar1 white.
t.update(c(15.0, 20.1, 14.9, 20.0, 0));
t.update(c(16.0, 16.1, 11.9, 12.0, 1));
t.update(c(11.0, 13.0, 9.9, 10.0, 2));
assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(0.0));
}
#[test]
fn first_bar_zero_range_yields_zero() {
let mut t = ConcealingBabySwallow::new();
// bar1 zero range -> not a marubozu.
t.update(c(15.0, 15.0, 15.0, 15.0, 0));
t.update(c(16.0, 16.1, 11.9, 12.0, 1));
t.update(c(11.0, 13.0, 9.9, 10.0, 2));
assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(0.0));
}
#[test]
fn second_bar_not_marubozu_yields_zero() {
let mut t = ConcealingBabySwallow::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
// bar2 white.
t.update(c(12.0, 16.1, 11.9, 16.0, 1));
t.update(c(11.0, 13.0, 9.9, 10.0, 2));
assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(0.0));
}
#[test]
fn third_bar_not_black_yields_zero() {
let mut t = ConcealingBabySwallow::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
t.update(c(16.0, 16.1, 11.9, 12.0, 1));
// bar3 white.
t.update(c(11.0, 13.0, 9.9, 12.5, 2));
assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(0.0));
}
#[test]
fn third_bar_no_gap_yields_zero() {
let mut t = ConcealingBabySwallow::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
t.update(c(16.0, 16.1, 11.9, 12.0, 1));
// bar3 black but opens at/above bar2's close -> no downside gap.
t.update(c(12.5, 13.0, 9.9, 10.0, 2));
assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(0.0));
}
#[test]
fn third_bar_no_upper_shadow_yields_zero() {
let mut t = ConcealingBabySwallow::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
t.update(c(16.0, 16.1, 11.9, 12.0, 1));
// bar3 black, gaps down, but its high does not reach into bar2's body.
t.update(c(11.0, 11.5, 9.9, 10.0, 2));
assert_eq!(t.update(c(14.0, 14.1, 8.9, 9.0, 3)), Some(0.0));
}
#[test]
fn fourth_bar_not_black_yields_zero() {
let mut t = ConcealingBabySwallow::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
t.update(c(16.0, 16.1, 11.9, 12.0, 1));
t.update(c(11.0, 13.0, 9.9, 10.0, 2));
// bar4 white.
assert_eq!(t.update(c(14.0, 14.1, 8.9, 14.05, 3)), Some(0.0));
}
#[test]
fn fourth_bar_not_engulfing_yields_zero() {
let mut t = ConcealingBabySwallow::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
t.update(c(16.0, 16.1, 11.9, 12.0, 1));
t.update(c(11.0, 13.0, 9.9, 10.0, 2));
// bar4 black but does not engulf bar3's high.
assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.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, base + 0.05, base - 5.0, base - 5.0, i)
})
.collect();
let mut a = ConcealingBabySwallow::new();
let mut b = ConcealingBabySwallow::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = ConcealingBabySwallow::new();
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
t.update(c(16.0, 16.1, 11.9, 12.0, 1));
t.update(c(11.0, 13.0, 9.9, 10.0, 2));
t.update(c(14.0, 14.1, 8.9, 9.0, 3));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0));
}
}
+16 -1
View File
@@ -45,10 +45,12 @@ mod chande_kroll_stop;
mod chandelier_exit;
mod choppiness_index;
mod classic_pivots;
mod closing_marubozu;
mod cmf;
mod cmo;
mod coefficient_of_variation;
mod cointegration;
mod concealing_baby_swallow;
mod conditional_value_at_risk;
mod connors_rsi;
mod coppock;
@@ -168,6 +170,7 @@ mod oi_price_divergence;
mod oi_weighted;
mod omega_ratio;
mod on_neck;
mod opening_marubozu;
mod opening_range;
mod pain_index;
mod pair_spread_zscore;
@@ -226,6 +229,7 @@ mod super_trend;
mod t3;
mod taker_buy_sell_ratio;
mod takuri;
mod tasuki_gap;
mod td_combo;
mod td_countdown;
mod td_demarker;
@@ -260,6 +264,7 @@ mod two_crows;
mod typical_price;
mod ulcer_index;
mod ultimate_oscillator;
mod unique_three_river;
mod upside_gap_three_methods;
mod upside_gap_two_crows;
mod value_area;
@@ -329,10 +334,12 @@ pub use chande_kroll_stop::{ChandeKrollStop, ChandeKrollStopOutput};
pub use chandelier_exit::{ChandelierExit, ChandelierExitOutput};
pub use choppiness_index::ChoppinessIndex;
pub use classic_pivots::{ClassicPivots, ClassicPivotsOutput};
pub use closing_marubozu::ClosingMarubozu;
pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
pub use coefficient_of_variation::CoefficientOfVariation;
pub use cointegration::{Cointegration, CointegrationOutput};
pub use concealing_baby_swallow::ConcealingBabySwallow;
pub use conditional_value_at_risk::ConditionalValueAtRisk;
pub use connors_rsi::ConnorsRsi;
pub use coppock::Coppock;
@@ -452,6 +459,7 @@ pub use oi_price_divergence::OIPriceDivergence;
pub use oi_weighted::OIWeighted;
pub use omega_ratio::OmegaRatio;
pub use on_neck::OnNeck;
pub use opening_marubozu::OpeningMarubozu;
pub use opening_range::{OpeningRange, OpeningRangeOutput};
pub use pain_index::PainIndex;
pub use pair_spread_zscore::PairSpreadZScore;
@@ -510,6 +518,7 @@ pub use super_trend::{SuperTrend, SuperTrendOutput};
pub use t3::T3;
pub use taker_buy_sell_ratio::TakerBuySellRatio;
pub use takuri::Takuri;
pub use tasuki_gap::TasukiGap;
pub use td_combo::TdCombo;
pub use td_countdown::TdCountdown;
pub use td_demarker::TdDeMarker;
@@ -544,6 +553,7 @@ pub use two_crows::TwoCrows;
pub use typical_price::TypicalPrice;
pub use ulcer_index::UlcerIndex;
pub use ultimate_oscillator::UltimateOscillator;
pub use unique_three_river::UniqueThreeRiver;
pub use upside_gap_three_methods::UpsideGapThreeMethods;
pub use upside_gap_two_crows::UpsideGapTwoCrows;
pub use value_area::{ValueArea, ValueAreaOutput};
@@ -875,6 +885,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"StalledPattern",
"StickSandwich",
"Takuri",
"ClosingMarubozu",
"OpeningMarubozu",
"TasukiGap",
"UniqueThreeRiver",
"ConcealingBabySwallow",
],
),
(
@@ -966,6 +981,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, 279, "FAMILIES total drifted from indicator count");
assert_eq!(total, 284, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,178 @@
//! Opening Marubozu candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Opening Marubozu — a single-bar strong-momentum candle with a long body and no
/// shadow on the *open* end. A white opening marubozu opens right at the low (no
/// lower shadow) and may carry a closing shadow above; a black one opens right at
/// the high (no upper shadow) and may carry a closing shadow below. The shaved
/// open end shows the move took off from the bell without hesitation.
///
/// ```text
/// range = high low
/// long body: |close open| >= 0.7 * range
/// white: close > open and open low <= 0.05 * range (open at the low)
/// black: close < open and high open <= 0.05 * range (open at the high)
/// ```
///
/// Output is `+1.0` for a white opening marubozu, `1.0` for a black one, and
/// `0.0` otherwise. Body and shadow thresholds follow the geometric house style
/// rather than TA-Lib's rolling averages. TA-Lib has no direct equivalent; this
/// completes the pair with [`crate::ClosingMarubozu`], which shaves the close end.
/// 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, Indicator, OpeningMarubozu};
///
/// let mut indicator = OpeningMarubozu::new();
/// // White: opens at the low, small closing shadow above.
/// let candle = Candle::new(10.0, 15.0, 10.0, 14.5, 1.0, 0).unwrap();
/// assert_eq!(indicator.update(candle), Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct OpeningMarubozu {
has_emitted: bool,
}
impl OpeningMarubozu {
/// Construct a new Opening Marubozu detector.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for OpeningMarubozu {
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);
}
let body = candle.close - candle.open;
if body.abs() < 0.7 * range {
return Some(0.0);
}
let tol = 0.05 * range;
if body > 0.0 && candle.open - candle.low <= tol {
return Some(1.0);
}
if body < 0.0 && candle.high - candle.open <= tol {
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 {
"OpeningMarubozu"
}
}
#[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 = OpeningMarubozu::new();
assert_eq!(t.name(), "OpeningMarubozu");
assert_eq!(t.warmup_period(), 1);
assert!(!t.is_ready());
}
#[test]
fn white_opening_marubozu_is_plus_one() {
let mut t = OpeningMarubozu::new();
// Opens at the low, closing shadow above.
assert_eq!(t.update(c(10.0, 15.0, 10.0, 14.5, 0)), Some(1.0));
}
#[test]
fn black_opening_marubozu_is_minus_one() {
let mut t = OpeningMarubozu::new();
// Opens at the high, closing shadow below.
assert_eq!(t.update(c(15.0, 15.0, 10.0, 10.5, 0)), Some(-1.0));
}
#[test]
fn white_with_lower_shadow_yields_zero() {
let mut t = OpeningMarubozu::new();
// Long white body but a clear lower shadow -> open is not at the low.
assert_eq!(t.update(c(11.0, 15.0, 10.0, 15.0, 0)), Some(0.0));
}
#[test]
fn black_with_upper_shadow_yields_zero() {
let mut t = OpeningMarubozu::new();
// Long black body but a clear upper shadow -> open is not at the high.
assert_eq!(t.update(c(14.0, 16.0, 10.0, 10.5, 0)), Some(0.0));
}
#[test]
fn short_body_yields_zero() {
let mut t = OpeningMarubozu::new();
// Body is short relative to range.
assert_eq!(t.update(c(10.0, 15.0, 10.0, 12.5, 0)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut t = OpeningMarubozu::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 + 5.0, base, base + 4.5, i)
})
.collect();
let mut a = OpeningMarubozu::new();
let mut b = OpeningMarubozu::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = OpeningMarubozu::new();
t.update(c(10.0, 15.0, 10.0, 14.5, 0));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
}
}
@@ -0,0 +1,278 @@
//! Tasuki Gap candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Tasuki Gap — a 3-bar continuation. Two same-coloured candles open a body gap in
/// the trend direction, then an opposite-coloured candle opens inside the second
/// body and closes back *into* the gap without filling it — the gap holds, so the
/// trend is expected to continue.
///
/// ```text
/// Upside (bullish, +1):
/// bar1 white, bar2 white with an upside body gap (open2 > close1)
/// bar3 black, opens within bar2's body, closes inside the gap
/// (close1 < close3 < open2)
/// Downside (bearish, 1): the mirror image with black candles and a downside gap
/// ```
///
/// Output is `+1.0` for an upside Tasuki gap, `1.0` for a downside one, and `0.0`
/// otherwise. The first two bars always return `0.0` because the three-bar window
/// is not yet filled. Thresholds follow the geometric house style rather than
/// TA-Lib's rolling averages. Pattern-shape check only — no trend filter is
/// applied; combine with a trend indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector emits the uniform candlestick sign convention shared across the
/// pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no pattern — so it drops
/// straight into a machine-learning feature matrix where the bullish and bearish
/// variants occupy a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TasukiGap};
///
/// let mut indicator = TasukiGap::new();
/// indicator.update(Candle::new(10.0, 11.2, 9.8, 11.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(12.0, 14.0, 11.9, 13.5, 1.0, 1).unwrap());
/// let out = indicator
/// .update(Candle::new(13.0, 13.1, 11.4, 11.5, 1.0, 2).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct TasukiGap {
c1: Option<Candle>,
c2: Option<Candle>,
has_emitted: bool,
}
impl TasukiGap {
/// Construct a new Tasuki Gap detector.
pub const fn new() -> Self {
Self {
c1: None,
c2: None,
has_emitted: false,
}
}
}
impl Indicator for TasukiGap {
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 up = bar1.close > bar1.open && bar2.close > bar2.open;
let down = bar1.close < bar1.open && bar2.close < bar2.open;
if up {
if bar2.open <= bar1.close {
return Some(0.0); // no upside body gap
}
if candle.close >= candle.open {
return Some(0.0); // bar3 must be black
}
if candle.open <= bar2.open || candle.open >= bar2.close {
return Some(0.0); // bar3 must open within bar2's body
}
if candle.close < bar2.open && candle.close > bar1.close {
return Some(1.0); // bar3 closes inside the gap
}
return Some(0.0);
}
if down {
if bar2.open >= bar1.close {
return Some(0.0); // no downside body gap
}
if candle.close <= candle.open {
return Some(0.0); // bar3 must be white
}
if candle.open >= bar2.open || candle.open <= bar2.close {
return Some(0.0); // bar3 must open within bar2's body
}
if candle.close > bar2.open && candle.close < bar1.close {
return Some(-1.0); // bar3 closes inside the gap
}
return Some(0.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 {
"TasukiGap"
}
}
#[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 = TasukiGap::new();
assert_eq!(t.name(), "TasukiGap");
assert_eq!(t.warmup_period(), 3);
assert!(!t.is_ready());
}
#[test]
fn upside_tasuki_gap_is_plus_one() {
let mut t = TasukiGap::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, 14.0, 11.9, 13.5, 1)), Some(0.0));
assert_eq!(t.update(c(13.0, 13.1, 11.4, 11.5, 2)), Some(1.0));
}
#[test]
fn downside_tasuki_gap_is_minus_one() {
let mut t = TasukiGap::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.5, 10.0, 1)), Some(0.0));
assert_eq!(t.update(c(10.5, 11.6, 10.4, 11.5, 2)), Some(-1.0));
}
#[test]
fn first_two_bars_return_zero() {
let mut t = TasukiGap::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, 14.0, 11.9, 13.5, 1)), Some(0.0));
}
#[test]
fn up_no_gap_yields_zero() {
let mut t = TasukiGap::new();
t.update(c(10.0, 11.2, 9.8, 11.0, 0));
// bar2 white but opens below bar1's close -> no upside gap.
t.update(c(10.5, 13.1, 10.4, 13.0, 1));
assert_eq!(t.update(c(12.5, 12.6, 10.9, 11.0, 2)), Some(0.0));
}
#[test]
fn up_third_not_black_yields_zero() {
let mut t = TasukiGap::new();
t.update(c(10.0, 11.2, 9.8, 11.0, 0));
t.update(c(12.0, 14.0, 11.9, 13.5, 1));
// bar3 white.
assert_eq!(t.update(c(12.5, 13.1, 12.4, 13.0, 2)), Some(0.0));
}
#[test]
fn up_third_open_outside_body_yields_zero() {
let mut t = TasukiGap::new();
t.update(c(10.0, 11.2, 9.8, 11.0, 0));
t.update(c(12.0, 14.0, 11.9, 13.5, 1));
// bar3 black but opens above bar2's body.
assert_eq!(t.update(c(14.0, 14.1, 11.4, 11.5, 2)), Some(0.0));
}
#[test]
fn up_third_close_not_in_gap_yields_zero() {
let mut t = TasukiGap::new();
t.update(c(10.0, 11.2, 9.8, 11.0, 0));
t.update(c(12.0, 14.0, 11.9, 13.5, 1));
// bar3 black, opens in body, but closes below the gap (under bar1's close).
assert_eq!(t.update(c(13.0, 13.1, 10.4, 10.5, 2)), Some(0.0));
}
#[test]
fn down_no_gap_yields_zero() {
let mut t = TasukiGap::new();
t.update(c(13.0, 13.2, 11.8, 12.0, 0));
// bar2 black but opens above bar1's close -> no downside gap.
t.update(c(12.5, 12.6, 10.4, 10.5, 1));
assert_eq!(t.update(c(11.0, 12.6, 10.9, 12.0, 2)), Some(0.0));
}
#[test]
fn down_third_not_white_yields_zero() {
let mut t = TasukiGap::new();
t.update(c(13.0, 13.2, 11.8, 12.0, 0));
t.update(c(11.0, 11.1, 9.5, 10.0, 1));
// bar3 black.
assert_eq!(t.update(c(11.5, 11.6, 10.4, 10.5, 2)), Some(0.0));
}
#[test]
fn down_third_open_outside_body_yields_zero() {
let mut t = TasukiGap::new();
t.update(c(13.0, 13.2, 11.8, 12.0, 0));
t.update(c(11.0, 11.1, 9.5, 10.0, 1));
// bar3 white but opens below bar2's body.
assert_eq!(t.update(c(9.5, 11.6, 9.4, 11.5, 2)), Some(0.0));
}
#[test]
fn down_third_close_not_in_gap_yields_zero() {
let mut t = TasukiGap::new();
t.update(c(13.0, 13.2, 11.8, 12.0, 0));
t.update(c(11.0, 11.1, 9.5, 10.0, 1));
// bar3 white, opens in body, but closes above the gap (over bar1's close).
assert_eq!(t.update(c(10.5, 13.0, 10.4, 12.5, 2)), Some(0.0));
}
#[test]
fn mixed_colours_yield_zero() {
let mut t = TasukiGap::new();
// bar1 white, bar2 black -> neither an upside nor downside setup.
t.update(c(10.0, 11.2, 9.8, 11.0, 0));
t.update(c(13.0, 13.2, 11.0, 11.5, 1));
assert_eq!(t.update(c(12.0, 12.6, 10.9, 11.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 = TasukiGap::new();
let mut b = TasukiGap::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = TasukiGap::new();
t.update(c(10.0, 11.2, 9.8, 11.0, 0));
t.update(c(12.0, 14.0, 11.9, 13.5, 1));
t.update(c(13.0, 13.1, 11.4, 11.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));
}
}
@@ -0,0 +1,256 @@
//! Unique Three River candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Unique Three River (Bottom) — a 3-bar bullish reversal. A long black candle is
/// followed by a smaller black candle whose body sits inside the first but whose
/// long lower shadow probes a new low, then a small white candle that stays below
/// the second body. The fresh low that fails to hold marks an exhausted decline.
///
/// ```text
/// bar1 long black: open1 close1 >= 0.5 * (high1 low1)
/// bar2 black, body inside bar1's body, with a new low (low2 < low1)
/// bar3 small white, contained below bar2's body (high3 <= close2)
/// small body: close3 open3 <= 0.3 * (high3 low3)
/// ```
///
/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Unique Three
/// River 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. 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, UniqueThreeRiver};
///
/// let mut indicator = UniqueThreeRiver::new();
/// indicator.update(Candle::new(15.0, 15.1, 10.0, 10.5, 1.0, 0).unwrap());
/// indicator.update(Candle::new(14.0, 14.1, 9.0, 11.0, 1.0, 1).unwrap());
/// let out = indicator
/// .update(Candle::new(10.2, 10.9, 9.5, 10.4, 1.0, 2).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct UniqueThreeRiver {
c1: Option<Candle>,
c2: Option<Candle>,
has_emitted: bool,
}
impl UniqueThreeRiver {
/// Construct a new Unique Three River detector.
pub const fn new() -> Self {
Self {
c1: None,
c2: None,
has_emitted: false,
}
}
}
impl Indicator for UniqueThreeRiver {
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 is a long black body.
if bar1.open <= bar1.close {
return Some(0.0);
}
let range1 = bar1.high - bar1.low;
if bar1.open - bar1.close < 0.5 * range1 {
return Some(0.0);
}
// bar2 is black, its body inside bar1's body, with a new low.
if bar2.open <= bar2.close {
return Some(0.0);
}
if bar2.open > bar1.open || bar2.close < bar1.close {
return Some(0.0);
}
if bar2.low >= bar1.low {
return Some(0.0);
}
// bar3 is a small white candle contained below bar2's body.
if candle.close <= candle.open {
return Some(0.0);
}
let range3 = candle.high - candle.low;
if candle.close - candle.open > 0.3 * range3 {
return Some(0.0);
}
if candle.high > bar2.close {
return Some(0.0);
}
Some(1.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 {
"UniqueThreeRiver"
}
}
#[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 = UniqueThreeRiver::new();
assert_eq!(t.name(), "UniqueThreeRiver");
assert_eq!(t.warmup_period(), 3);
assert!(!t.is_ready());
}
#[test]
fn unique_three_river_is_plus_one() {
let mut t = UniqueThreeRiver::new();
assert_eq!(t.update(c(15.0, 15.1, 10.0, 10.5, 0)), Some(0.0));
assert_eq!(t.update(c(14.0, 14.1, 9.0, 11.0, 1)), Some(0.0));
assert_eq!(t.update(c(10.2, 10.9, 9.5, 10.4, 2)), Some(1.0));
}
#[test]
fn first_two_bars_return_zero() {
let mut t = UniqueThreeRiver::new();
assert_eq!(t.update(c(15.0, 15.1, 10.0, 10.5, 0)), Some(0.0));
assert_eq!(t.update(c(14.0, 14.1, 9.0, 11.0, 1)), Some(0.0));
}
#[test]
fn first_bar_not_black_yields_zero() {
let mut t = UniqueThreeRiver::new();
// bar1 white.
t.update(c(10.5, 15.1, 10.0, 15.0, 0));
t.update(c(14.0, 14.1, 9.0, 11.0, 1));
assert_eq!(t.update(c(10.2, 10.9, 9.5, 10.4, 2)), Some(0.0));
}
#[test]
fn first_bar_short_body_yields_zero() {
let mut t = UniqueThreeRiver::new();
// bar1 black but its body is short relative to range.
t.update(c(15.0, 15.1, 10.0, 14.5, 0));
t.update(c(14.0, 14.1, 9.0, 11.0, 1));
assert_eq!(t.update(c(10.2, 10.9, 9.5, 10.4, 2)), Some(0.0));
}
#[test]
fn second_bar_not_black_yields_zero() {
let mut t = UniqueThreeRiver::new();
t.update(c(15.0, 15.1, 10.0, 10.5, 0));
// bar2 white.
t.update(c(11.0, 14.1, 9.0, 13.0, 1));
assert_eq!(t.update(c(10.2, 10.9, 9.5, 10.4, 2)), Some(0.0));
}
#[test]
fn second_bar_not_inside_yields_zero() {
let mut t = UniqueThreeRiver::new();
t.update(c(15.0, 15.1, 10.0, 10.5, 0));
// bar2 black but opens above bar1's open -> body not inside.
t.update(c(16.0, 16.1, 9.0, 11.0, 1));
assert_eq!(t.update(c(10.2, 10.9, 9.5, 10.4, 2)), Some(0.0));
}
#[test]
fn second_bar_no_new_low_yields_zero() {
let mut t = UniqueThreeRiver::new();
t.update(c(15.0, 15.1, 10.0, 10.5, 0));
// bar2 black, inside, but does not make a new low.
t.update(c(14.0, 14.1, 10.5, 11.0, 1));
assert_eq!(t.update(c(10.2, 10.9, 9.5, 10.4, 2)), Some(0.0));
}
#[test]
fn third_bar_not_white_yields_zero() {
let mut t = UniqueThreeRiver::new();
t.update(c(15.0, 15.1, 10.0, 10.5, 0));
t.update(c(14.0, 14.1, 9.0, 11.0, 1));
// bar3 black.
assert_eq!(t.update(c(10.6, 10.9, 9.5, 10.2, 2)), Some(0.0));
}
#[test]
fn third_bar_large_body_yields_zero() {
let mut t = UniqueThreeRiver::new();
t.update(c(15.0, 15.1, 10.0, 10.5, 0));
t.update(c(14.0, 14.1, 9.0, 11.0, 1));
// bar3 white but with a large body.
assert_eq!(t.update(c(9.6, 10.9, 9.5, 10.8, 2)), Some(0.0));
}
#[test]
fn third_bar_not_below_second_yields_zero() {
let mut t = UniqueThreeRiver::new();
t.update(c(15.0, 15.1, 10.0, 10.5, 0));
t.update(c(14.0, 14.1, 9.0, 11.0, 1));
// bar3 small white but pokes above bar2's close.
assert_eq!(t.update(c(10.5, 11.5, 10.4, 10.7, 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 = UniqueThreeRiver::new();
let mut b = UniqueThreeRiver::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = UniqueThreeRiver::new();
t.update(c(15.0, 15.1, 10.0, 10.5, 0));
t.update(c(14.0, 14.1, 9.0, 11.0, 1));
t.update(c(10.2, 10.9, 9.5, 10.4, 2));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(15.0, 15.1, 10.0, 10.5, 0)), Some(0.0));
}
}