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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user