feat: TA-Lib candlestick patterns — hikkake-mod/pigeon/neck-lines (part 5 of 9) (#137)
* feat: add hikkake-modified, homing-pigeon and neck-line candlestick patterns Five patterns, all `Input = Candle`, `Output = f64`: - Modified Hikkake (CDLHIKKAKEMOD) — a close-confirmed Hikkake: an inside bar then a breakout that closes back inside the inside-bar range; bullish +1, bearish -1. - Homing Pigeon (CDLHOMINGPIGEON) — two black candles, the second a small body inside the first, a bullish reversal; +1. - On-Neck (CDLONNECK) — long black bar then a white bar closing at its low (the neckline), a bearish continuation; -1. - In-Neck (CDLINNECK) — long black bar then a white bar closing just into its body, a bearish continuation; -1. - Thrusting (CDLTHRUSTING) — long black bar then a white bar closing well into but below the midpoint of its body, a bearish continuation; -1. Counter 264 -> 269 (mod-count == lib counted block; FAMILIES total 259 -> 264). * chore: sync indicator count to 269 --------- Co-authored-by: wickra-bot <wickra-bot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
//! Modified Hikkake candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Modified Hikkake — a close-confirmed variant of the [`Hikkake`](crate::Hikkake)
|
||||
/// trap. An inside bar is followed by a bar that breaks out *and is immediately
|
||||
/// rejected*: it pierces the inside bar's range intrabar but closes back inside,
|
||||
/// a stronger signal than the plain breakout setup.
|
||||
///
|
||||
/// ```text
|
||||
/// inside bar : bar2.high < bar1.high && bar2.low > bar1.low
|
||||
/// bullish (+1.0): bar3 makes a lower high AND lower low than bar2,
|
||||
/// yet closes back above the inside-bar low (close3 > bar2.low)
|
||||
/// bearish (−1.0): bar3 makes a higher high AND higher low than bar2,
|
||||
/// yet closes back below the inside-bar high (close3 < bar2.high)
|
||||
/// ```
|
||||
///
|
||||
/// Output is `+1.0` (bullish), `−1.0` (bearish), or `0.0` otherwise. The extra
|
||||
/// close-recovery condition is what distinguishes it from the plain Hikkake, which
|
||||
/// fires on the high/low break alone. 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, `−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, HikkakeModified, Indicator};
|
||||
///
|
||||
/// let mut indicator = HikkakeModified::new();
|
||||
/// indicator.update(Candle::new(10.0, 15.0, 5.0, 12.0, 1.0, 0).unwrap());
|
||||
/// indicator.update(Candle::new(11.0, 13.0, 8.0, 12.0, 1.0, 1).unwrap());
|
||||
/// let out = indicator
|
||||
/// .update(Candle::new(9.0, 12.0, 6.0, 9.0, 1.0, 2).unwrap());
|
||||
/// assert_eq!(out, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HikkakeModified {
|
||||
prev: Option<Candle>,
|
||||
prev_prev: Option<Candle>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl HikkakeModified {
|
||||
/// Construct a new Modified Hikkake detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev: None,
|
||||
prev_prev: None,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HikkakeModified {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let bar1 = self.prev_prev;
|
||||
let bar2 = self.prev;
|
||||
self.prev_prev = self.prev;
|
||||
self.prev = Some(candle);
|
||||
let (Some(bar1), Some(bar2)) = (bar1, bar2) else {
|
||||
return Some(0.0);
|
||||
};
|
||||
if !(bar2.high < bar1.high && bar2.low > bar1.low) {
|
||||
return Some(0.0);
|
||||
}
|
||||
// Bullish: false downside break that closes back above the inside-bar low.
|
||||
if candle.high < bar2.high && candle.low < bar2.low && candle.close > bar2.low {
|
||||
return Some(1.0);
|
||||
}
|
||||
// Bearish: false upside break that closes back below the inside-bar high.
|
||||
if candle.high > bar2.high && candle.low > bar2.low && candle.close < bar2.high {
|
||||
return Some(-1.0);
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.prev_prev = None;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HikkakeModified"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 = HikkakeModified::new();
|
||||
assert_eq!(t.name(), "HikkakeModified");
|
||||
assert_eq!(t.warmup_period(), 3);
|
||||
assert!(!t.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_modified_hikkake_is_plus_one() {
|
||||
let mut t = HikkakeModified::new();
|
||||
assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(11.0, 13.0, 8.0, 12.0, 1)), Some(0.0));
|
||||
assert_eq!(t.update(c(9.0, 12.0, 6.0, 9.0, 2)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_modified_hikkake_is_minus_one() {
|
||||
let mut t = HikkakeModified::new();
|
||||
assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(11.0, 13.0, 8.0, 12.0, 1)), Some(0.0));
|
||||
assert_eq!(t.update(c(13.0, 14.0, 9.0, 10.0, 2)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_without_close_recovery_yields_zero() {
|
||||
let mut t = HikkakeModified::new();
|
||||
t.update(c(10.0, 15.0, 5.0, 12.0, 0));
|
||||
t.update(c(11.0, 13.0, 8.0, 12.0, 1));
|
||||
// Lower high and lower low, but closes below the inside-bar low -> plain
|
||||
// Hikkake break, not the close-confirmed modified version.
|
||||
assert_eq!(t.update(c(9.0, 12.0, 6.0, 7.0, 2)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_inside_bar_yields_zero() {
|
||||
let mut t = HikkakeModified::new();
|
||||
t.update(c(10.0, 15.0, 5.0, 12.0, 0));
|
||||
t.update(c(11.0, 16.0, 8.0, 12.0, 1));
|
||||
assert_eq!(t.update(c(9.0, 12.0, 6.0, 9.0, 2)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_two_bars_return_zero() {
|
||||
let mut t = HikkakeModified::new();
|
||||
assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(11.0, 13.0, 8.0, 12.0, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
match i % 3 {
|
||||
0 => c(base, base + 6.0, base - 6.0, base, i),
|
||||
1 => c(base, base + 2.0, base - 2.0, base, i),
|
||||
_ => c(base, base + 1.0, base - 5.0, base, i),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut a = HikkakeModified::new();
|
||||
let mut b = HikkakeModified::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = HikkakeModified::new();
|
||||
t.update(c(10.0, 15.0, 5.0, 12.0, 0));
|
||||
t.update(c(11.0, 13.0, 8.0, 12.0, 1));
|
||||
t.update(c(9.0, 12.0, 6.0, 9.0, 2));
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Homing Pigeon candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Homing Pigeon — a 2-bar bullish reversal. Two black candles in a decline, the
|
||||
/// second a small body sitting entirely inside the first body (a same-colour
|
||||
/// harami). The shrinking range signals selling pressure is fading.
|
||||
///
|
||||
/// ```text
|
||||
/// bar1 black (close < open)
|
||||
/// bar2 black & its body sits inside bar1's body
|
||||
/// (open2 <= open1 && close2 >= close1)
|
||||
/// bar2 body is smaller than bar1's
|
||||
/// ```
|
||||
///
|
||||
/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Homing Pigeon
|
||||
/// is a single-direction (bullish-only) reversal, so it never emits `−1.0`. The
|
||||
/// first bar always returns `0.0` because the two-bar window is not yet filled.
|
||||
/// 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, HomingPigeon, Indicator};
|
||||
///
|
||||
/// let mut indicator = HomingPigeon::new();
|
||||
/// indicator.update(Candle::new(15.0, 15.1, 9.9, 10.0, 1.0, 0).unwrap());
|
||||
/// let out = indicator
|
||||
/// .update(Candle::new(14.0, 14.1, 10.9, 11.0, 1.0, 1).unwrap());
|
||||
/// assert_eq!(out, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HomingPigeon {
|
||||
prev: Option<Candle>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl HomingPigeon {
|
||||
/// Construct a new Homing Pigeon detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev: None,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HomingPigeon {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let prev = self.prev;
|
||||
self.prev = Some(candle);
|
||||
let Some(bar1) = prev else {
|
||||
return Some(0.0);
|
||||
};
|
||||
// Both bars black, bar2's body inside bar1's body and smaller.
|
||||
if bar1.close < bar1.open
|
||||
&& candle.close < candle.open
|
||||
&& candle.open <= bar1.open
|
||||
&& candle.close >= bar1.close
|
||||
&& (candle.open - candle.close) < (bar1.open - bar1.close)
|
||||
{
|
||||
return Some(1.0);
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HomingPigeon"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 = HomingPigeon::new();
|
||||
assert_eq!(t.name(), "HomingPigeon");
|
||||
assert_eq!(t.warmup_period(), 2);
|
||||
assert!(!t.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn homing_pigeon_is_plus_one() {
|
||||
let mut t = HomingPigeon::new();
|
||||
assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(14.0, 14.1, 10.9, 11.0, 1)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_bar_white_yields_zero() {
|
||||
let mut t = HomingPigeon::new();
|
||||
t.update(c(15.0, 15.1, 9.9, 10.0, 0));
|
||||
// bar2 white -> not a homing pigeon.
|
||||
assert_eq!(t.update(c(11.0, 14.1, 10.9, 14.0, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_body_not_inside_yields_zero() {
|
||||
let mut t = HomingPigeon::new();
|
||||
t.update(c(15.0, 15.1, 9.9, 10.0, 0));
|
||||
// bar2 opens above bar1's open -> body not contained.
|
||||
assert_eq!(t.update(c(16.0, 16.1, 10.9, 11.0, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_returns_zero() {
|
||||
let mut t = HomingPigeon::new();
|
||||
assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 5.0, base + 5.1, base - 0.1, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = HomingPigeon::new();
|
||||
let mut b = HomingPigeon::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = HomingPigeon::new();
|
||||
t.update(c(15.0, 15.1, 9.9, 10.0, 0));
|
||||
t.update(c(14.0, 14.1, 10.9, 11.0, 1));
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! In-Neck candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// In-Neck — a 2-bar bearish continuation, slightly stronger than On-Neck. A long
|
||||
/// black candle in a decline is followed by a white candle that opens below the
|
||||
/// black bar's low and closes just barely *into* the black body, around its close
|
||||
/// level. The shallow recovery still favours the sellers.
|
||||
///
|
||||
/// ```text
|
||||
/// long body = |close − open| >= 0.5 * (high − low)
|
||||
/// bar1 black & long
|
||||
/// bar2 white, opens below bar1's low (open2 < low1)
|
||||
/// bar2 closes just into bar1's body (close1 <= close2 <= close1 + 0.1 · body1)
|
||||
/// ```
|
||||
///
|
||||
/// Output is `−1.0` when the pattern completes and `0.0` otherwise. In-Neck is a
|
||||
/// single-direction (bearish-only) continuation, so it never emits `+1.0`. The
|
||||
/// first bar always returns `0.0` because the two-bar window is not yet filled.
|
||||
/// Body and neckline 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, InNeck, Indicator};
|
||||
///
|
||||
/// let mut indicator = InNeck::new();
|
||||
/// indicator.update(Candle::new(15.0, 15.1, 9.0, 10.0, 1.0, 0).unwrap());
|
||||
/// let out = indicator
|
||||
/// .update(Candle::new(7.0, 10.3, 6.9, 10.2, 1.0, 1).unwrap());
|
||||
/// assert_eq!(out, Some(-1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct InNeck {
|
||||
prev: Option<Candle>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl InNeck {
|
||||
/// Construct a new In-Neck detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev: None,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for InNeck {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let prev = self.prev;
|
||||
self.prev = Some(candle);
|
||||
let Some(bar1) = prev else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let range1 = bar1.high - bar1.low;
|
||||
if range1 <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let body1 = bar1.open - bar1.close;
|
||||
if bar1.close < bar1.open
|
||||
&& body1 >= 0.5 * range1
|
||||
&& candle.close > candle.open
|
||||
&& candle.open < bar1.low
|
||||
&& candle.close >= bar1.close
|
||||
&& candle.close <= bar1.close + 0.1 * body1
|
||||
{
|
||||
return Some(-1.0);
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"InNeck"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 = InNeck::new();
|
||||
assert_eq!(t.name(), "InNeck");
|
||||
assert_eq!(t.warmup_period(), 2);
|
||||
assert!(!t.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_neck_is_minus_one() {
|
||||
let mut t = InNeck::new();
|
||||
assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(7.0, 10.3, 6.9, 10.2, 1)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_at_low_yields_zero() {
|
||||
let mut t = InNeck::new();
|
||||
t.update(c(15.0, 15.1, 9.0, 10.0, 0));
|
||||
// Closes at the prior low, not into the body -> on-neck, not in-neck.
|
||||
assert_eq!(t.update(c(7.0, 9.1, 6.9, 9.0, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_past_neck_yields_zero() {
|
||||
let mut t = InNeck::new();
|
||||
t.update(c(15.0, 15.1, 9.0, 10.0, 0));
|
||||
// Closes well into the body -> thrusting, not in-neck.
|
||||
assert_eq!(t.update(c(7.0, 11.6, 6.9, 11.5, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_bar_black_yields_zero() {
|
||||
let mut t = InNeck::new();
|
||||
t.update(c(15.0, 15.1, 9.0, 10.0, 0));
|
||||
assert_eq!(t.update(c(10.4, 10.5, 6.9, 10.1, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_returns_zero() {
|
||||
let mut t = InNeck::new();
|
||||
assert_eq!(t.update(c(15.0, 15.1, 9.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 + 5.0, base + 5.1, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = InNeck::new();
|
||||
let mut b = InNeck::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = InNeck::new();
|
||||
t.update(c(15.0, 15.1, 9.0, 10.0, 0));
|
||||
t.update(c(7.0, 10.3, 6.9, 10.2, 1));
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
}
|
||||
@@ -100,14 +100,17 @@ mod harami;
|
||||
mod heikin_ashi;
|
||||
mod high_wave;
|
||||
mod hikkake;
|
||||
mod hikkake_modified;
|
||||
mod hilbert_dominant_cycle;
|
||||
mod hilo_activator;
|
||||
mod historical_volatility;
|
||||
mod hma;
|
||||
mod homing_pigeon;
|
||||
mod hurst_channel;
|
||||
mod hurst_exponent;
|
||||
mod ichimoku;
|
||||
mod identical_three_crows;
|
||||
mod in_neck;
|
||||
mod inertia;
|
||||
mod information_ratio;
|
||||
mod initial_balance;
|
||||
@@ -156,6 +159,7 @@ mod oi_delta;
|
||||
mod oi_price_divergence;
|
||||
mod oi_weighted;
|
||||
mod omega_ratio;
|
||||
mod on_neck;
|
||||
mod opening_range;
|
||||
mod pain_index;
|
||||
mod pair_spread_zscore;
|
||||
@@ -227,6 +231,7 @@ mod three_line_strike;
|
||||
mod three_outside;
|
||||
mod three_soldiers_or_crows;
|
||||
mod three_stars_in_south;
|
||||
mod thrusting;
|
||||
mod tii;
|
||||
mod trade_imbalance;
|
||||
mod treynor_ratio;
|
||||
@@ -364,14 +369,17 @@ pub use harami::Harami;
|
||||
pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
|
||||
pub use high_wave::HighWave;
|
||||
pub use hikkake::Hikkake;
|
||||
pub use hikkake_modified::HikkakeModified;
|
||||
pub use hilbert_dominant_cycle::HilbertDominantCycle;
|
||||
pub use hilo_activator::HiLoActivator;
|
||||
pub use historical_volatility::HistoricalVolatility;
|
||||
pub use hma::Hma;
|
||||
pub use homing_pigeon::HomingPigeon;
|
||||
pub use hurst_channel::{HurstChannel, HurstChannelOutput};
|
||||
pub use hurst_exponent::HurstExponent;
|
||||
pub use ichimoku::{Ichimoku, IchimokuOutput};
|
||||
pub use identical_three_crows::IdenticalThreeCrows;
|
||||
pub use in_neck::InNeck;
|
||||
pub use inertia::Inertia;
|
||||
pub use information_ratio::InformationRatio;
|
||||
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
|
||||
@@ -420,6 +428,7 @@ pub use oi_delta::OpenInterestDelta;
|
||||
pub use oi_price_divergence::OIPriceDivergence;
|
||||
pub use oi_weighted::OIWeighted;
|
||||
pub use omega_ratio::OmegaRatio;
|
||||
pub use on_neck::OnNeck;
|
||||
pub use opening_range::{OpeningRange, OpeningRangeOutput};
|
||||
pub use pain_index::PainIndex;
|
||||
pub use pair_spread_zscore::PairSpreadZScore;
|
||||
@@ -491,6 +500,7 @@ pub use three_line_strike::ThreeLineStrike;
|
||||
pub use three_outside::ThreeOutside;
|
||||
pub use three_soldiers_or_crows::ThreeSoldiersOrCrows;
|
||||
pub use three_stars_in_south::ThreeStarsInSouth;
|
||||
pub use thrusting::Thrusting;
|
||||
pub use tii::Tii;
|
||||
pub use trade_imbalance::TradeImbalance;
|
||||
pub use treynor_ratio::TreynorRatio;
|
||||
@@ -815,6 +825,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"GapSideBySideWhite",
|
||||
"HighWave",
|
||||
"Hikkake",
|
||||
"HikkakeModified",
|
||||
"HomingPigeon",
|
||||
"OnNeck",
|
||||
"InNeck",
|
||||
"Thrusting",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -906,6 +921,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, 259, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 264, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! On-Neck candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// On-Neck — a 2-bar bearish continuation. In a decline a long black candle is
|
||||
/// followed by a white candle that opens below the black bar's low yet rallies
|
||||
/// only as far as the black bar's *low* (the "neckline"). The feeble bounce shows
|
||||
/// sellers remain in control.
|
||||
///
|
||||
/// ```text
|
||||
/// long body = |close − open| >= 0.5 * (high − low)
|
||||
/// bar1 black & long
|
||||
/// bar2 white, opens below bar1's low (open2 < low1)
|
||||
/// bar2 closes at bar1's low (the neckline) (|close2 − low1| <= 0.05 · range1)
|
||||
/// ```
|
||||
///
|
||||
/// Output is `−1.0` when the pattern completes and `0.0` otherwise. On-Neck is a
|
||||
/// single-direction (bearish-only) continuation, so it never emits `+1.0`. The
|
||||
/// first bar always returns `0.0` because the two-bar window is not yet filled.
|
||||
/// Body and neckline 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, OnNeck};
|
||||
///
|
||||
/// let mut indicator = OnNeck::new();
|
||||
/// indicator.update(Candle::new(15.0, 15.1, 9.0, 10.0, 1.0, 0).unwrap());
|
||||
/// let out = indicator
|
||||
/// .update(Candle::new(7.0, 9.1, 6.9, 9.0, 1.0, 1).unwrap());
|
||||
/// assert_eq!(out, Some(-1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct OnNeck {
|
||||
prev: Option<Candle>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl OnNeck {
|
||||
/// Construct a new On-Neck detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev: None,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for OnNeck {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let prev = self.prev;
|
||||
self.prev = Some(candle);
|
||||
let Some(bar1) = prev else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let range1 = bar1.high - bar1.low;
|
||||
if range1 <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
if bar1.close < bar1.open
|
||||
&& (bar1.open - bar1.close) >= 0.5 * range1
|
||||
&& candle.close > candle.open
|
||||
&& candle.open < bar1.low
|
||||
&& (candle.close - bar1.low).abs() <= 0.05 * range1
|
||||
{
|
||||
return Some(-1.0);
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"OnNeck"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 = OnNeck::new();
|
||||
assert_eq!(t.name(), "OnNeck");
|
||||
assert_eq!(t.warmup_period(), 2);
|
||||
assert!(!t.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_neck_is_minus_one() {
|
||||
let mut t = OnNeck::new();
|
||||
assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(7.0, 9.1, 6.9, 9.0, 1)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_into_body_yields_zero() {
|
||||
let mut t = OnNeck::new();
|
||||
t.update(c(15.0, 15.1, 9.0, 10.0, 0));
|
||||
// Closes at the prior close, not the low -> in-neck, not on-neck.
|
||||
assert_eq!(t.update(c(7.0, 10.2, 6.9, 10.1, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_bar_black_yields_zero() {
|
||||
let mut t = OnNeck::new();
|
||||
t.update(c(15.0, 15.1, 9.0, 10.0, 0));
|
||||
assert_eq!(t.update(c(9.5, 9.6, 6.9, 9.0, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_above_low_yields_zero() {
|
||||
let mut t = OnNeck::new();
|
||||
t.update(c(15.0, 15.1, 9.0, 10.0, 0));
|
||||
// Opens above bar1's low.
|
||||
assert_eq!(t.update(c(9.5, 10.1, 9.4, 10.0, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_returns_zero() {
|
||||
let mut t = OnNeck::new();
|
||||
assert_eq!(t.update(c(15.0, 15.1, 9.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 + 5.0, base + 5.1, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = OnNeck::new();
|
||||
let mut b = OnNeck::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = OnNeck::new();
|
||||
t.update(c(15.0, 15.1, 9.0, 10.0, 0));
|
||||
t.update(c(7.0, 9.1, 6.9, 9.0, 1));
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Thrusting candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Thrusting — a 2-bar bearish continuation, deeper than In-Neck but short of a
|
||||
/// piercing reversal. A long black candle in a decline is followed by a white
|
||||
/// candle that opens below the black bar's low and closes well into the black
|
||||
/// body — but still below its midpoint, so the bounce is not yet a reversal.
|
||||
///
|
||||
/// ```text
|
||||
/// long body = |close − open| >= 0.5 * (high − low)
|
||||
/// bar1 black & long
|
||||
/// bar2 white, opens below bar1's low (open2 < low1)
|
||||
/// bar2 closes above the in-neck zone but below the body midpoint
|
||||
/// (close1 + 0.1·body1 < close2 < midpoint(open1, close1))
|
||||
/// ```
|
||||
///
|
||||
/// Output is `−1.0` when the pattern completes and `0.0` otherwise. Thrusting is a
|
||||
/// single-direction (bearish-only) continuation, so it never emits `+1.0`. A close
|
||||
/// at or above the midpoint would be a piercing pattern instead. The first bar
|
||||
/// always returns `0.0` because the two-bar window is not yet filled. Body and
|
||||
/// neckline 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, Thrusting};
|
||||
///
|
||||
/// let mut indicator = Thrusting::new();
|
||||
/// indicator.update(Candle::new(15.0, 15.1, 9.0, 10.0, 1.0, 0).unwrap());
|
||||
/// let out = indicator
|
||||
/// .update(Candle::new(7.0, 11.6, 6.9, 11.5, 1.0, 1).unwrap());
|
||||
/// assert_eq!(out, Some(-1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Thrusting {
|
||||
prev: Option<Candle>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Thrusting {
|
||||
/// Construct a new Thrusting detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev: None,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Thrusting {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let prev = self.prev;
|
||||
self.prev = Some(candle);
|
||||
let Some(bar1) = prev else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let range1 = bar1.high - bar1.low;
|
||||
if range1 <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let body1 = bar1.open - bar1.close;
|
||||
let mid1 = f64::midpoint(bar1.open, bar1.close);
|
||||
if bar1.close < bar1.open
|
||||
&& body1 >= 0.5 * range1
|
||||
&& candle.close > candle.open
|
||||
&& candle.open < bar1.low
|
||||
&& candle.close > bar1.close + 0.1 * body1
|
||||
&& candle.close < mid1
|
||||
{
|
||||
return Some(-1.0);
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Thrusting"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 = Thrusting::new();
|
||||
assert_eq!(t.name(), "Thrusting");
|
||||
assert_eq!(t.warmup_period(), 2);
|
||||
assert!(!t.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thrusting_is_minus_one() {
|
||||
let mut t = Thrusting::new();
|
||||
assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(7.0, 11.6, 6.9, 11.5, 1)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shallow_close_yields_zero() {
|
||||
let mut t = Thrusting::new();
|
||||
t.update(c(15.0, 15.1, 9.0, 10.0, 0));
|
||||
// Closes barely into the body -> in-neck, not thrusting.
|
||||
assert_eq!(t.update(c(7.0, 10.3, 6.9, 10.2, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_past_midpoint_yields_zero() {
|
||||
let mut t = Thrusting::new();
|
||||
t.update(c(15.0, 15.1, 9.0, 10.0, 0));
|
||||
// Closes above the midpoint -> piercing, not thrusting.
|
||||
assert_eq!(t.update(c(7.0, 13.1, 6.9, 13.0, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_bar_black_yields_zero() {
|
||||
let mut t = Thrusting::new();
|
||||
t.update(c(15.0, 15.1, 9.0, 10.0, 0));
|
||||
assert_eq!(t.update(c(12.0, 12.1, 6.9, 11.5, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_returns_zero() {
|
||||
let mut t = Thrusting::new();
|
||||
assert_eq!(t.update(c(15.0, 15.1, 9.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 + 5.0, base + 5.1, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Thrusting::new();
|
||||
let mut b = Thrusting::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = Thrusting::new();
|
||||
t.update(c(15.0, 15.1, 9.0, 10.0, 0));
|
||||
t.update(c(7.0, 11.6, 6.9, 11.5, 1));
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user