feat(family-14): add 15 candlestick patterns (#53)
* feat(family-14): add 15 candlestick patterns Introduces the Candlestick Patterns family (block A of the family-14 spec) as scalar f64 indicators on Candle inputs. Each detector emits +1.0 for a bullish reading, -1.0 for a bearish reading, and 0.0 when no pattern is present. Doji is direction-less and emits +1.0 / 0.0 only. New indicators (15): - Doji - Hammer - InvertedHammer - HangingMan - ShootingStar - Engulfing - Harami - MorningEveningStar (signed: +1.0 morning star, -1.0 evening star) - ThreeSoldiersOrCrows (signed: +1.0 soldiers, -1.0 crows) - PiercingDarkCloud (signed: +1.0 piercing, -1.0 dark cloud) - Marubozu (signed: +1.0 bullish, -1.0 bearish, 5 percent shadow tolerance default) - Tweezer (signed: +1.0 bottom, -1.0 top, 10 bps relative tolerance default) - SpinningTop (direction-signed indecision) - ThreeInside (confirmed Harami) - ThreeOutside (confirmed Engulfing) MVP scope notes: - Pattern-shape check only, no trend filter applied. Caller combines with a trend indicator for actionable signals. Documented in every doc comment. - Block B (Harmonic patterns) and block C (Chart patterns) remain out-of-scope and will follow when the pattern-detection framework (pivot detector, multi-bar state machines) lands. Touched across all bindings: Python, Node, WASM. Fuzz target, Python tests (streaming-vs-batch + reference values), Node tests (streaming-vs-batch + reference values), and a representative bench subset (1-, 2- and 3-bar patterns) added. README family table + indicator counter (71 -> 86, eight -> nine families) and CHANGELOG [Unreleased] updated. * fix(family-14): unpack MULTI values with *_ to handle 3-element tuples * cov(family-14): cover Default impl cold paths and MorningEveningStar guard branches
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
//! Doji candlestick pattern.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Doji — a candle whose body is negligible relative to its range.
|
||||
///
|
||||
/// A Doji prints whenever the absolute distance between open and close is
|
||||
/// small compared to the total `high − low` range. It is the canonical
|
||||
/// indecision bar and a building block for many three-bar reversal patterns.
|
||||
///
|
||||
/// ```text
|
||||
/// body = |close − open|
|
||||
/// range = high − low
|
||||
/// doji = body <= body_threshold * range
|
||||
/// ```
|
||||
///
|
||||
/// The output is `+1.0` when a Doji is detected and `0.0` otherwise. Doji is
|
||||
/// directionless — no `−1.0` is emitted. Pattern-shape check only — no trend
|
||||
/// filter is applied; combine with a trend indicator for actionable signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Doji, Indicator};
|
||||
///
|
||||
/// let mut indicator = Doji::default();
|
||||
/// let candle = Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0).unwrap();
|
||||
/// assert_eq!(indicator.update(candle), Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Doji {
|
||||
body_threshold: f64,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Default for Doji {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Doji {
|
||||
/// Construct a Doji detector with the default body threshold (`0.1`).
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
body_threshold: 0.1,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a Doji detector with a custom body / range threshold.
|
||||
///
|
||||
/// `body_threshold` must lie in `(0, 1]`.
|
||||
pub fn with_threshold(body_threshold: f64) -> Result<Self> {
|
||||
if !(body_threshold > 0.0 && body_threshold <= 1.0) {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "doji body threshold must lie in (0, 1]",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
body_threshold,
|
||||
has_emitted: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured body / range threshold.
|
||||
pub fn body_threshold(&self) -> f64 {
|
||||
self.body_threshold
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Doji {
|
||||
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).abs();
|
||||
Some(if body <= self.body_threshold * range {
|
||||
1.0
|
||||
} else {
|
||||
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 {
|
||||
"Doji"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 rejects_invalid_threshold() {
|
||||
assert!(Doji::with_threshold(0.0).is_err());
|
||||
assert!(Doji::with_threshold(-0.1).is_err());
|
||||
assert!(Doji::with_threshold(1.5).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_threshold() {
|
||||
let d = Doji::with_threshold(0.05).unwrap();
|
||||
assert!((d.body_threshold() - 0.05).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let d = Doji::default();
|
||||
assert_eq!(d.name(), "Doji");
|
||||
assert_eq!(d.warmup_period(), 1);
|
||||
assert!(!d.is_ready());
|
||||
assert!((d.body_threshold() - 0.1).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obvious_doji_is_one() {
|
||||
let mut d = Doji::new();
|
||||
// open == close, full range -> body / range = 0.
|
||||
assert_eq!(d.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(1.0));
|
||||
assert!(d.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marubozu_is_not_doji() {
|
||||
// Big body, no shadows -> body / range = 1.0 > 0.1.
|
||||
let mut d = Doji::new();
|
||||
assert_eq!(d.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_yields_zero() {
|
||||
let mut d = Doji::new();
|
||||
assert_eq!(d.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base, base + 2.0, base - 2.0, base + 1.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Doji::new();
|
||||
let mut b = Doji::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut d = Doji::new();
|
||||
d.update(c(10.0, 11.0, 9.0, 10.0, 0));
|
||||
assert!(d.is_ready());
|
||||
d.reset();
|
||||
assert!(!d.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Bullish / Bearish Engulfing candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Engulfing — a 2-bar reversal pattern. The current candle's body fully
|
||||
/// engulfs the prior candle's body and points in the opposite direction.
|
||||
///
|
||||
/// ```text
|
||||
/// prev_body = |prev.close − prev.open|
|
||||
/// curr_body = |curr.close − curr.open|
|
||||
/// bullish = prev red & curr green
|
||||
/// & curr.open <= prev.close & curr.close >= prev.open
|
||||
/// & curr_body > prev_body
|
||||
/// bearish = prev green & curr red
|
||||
/// & curr.open >= prev.close & curr.close <= prev.open
|
||||
/// & curr_body > prev_body
|
||||
/// ```
|
||||
///
|
||||
/// Output is `+1.0` for a bullish engulfing, `−1.0` for a bearish one, and
|
||||
/// `0.0` otherwise. The first bar always returns `0.0` because no previous
|
||||
/// body exists to engulf. Pattern-shape check only — no trend filter is
|
||||
/// applied; combine with a trend indicator for actionable signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Engulfing, Indicator};
|
||||
///
|
||||
/// let mut indicator = Engulfing::new();
|
||||
/// // Prior red candle followed by a larger green engulfing candle.
|
||||
/// indicator.update(Candle::new(11.0, 11.2, 9.8, 10.0, 1.0, 0).unwrap());
|
||||
/// let out = indicator
|
||||
/// .update(Candle::new(9.5, 12.0, 9.5, 11.5, 1.0, 1).unwrap());
|
||||
/// assert_eq!(out, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Engulfing {
|
||||
prev: Option<Candle>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Engulfing {
|
||||
/// Construct a new Engulfing detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev: None,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Engulfing {
|
||||
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(p) = prev else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let prev_body = (p.close - p.open).abs();
|
||||
let curr_body = (candle.close - candle.open).abs();
|
||||
if prev_body <= 0.0 || curr_body <= prev_body {
|
||||
return Some(0.0);
|
||||
}
|
||||
let prev_red = p.close < p.open;
|
||||
let prev_green = p.close > p.open;
|
||||
let curr_green = candle.close > candle.open;
|
||||
let curr_red = candle.close < candle.open;
|
||||
if prev_red && curr_green && candle.open <= p.close && candle.close >= p.open {
|
||||
Some(1.0)
|
||||
} else if prev_green && curr_red && candle.open >= p.close && candle.close <= p.open {
|
||||
Some(-1.0)
|
||||
} else {
|
||||
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 {
|
||||
"Engulfing"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 e = Engulfing::new();
|
||||
assert_eq!(e.name(), "Engulfing");
|
||||
assert_eq!(e.warmup_period(), 2);
|
||||
assert!(!e.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_engulfing_is_plus_one() {
|
||||
let mut e = Engulfing::new();
|
||||
// Prior red 11 -> 10, current green 9.5 -> 11.5 (body 2 > 1).
|
||||
assert_eq!(e.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0));
|
||||
assert_eq!(e.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_engulfing_is_minus_one() {
|
||||
let mut e = Engulfing::new();
|
||||
// Prior green 10 -> 11, current red 12 -> 9.
|
||||
assert_eq!(e.update(c(10.0, 11.2, 9.8, 11.0, 0)), Some(0.0));
|
||||
assert_eq!(e.update(c(12.0, 12.0, 9.0, 9.0, 1)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_direction_is_not_engulfing() {
|
||||
let mut e = Engulfing::new();
|
||||
e.update(c(10.0, 11.0, 9.8, 11.0, 0));
|
||||
// Another green candle that engulfs but matches direction -> 0.
|
||||
assert_eq!(e.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smaller_body_is_not_engulfing() {
|
||||
let mut e = Engulfing::new();
|
||||
e.update(c(11.0, 11.2, 8.0, 8.5, 0));
|
||||
// Body 0.5 < 2.5 -> not engulfing.
|
||||
assert_eq!(e.update(c(8.6, 9.0, 8.4, 8.7, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_returns_zero() {
|
||||
let mut e = Engulfing::new();
|
||||
assert_eq!(e.update(c(10.0, 11.0, 9.0, 11.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
if i % 3 == 0 {
|
||||
c(base + 1.0, base + 1.5, base - 0.5, base, i)
|
||||
} else {
|
||||
c(base - 1.0, base + 2.0, base - 1.5, base + 2.0, i)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut a = Engulfing::new();
|
||||
let mut b = Engulfing::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut e = Engulfing::new();
|
||||
e.update(c(10.0, 11.0, 9.0, 11.0, 0));
|
||||
e.update(c(11.0, 12.0, 10.0, 12.0, 1));
|
||||
assert!(e.is_ready());
|
||||
e.reset();
|
||||
assert!(!e.is_ready());
|
||||
// After reset the next bar again has no prev.
|
||||
assert_eq!(e.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Hammer candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Hammer — a single-bar bullish reversal candidate.
|
||||
///
|
||||
/// A Hammer has a small real body sitting near the top of the bar, a long
|
||||
/// lower shadow at least twice the body, and a short or absent upper shadow.
|
||||
/// It is traditionally read as a rejection of lower prices.
|
||||
///
|
||||
/// ```text
|
||||
/// body = |close − open|
|
||||
/// upper_shadow = high − max(open, close)
|
||||
/// lower_shadow = min(open, close) − low
|
||||
/// hammer = lower_shadow >= 2 * body
|
||||
/// && upper_shadow <= body
|
||||
/// && body > 0
|
||||
/// ```
|
||||
///
|
||||
/// Output is `+1.0` when the shape matches, `0.0` otherwise. Pattern-shape
|
||||
/// check only — no trend filter is applied; combine with a trend indicator
|
||||
/// for actionable signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Hammer, Indicator};
|
||||
///
|
||||
/// let mut indicator = Hammer::new();
|
||||
/// // Open 10, close 10.5, low 5, high 10.6: long lower shadow, tiny upper.
|
||||
/// let candle = Candle::new(10.0, 10.6, 5.0, 10.5, 1.0, 0).unwrap();
|
||||
/// assert_eq!(indicator.update(candle), Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Hammer {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Hammer {
|
||||
/// Construct a new Hammer detector.
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Hammer {
|
||||
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).abs();
|
||||
if body <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let upper = candle.high - candle.open.max(candle.close);
|
||||
let lower = candle.open.min(candle.close) - candle.low;
|
||||
Some(if lower >= 2.0 * body && upper <= body {
|
||||
1.0
|
||||
} else {
|
||||
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 {
|
||||
"Hammer"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 h = Hammer::new();
|
||||
assert_eq!(h.name(), "Hammer");
|
||||
assert_eq!(h.warmup_period(), 1);
|
||||
assert!(!h.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_hammer_is_one() {
|
||||
let mut h = Hammer::new();
|
||||
// body 0.5 (10 -> 10.5), lower shadow 5.0, upper shadow 0.1.
|
||||
assert_eq!(h.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marubozu_is_not_hammer() {
|
||||
let mut h = Hammer::new();
|
||||
assert_eq!(h.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shooting_star_shape_is_not_hammer() {
|
||||
// Long upper, short lower -> not a hammer.
|
||||
let mut h = Hammer::new();
|
||||
assert_eq!(h.update(c(10.5, 15.0, 10.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doji_is_not_hammer() {
|
||||
let mut h = Hammer::new();
|
||||
assert_eq!(h.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_yields_zero() {
|
||||
let mut h = Hammer::new();
|
||||
assert_eq!(h.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base, base + 2.0, base - 4.0, base + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Hammer::new();
|
||||
let mut b = Hammer::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = Hammer::new();
|
||||
h.update(c(10.0, 10.6, 5.0, 10.5, 0));
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Hanging Man candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Hanging Man — a single-bar bearish reversal candidate.
|
||||
///
|
||||
/// A Hanging Man has the same geometry as a Hammer (small body near the top,
|
||||
/// long lower shadow ≥ 2× body, short upper shadow) but is read bearishly
|
||||
/// because it appears at the top of an uptrend.
|
||||
///
|
||||
/// ```text
|
||||
/// body = |close − open|
|
||||
/// upper_shadow = high − max(open, close)
|
||||
/// lower_shadow = min(open, close) − low
|
||||
/// hanging = lower_shadow >= 2 * body
|
||||
/// && upper_shadow <= body
|
||||
/// && body > 0
|
||||
/// ```
|
||||
///
|
||||
/// Output is `−1.0` when the shape matches, `0.0` otherwise. Pattern-shape
|
||||
/// check only — no trend filter is applied; combine with a trend indicator
|
||||
/// for actionable signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, HangingMan, Indicator};
|
||||
///
|
||||
/// let mut indicator = HangingMan::new();
|
||||
/// let candle = Candle::new(10.0, 10.6, 5.0, 10.5, 1.0, 0).unwrap();
|
||||
/// assert_eq!(indicator.update(candle), Some(-1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HangingMan {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl HangingMan {
|
||||
/// Construct a new Hanging Man detector.
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HangingMan {
|
||||
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).abs();
|
||||
if body <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let upper = candle.high - candle.open.max(candle.close);
|
||||
let lower = candle.open.min(candle.close) - candle.low;
|
||||
Some(if lower >= 2.0 * body && upper <= body {
|
||||
-1.0
|
||||
} else {
|
||||
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 {
|
||||
"HangingMan"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 h = HangingMan::new();
|
||||
assert_eq!(h.name(), "HangingMan");
|
||||
assert_eq!(h.warmup_period(), 1);
|
||||
assert!(!h.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_hanging_man_is_minus_one() {
|
||||
let mut h = HangingMan::new();
|
||||
assert_eq!(h.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marubozu_is_not_hanging_man() {
|
||||
let mut h = HangingMan::new();
|
||||
assert_eq!(h.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doji_is_not_hanging_man() {
|
||||
let mut h = HangingMan::new();
|
||||
assert_eq!(h.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_yields_zero() {
|
||||
let mut h = HangingMan::new();
|
||||
assert_eq!(h.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base, base + 2.0, base - 4.0, base + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = HangingMan::new();
|
||||
let mut b = HangingMan::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = HangingMan::new();
|
||||
h.update(c(10.0, 10.6, 5.0, 10.5, 0));
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Bullish / Bearish Harami candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Harami — a 2-bar reversal pattern. The current candle's body sits entirely
|
||||
/// inside the previous candle's body and points in the opposite direction.
|
||||
///
|
||||
/// ```text
|
||||
/// prev_body = |prev.close − prev.open|
|
||||
/// curr_body = |curr.close − curr.open|
|
||||
/// bullish = prev red & curr green
|
||||
/// & curr.open >= prev.close & curr.close <= prev.open
|
||||
/// & curr_body < prev_body
|
||||
/// bearish = prev green & curr red
|
||||
/// & curr.open <= prev.close & curr.close >= prev.open
|
||||
/// & curr_body < prev_body
|
||||
/// ```
|
||||
///
|
||||
/// Output is `+1.0` for a bullish harami (small green inside a prior red),
|
||||
/// `−1.0` for a bearish harami (small red inside a prior green), `0.0`
|
||||
/// otherwise. The first bar always returns `0.0`. Pattern-shape check only —
|
||||
/// no trend filter is applied; combine with a trend indicator for actionable
|
||||
/// signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Harami, Indicator};
|
||||
///
|
||||
/// let mut indicator = Harami::new();
|
||||
/// indicator.update(Candle::new(12.0, 12.5, 9.5, 10.0, 1.0, 0).unwrap());
|
||||
/// let out = indicator
|
||||
/// .update(Candle::new(10.5, 11.5, 10.4, 11.0, 1.0, 1).unwrap());
|
||||
/// assert_eq!(out, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Harami {
|
||||
prev: Option<Candle>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Harami {
|
||||
/// Construct a new Harami detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev: None,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Harami {
|
||||
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(p) = prev else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let prev_body = (p.close - p.open).abs();
|
||||
let curr_body = (candle.close - candle.open).abs();
|
||||
if prev_body <= 0.0 || curr_body <= 0.0 || curr_body >= prev_body {
|
||||
return Some(0.0);
|
||||
}
|
||||
let prev_red = p.close < p.open;
|
||||
let prev_green = p.close > p.open;
|
||||
let curr_green = candle.close > candle.open;
|
||||
let curr_red = candle.close < candle.open;
|
||||
// Bullish: small green strictly inside prior red body (open >= prev.close, close <= prev.open).
|
||||
if prev_red && curr_green && candle.open >= p.close && candle.close <= p.open {
|
||||
Some(1.0)
|
||||
} else if prev_green && curr_red && candle.open <= p.close && candle.close >= p.open {
|
||||
Some(-1.0)
|
||||
} else {
|
||||
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 {
|
||||
"Harami"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 h = Harami::new();
|
||||
assert_eq!(h.name(), "Harami");
|
||||
assert_eq!(h.warmup_period(), 2);
|
||||
assert!(!h.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_harami_is_plus_one() {
|
||||
let mut h = Harami::new();
|
||||
// Prior red 12 -> 10 (body 2). Current green 10.5 -> 11 inside.
|
||||
assert_eq!(h.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0));
|
||||
assert_eq!(h.update(c(10.5, 11.5, 10.4, 11.0, 1)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_harami_is_minus_one() {
|
||||
let mut h = Harami::new();
|
||||
// Prior green 10 -> 12 (body 2). Current red 11.5 -> 11 inside.
|
||||
assert_eq!(h.update(c(10.0, 12.5, 9.5, 12.0, 0)), Some(0.0));
|
||||
assert_eq!(h.update(c(11.5, 11.6, 10.9, 11.0, 1)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn larger_body_is_not_harami() {
|
||||
let mut h = Harami::new();
|
||||
h.update(c(11.0, 11.2, 9.8, 10.0, 0));
|
||||
// Current body bigger than prior.
|
||||
assert_eq!(h.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_direction_is_not_harami() {
|
||||
let mut h = Harami::new();
|
||||
h.update(c(10.0, 12.5, 9.5, 12.0, 0));
|
||||
// Smaller candle but also green -> 0.
|
||||
assert_eq!(h.update(c(11.0, 11.6, 10.9, 11.5, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_returns_zero() {
|
||||
let mut h = Harami::new();
|
||||
assert_eq!(h.update(c(10.0, 11.0, 9.0, 11.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
if i % 2 == 0 {
|
||||
c(base + 2.0, base + 2.5, base - 0.5, base, i)
|
||||
} else {
|
||||
c(base + 1.0, base + 1.5, base + 0.7, base + 1.3, i)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut a = Harami::new();
|
||||
let mut b = Harami::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = Harami::new();
|
||||
h.update(c(12.0, 12.5, 9.5, 10.0, 0));
|
||||
h.update(c(10.5, 11.5, 10.4, 11.0, 1));
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
// After reset the next bar again has no prev.
|
||||
assert_eq!(h.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Inverted Hammer candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Inverted Hammer — a single-bar bullish reversal candidate.
|
||||
///
|
||||
/// An Inverted Hammer is the mirror of a Hammer: small real body near the
|
||||
/// bottom of the bar, a long upper shadow at least twice the body and a
|
||||
/// short or absent lower shadow.
|
||||
///
|
||||
/// ```text
|
||||
/// body = |close − open|
|
||||
/// upper_shadow = high − max(open, close)
|
||||
/// lower_shadow = min(open, close) − low
|
||||
/// inverted = upper_shadow >= 2 * body
|
||||
/// && lower_shadow <= body
|
||||
/// && body > 0
|
||||
/// ```
|
||||
///
|
||||
/// Output is `+1.0` when the shape matches, `0.0` otherwise. Pattern-shape
|
||||
/// check only — no trend filter is applied; combine with a trend indicator
|
||||
/// for actionable signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, InvertedHammer};
|
||||
///
|
||||
/// let mut indicator = InvertedHammer::new();
|
||||
/// // Open 10, close 10.5, low 9.9, high 15: long upper shadow, tiny lower.
|
||||
/// let candle = Candle::new(10.0, 15.0, 9.9, 10.5, 1.0, 0).unwrap();
|
||||
/// assert_eq!(indicator.update(candle), Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct InvertedHammer {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl InvertedHammer {
|
||||
/// Construct a new Inverted Hammer detector.
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for InvertedHammer {
|
||||
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).abs();
|
||||
if body <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let upper = candle.high - candle.open.max(candle.close);
|
||||
let lower = candle.open.min(candle.close) - candle.low;
|
||||
Some(if upper >= 2.0 * body && lower <= body {
|
||||
1.0
|
||||
} else {
|
||||
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 {
|
||||
"InvertedHammer"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 h = InvertedHammer::new();
|
||||
assert_eq!(h.name(), "InvertedHammer");
|
||||
assert_eq!(h.warmup_period(), 1);
|
||||
assert!(!h.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_inverted_hammer_is_one() {
|
||||
let mut h = InvertedHammer::new();
|
||||
// body 0.5, upper shadow 4.5, lower shadow 0.1.
|
||||
assert_eq!(h.update(c(10.0, 15.0, 9.9, 10.5, 0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hammer_shape_is_not_inverted() {
|
||||
let mut h = InvertedHammer::new();
|
||||
assert_eq!(h.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doji_is_not_inverted_hammer() {
|
||||
let mut h = InvertedHammer::new();
|
||||
assert_eq!(h.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_yields_zero() {
|
||||
let mut h = InvertedHammer::new();
|
||||
assert_eq!(h.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base, base + 4.0, base - 0.1, base + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = InvertedHammer::new();
|
||||
let mut b = InvertedHammer::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = InvertedHammer::new();
|
||||
h.update(c(10.0, 15.0, 9.9, 10.5, 0));
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Marubozu candlestick pattern.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Marubozu — a single-bar strong-continuation candle with body equal to range
|
||||
/// and (almost) no shadows.
|
||||
///
|
||||
/// ```text
|
||||
/// range = high − low
|
||||
/// upper_shadow = high − max(open, close)
|
||||
/// lower_shadow = min(open, close) − low
|
||||
/// shadows OK = upper_shadow <= tol * range && lower_shadow <= tol * range
|
||||
/// ```
|
||||
///
|
||||
/// When the shadow tolerance is satisfied the output is `+1.0` for a bullish
|
||||
/// Marubozu (close > open) and `−1.0` for a bearish one (close < open). Any
|
||||
/// candle whose shadows exceed the tolerance — or whose body is zero — yields
|
||||
/// `0.0`.
|
||||
///
|
||||
/// `shadow_tolerance` defaults to `0.05` (5 % of the bar range allowed on each
|
||||
/// side) and must lie in `[0, 1)`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Marubozu};
|
||||
///
|
||||
/// let mut indicator = Marubozu::new();
|
||||
/// // Bullish marubozu: open == low, close == high.
|
||||
/// let candle = Candle::new(10.0, 12.0, 10.0, 12.0, 1.0, 0).unwrap();
|
||||
/// assert_eq!(indicator.update(candle), Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Marubozu {
|
||||
shadow_tolerance: f64,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Default for Marubozu {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Marubozu {
|
||||
/// Construct a Marubozu detector with the default 5 % shadow tolerance.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
shadow_tolerance: 0.05,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a Marubozu detector with a custom shadow tolerance.
|
||||
///
|
||||
/// `shadow_tolerance` must lie in `[0, 1)`.
|
||||
pub fn with_tolerance(shadow_tolerance: f64) -> Result<Self> {
|
||||
if !(0.0..1.0).contains(&shadow_tolerance) {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "marubozu shadow tolerance must lie in [0, 1)",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
shadow_tolerance,
|
||||
has_emitted: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured shadow tolerance.
|
||||
pub fn shadow_tolerance(&self) -> f64 {
|
||||
self.shadow_tolerance
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Marubozu {
|
||||
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 == 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let upper = candle.high - candle.open.max(candle.close);
|
||||
let lower = candle.open.min(candle.close) - candle.low;
|
||||
let tol = self.shadow_tolerance * range;
|
||||
if upper <= tol && lower <= tol {
|
||||
Some(if body > 0.0 { 1.0 } else { -1.0 })
|
||||
} else {
|
||||
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 {
|
||||
"Marubozu"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 rejects_invalid_tolerance() {
|
||||
assert!(Marubozu::with_tolerance(-0.01).is_err());
|
||||
assert!(Marubozu::with_tolerance(1.0).is_err());
|
||||
assert!(Marubozu::with_tolerance(2.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_tolerance() {
|
||||
let m = Marubozu::with_tolerance(0.0).unwrap();
|
||||
assert!((m.shadow_tolerance() - 0.0).abs() < 1e-12);
|
||||
let m = Marubozu::with_tolerance(0.5).unwrap();
|
||||
assert!((m.shadow_tolerance() - 0.5).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let m = Marubozu::default();
|
||||
assert_eq!(m.name(), "Marubozu");
|
||||
assert_eq!(m.warmup_period(), 1);
|
||||
assert!(!m.is_ready());
|
||||
assert!((m.shadow_tolerance() - 0.05).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_marubozu_is_plus_one() {
|
||||
let mut m = Marubozu::new();
|
||||
assert_eq!(m.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_marubozu_is_minus_one() {
|
||||
let mut m = Marubozu::new();
|
||||
assert_eq!(m.update(c(12.0, 12.0, 10.0, 10.0, 0)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candle_with_long_shadows_is_zero() {
|
||||
let mut m = Marubozu::new();
|
||||
// Big upper shadow violates tolerance.
|
||||
assert_eq!(m.update(c(10.0, 15.0, 10.0, 12.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doji_is_zero() {
|
||||
let mut m = Marubozu::new();
|
||||
// body == 0 -> not a marubozu.
|
||||
assert_eq!(m.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_yields_zero() {
|
||||
let mut m = Marubozu::new();
|
||||
assert_eq!(m.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base, base + 2.0, base, base + 2.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Marubozu::new();
|
||||
let mut b = Marubozu::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut m = Marubozu::new();
|
||||
m.update(c(10.0, 12.0, 10.0, 12.0, 0));
|
||||
assert!(m.is_ready());
|
||||
m.reset();
|
||||
assert!(!m.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ mod dema;
|
||||
mod demand_index;
|
||||
mod demark_pivots;
|
||||
mod detrended_std_dev;
|
||||
mod doji;
|
||||
mod donchian;
|
||||
mod donchian_stop;
|
||||
mod double_bollinger;
|
||||
@@ -58,6 +59,7 @@ mod ehlers_stochastic;
|
||||
mod elder_impulse;
|
||||
mod ema;
|
||||
mod empirical_mode_decomposition;
|
||||
mod engulfing;
|
||||
mod evwma;
|
||||
mod fama;
|
||||
mod fibonacci_pivots;
|
||||
@@ -66,6 +68,9 @@ mod force_index;
|
||||
mod fractal_chaos_bands;
|
||||
mod frama;
|
||||
mod garman_klass;
|
||||
mod hammer;
|
||||
mod hanging_man;
|
||||
mod harami;
|
||||
mod heikin_ashi;
|
||||
mod hilbert_dominant_cycle;
|
||||
mod hilo_activator;
|
||||
@@ -78,6 +83,7 @@ mod inertia;
|
||||
mod initial_balance;
|
||||
mod instantaneous_trendline;
|
||||
mod inverse_fisher_transform;
|
||||
mod inverted_hammer;
|
||||
mod jma;
|
||||
mod kama;
|
||||
mod keltner;
|
||||
@@ -93,12 +99,14 @@ mod ma_envelope;
|
||||
mod macd;
|
||||
mod mama;
|
||||
mod market_facilitation_index;
|
||||
mod marubozu;
|
||||
mod mass_index;
|
||||
mod mcginley_dynamic;
|
||||
mod median_absolute_deviation;
|
||||
mod median_price;
|
||||
mod mfi;
|
||||
mod mom;
|
||||
mod morning_evening_star;
|
||||
mod natr;
|
||||
mod nvi;
|
||||
mod obv;
|
||||
@@ -108,6 +116,7 @@ mod pearson_correlation;
|
||||
mod percent_b;
|
||||
mod percentage_trailing_stop;
|
||||
mod pgo;
|
||||
mod piercing_dark_cloud;
|
||||
mod pmo;
|
||||
mod ppo;
|
||||
mod psar;
|
||||
@@ -121,12 +130,14 @@ mod rsi;
|
||||
mod rvi;
|
||||
mod rvi_volatility;
|
||||
mod rwi;
|
||||
mod shooting_star;
|
||||
mod sine_wave;
|
||||
mod skewness;
|
||||
mod sma;
|
||||
mod smi;
|
||||
mod smma;
|
||||
mod spearman_correlation;
|
||||
mod spinning_top;
|
||||
mod standard_error;
|
||||
mod standard_error_bands;
|
||||
mod starc_bands;
|
||||
@@ -151,6 +162,9 @@ mod td_risk_level;
|
||||
mod td_sequential;
|
||||
mod td_setup;
|
||||
mod tema;
|
||||
mod three_inside;
|
||||
mod three_outside;
|
||||
mod three_soldiers_or_crows;
|
||||
mod tii;
|
||||
mod trima;
|
||||
mod trix;
|
||||
@@ -158,6 +172,7 @@ mod true_range;
|
||||
mod tsi;
|
||||
mod tsv;
|
||||
mod ttm_squeeze;
|
||||
mod tweezer;
|
||||
mod typical_price;
|
||||
mod ulcer_index;
|
||||
mod ultimate_oscillator;
|
||||
@@ -231,6 +246,7 @@ pub use dema::Dema;
|
||||
pub use demand_index::DemandIndex;
|
||||
pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput};
|
||||
pub use detrended_std_dev::DetrendedStdDev;
|
||||
pub use doji::Doji;
|
||||
pub use donchian::{Donchian, DonchianOutput};
|
||||
pub use donchian_stop::{DonchianStop, DonchianStopOutput};
|
||||
pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput};
|
||||
@@ -240,6 +256,7 @@ pub use ehlers_stochastic::EhlersStochastic;
|
||||
pub use elder_impulse::ElderImpulse;
|
||||
pub use ema::Ema;
|
||||
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
|
||||
pub use engulfing::Engulfing;
|
||||
pub use evwma::Evwma;
|
||||
pub use fama::Fama;
|
||||
pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput};
|
||||
@@ -248,6 +265,9 @@ pub use force_index::ForceIndex;
|
||||
pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput};
|
||||
pub use frama::Frama;
|
||||
pub use garman_klass::GarmanKlassVolatility;
|
||||
pub use hammer::Hammer;
|
||||
pub use hanging_man::HangingMan;
|
||||
pub use harami::Harami;
|
||||
pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
|
||||
pub use hilbert_dominant_cycle::HilbertDominantCycle;
|
||||
pub use hilo_activator::HiLoActivator;
|
||||
@@ -260,6 +280,7 @@ pub use inertia::Inertia;
|
||||
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
|
||||
pub use instantaneous_trendline::InstantaneousTrendline;
|
||||
pub use inverse_fisher_transform::InverseFisherTransform;
|
||||
pub use inverted_hammer::InvertedHammer;
|
||||
pub use jma::Jma;
|
||||
pub use kama::Kama;
|
||||
pub use keltner::{Keltner, KeltnerOutput};
|
||||
@@ -275,12 +296,14 @@ pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput};
|
||||
pub use macd::{MacdIndicator, MacdOutput};
|
||||
pub use mama::{Mama, MamaOutput};
|
||||
pub use market_facilitation_index::MarketFacilitationIndex;
|
||||
pub use marubozu::Marubozu;
|
||||
pub use mass_index::MassIndex;
|
||||
pub use mcginley_dynamic::McGinleyDynamic;
|
||||
pub use median_absolute_deviation::MedianAbsoluteDeviation;
|
||||
pub use median_price::MedianPrice;
|
||||
pub use mfi::Mfi;
|
||||
pub use mom::Mom;
|
||||
pub use morning_evening_star::MorningEveningStar;
|
||||
pub use natr::Natr;
|
||||
pub use nvi::Nvi;
|
||||
pub use obv::Obv;
|
||||
@@ -290,6 +313,7 @@ pub use pearson_correlation::PearsonCorrelation;
|
||||
pub use percent_b::PercentB;
|
||||
pub use percentage_trailing_stop::PercentageTrailingStop;
|
||||
pub use pgo::Pgo;
|
||||
pub use piercing_dark_cloud::PiercingDarkCloud;
|
||||
pub use pmo::Pmo;
|
||||
pub use ppo::Ppo;
|
||||
pub use psar::Psar;
|
||||
@@ -303,12 +327,14 @@ pub use rsi::Rsi;
|
||||
pub use rvi::Rvi;
|
||||
pub use rvi_volatility::RviVolatility;
|
||||
pub use rwi::{Rwi, RwiOutput};
|
||||
pub use shooting_star::ShootingStar;
|
||||
pub use sine_wave::SineWave;
|
||||
pub use skewness::Skewness;
|
||||
pub use sma::Sma;
|
||||
pub use smi::Smi;
|
||||
pub use smma::Smma;
|
||||
pub use spearman_correlation::SpearmanCorrelation;
|
||||
pub use spinning_top::SpinningTop;
|
||||
pub use standard_error::StandardError;
|
||||
pub use standard_error_bands::{StandardErrorBands, StandardErrorBandsOutput};
|
||||
pub use starc_bands::{StarcBands, StarcBandsOutput};
|
||||
@@ -333,6 +359,9 @@ pub use td_risk_level::{TdRiskLevel, TdRiskLevelOutput};
|
||||
pub use td_sequential::{TdSequential, TdSequentialOutput};
|
||||
pub use td_setup::TdSetup;
|
||||
pub use tema::Tema;
|
||||
pub use three_inside::ThreeInside;
|
||||
pub use three_outside::ThreeOutside;
|
||||
pub use three_soldiers_or_crows::ThreeSoldiersOrCrows;
|
||||
pub use tii::Tii;
|
||||
pub use trima::Trima;
|
||||
pub use trix::Trix;
|
||||
@@ -340,6 +369,7 @@ pub use true_range::TrueRange;
|
||||
pub use tsi::Tsi;
|
||||
pub use tsv::Tsv;
|
||||
pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
|
||||
pub use tweezer::Tweezer;
|
||||
pub use typical_price::TypicalPrice;
|
||||
pub use ulcer_index::UlcerIndex;
|
||||
pub use ultimate_oscillator::UltimateOscillator;
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Morning Star / Evening Star candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Morning Star / Evening Star — a 3-bar reversal pattern.
|
||||
///
|
||||
/// **Morning Star** (bullish, `+1.0`):
|
||||
/// 1. Bar 1 is a long red candle.
|
||||
/// 2. Bar 2 has a small body (the "star") — colour does not matter.
|
||||
/// 3. Bar 3 is a long green candle that closes above the midpoint of Bar 1.
|
||||
///
|
||||
/// **Evening Star** (bearish, `−1.0`): the mirror image — long green, small
|
||||
/// body, long red closing below Bar 1's midpoint.
|
||||
///
|
||||
/// The "long" qualifier is enforced by requiring the outer bars' bodies to be
|
||||
/// at least twice the size of the star's body. Pattern-shape check only — no
|
||||
/// trend filter is applied; combine with a trend indicator for actionable
|
||||
/// signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, MorningEveningStar};
|
||||
///
|
||||
/// let mut indicator = MorningEveningStar::new();
|
||||
/// indicator.update(Candle::new(12.0, 12.2, 9.5, 10.0, 1.0, 0).unwrap());
|
||||
/// indicator.update(Candle::new(9.9, 10.1, 9.7, 9.95, 1.0, 1).unwrap());
|
||||
/// let out = indicator
|
||||
/// .update(Candle::new(10.1, 12.0, 10.0, 11.8, 1.0, 2).unwrap());
|
||||
/// assert_eq!(out, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MorningEveningStar {
|
||||
prev: Option<Candle>,
|
||||
prev_prev: Option<Candle>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl MorningEveningStar {
|
||||
/// Construct a new Morning / Evening Star detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev: None,
|
||||
prev_prev: None,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for MorningEveningStar {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let pp = self.prev_prev;
|
||||
let p = self.prev;
|
||||
self.prev_prev = self.prev;
|
||||
self.prev = Some(candle);
|
||||
let (Some(b1), Some(b2)) = (pp, p) else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let body1 = (b1.close - b1.open).abs();
|
||||
let body2 = (b2.close - b2.open).abs();
|
||||
let body3 = (candle.close - candle.open).abs();
|
||||
if body1 <= 0.0 || body3 <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
// Star body must be small relative to the outer bars.
|
||||
if body1 < 2.0 * body2 || body3 < 2.0 * body2 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let mid1 = f64::midpoint(b1.open, b1.close);
|
||||
let bar1_red = b1.close < b1.open;
|
||||
let bar1_green = b1.close > b1.open;
|
||||
let bar3_green = candle.close > candle.open;
|
||||
let bar3_red = candle.close < candle.open;
|
||||
if bar1_red && bar3_green && candle.close > mid1 {
|
||||
Some(1.0)
|
||||
} else if bar1_green && bar3_red && candle.close < mid1 {
|
||||
Some(-1.0)
|
||||
} else {
|
||||
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 {
|
||||
"MorningEveningStar"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 m = MorningEveningStar::new();
|
||||
assert_eq!(m.name(), "MorningEveningStar");
|
||||
assert_eq!(m.warmup_period(), 3);
|
||||
assert!(!m.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morning_star_is_plus_one() {
|
||||
let mut m = MorningEveningStar::new();
|
||||
// Long red 12 -> 10 (body 2). Star small body. Long green 10.1 -> 11.8 (body 1.7).
|
||||
// Mid of bar 1 = 11. Bar 3 closes at 11.8 > 11.
|
||||
assert_eq!(m.update(c(12.0, 12.2, 9.5, 10.0, 0)), Some(0.0));
|
||||
assert_eq!(m.update(c(9.9, 10.1, 9.7, 9.95, 1)), Some(0.0));
|
||||
assert_eq!(m.update(c(10.1, 12.0, 10.0, 11.8, 2)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evening_star_is_minus_one() {
|
||||
let mut m = MorningEveningStar::new();
|
||||
// Long green 10 -> 12, star, long red 11.9 -> 10.2 (body 1.7).
|
||||
// Mid of bar 1 = 11. Bar 3 closes at 10.2 < 11.
|
||||
assert_eq!(m.update(c(10.0, 12.2, 9.8, 12.0, 0)), Some(0.0));
|
||||
assert_eq!(m.update(c(12.1, 12.3, 11.9, 12.05, 1)), Some(0.0));
|
||||
assert_eq!(m.update(c(11.9, 12.0, 10.1, 10.2, 2)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_star_body_is_not_star() {
|
||||
let mut m = MorningEveningStar::new();
|
||||
m.update(c(12.0, 12.2, 9.5, 10.0, 0));
|
||||
// Star body 1.5 -> body1=2, body3 needs to be >= 3 to satisfy 2*body2.
|
||||
m.update(c(9.5, 11.5, 9.5, 11.0, 1));
|
||||
assert_eq!(m.update(c(10.1, 12.0, 10.0, 11.8, 2)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_two_bars_return_zero() {
|
||||
let mut m = MorningEveningStar::new();
|
||||
assert_eq!(m.update(c(12.0, 12.2, 9.5, 10.0, 0)), Some(0.0));
|
||||
assert_eq!(m.update(c(9.9, 10.1, 9.7, 9.95, 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 + 2.0, base + 2.5, base - 0.5, base, i),
|
||||
1 => c(base + 0.1, base + 0.3, base - 0.1, base + 0.15, i),
|
||||
_ => c(base, base + 2.5, base - 0.5, base + 2.0, i),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut a = MorningEveningStar::new();
|
||||
let mut b = MorningEveningStar::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut m = MorningEveningStar::new();
|
||||
m.update(c(12.0, 12.2, 9.5, 10.0, 0));
|
||||
m.update(c(9.9, 10.1, 9.7, 9.95, 1));
|
||||
m.update(c(10.1, 12.0, 10.0, 11.8, 2));
|
||||
assert!(m.is_ready());
|
||||
m.reset();
|
||||
assert!(!m.is_ready());
|
||||
assert_eq!(m.update(c(12.0, 12.2, 9.5, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doji_outer_bar_yields_zero() {
|
||||
// Bar1 is a doji (body == 0): body1 == 0 -> guard triggers, returns 0.
|
||||
let mut m = MorningEveningStar::new();
|
||||
m.update(c(10.0, 11.0, 9.0, 10.0, 0)); // doji bar1
|
||||
m.update(c(9.9, 10.1, 9.7, 9.95, 1));
|
||||
assert_eq!(m.update(c(10.1, 12.0, 10.0, 11.8, 2)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_direction_bars_yield_zero() {
|
||||
// Bar1 red, star small, bar3 also red (wrong direction) -> falls through to else 0.
|
||||
let mut m = MorningEveningStar::new();
|
||||
m.update(c(12.0, 12.2, 9.5, 10.0, 0)); // long red (body 2)
|
||||
m.update(c(9.9, 10.1, 9.7, 9.95, 1)); // small star
|
||||
// Bar3 red, closes below mid (11); doesn't match morning star (bar3 must be green)
|
||||
// and also doesn't match evening star (bar1 must be green).
|
||||
assert_eq!(m.update(c(11.0, 11.2, 9.0, 9.5, 2)), Some(0.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Piercing Line / Dark Cloud Cover candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Piercing Line / Dark Cloud Cover — a 2-bar reversal pattern.
|
||||
///
|
||||
/// **Piercing Line** (bullish, `+1.0`):
|
||||
/// ```text
|
||||
/// prev_red & curr_green
|
||||
/// & curr.open < prev.low
|
||||
/// & curr.close > (prev.open + prev.close) / 2
|
||||
/// & curr.close < prev.open
|
||||
/// ```
|
||||
///
|
||||
/// **Dark Cloud Cover** (bearish, `−1.0`):
|
||||
/// ```text
|
||||
/// prev_green & curr_red
|
||||
/// & curr.open > prev.high
|
||||
/// & curr.close < (prev.open + prev.close) / 2
|
||||
/// & curr.close > prev.open
|
||||
/// ```
|
||||
///
|
||||
/// Output is `+1.0` for a Piercing Line, `−1.0` for a Dark Cloud Cover, and
|
||||
/// `0.0` otherwise. The first bar always returns `0.0`. Pattern-shape check
|
||||
/// only — no trend filter is applied; combine with a trend indicator for
|
||||
/// actionable signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, PiercingDarkCloud};
|
||||
///
|
||||
/// let mut indicator = PiercingDarkCloud::new();
|
||||
/// indicator.update(Candle::new(12.0, 12.5, 10.0, 10.0, 1.0, 0).unwrap());
|
||||
/// // Open below prev low, close above midpoint (11) but below prev open (12).
|
||||
/// let out = indicator
|
||||
/// .update(Candle::new(9.8, 11.8, 9.5, 11.5, 1.0, 1).unwrap());
|
||||
/// assert_eq!(out, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PiercingDarkCloud {
|
||||
prev: Option<Candle>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl PiercingDarkCloud {
|
||||
/// Construct a new Piercing Line / Dark Cloud Cover detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev: None,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for PiercingDarkCloud {
|
||||
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(p) = prev else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let prev_red = p.close < p.open;
|
||||
let prev_green = p.close > p.open;
|
||||
let curr_green = candle.close > candle.open;
|
||||
let curr_red = candle.close < candle.open;
|
||||
let mid = f64::midpoint(p.open, p.close);
|
||||
if prev_red
|
||||
&& curr_green
|
||||
&& candle.open < p.low
|
||||
&& candle.close > mid
|
||||
&& candle.close < p.open
|
||||
{
|
||||
Some(1.0)
|
||||
} else if prev_green
|
||||
&& curr_red
|
||||
&& candle.open > p.high
|
||||
&& candle.close < mid
|
||||
&& candle.close > p.open
|
||||
{
|
||||
Some(-1.0)
|
||||
} else {
|
||||
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 {
|
||||
"PiercingDarkCloud"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 p = PiercingDarkCloud::new();
|
||||
assert_eq!(p.name(), "PiercingDarkCloud");
|
||||
assert_eq!(p.warmup_period(), 2);
|
||||
assert!(!p.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn piercing_line_is_plus_one() {
|
||||
let mut p = PiercingDarkCloud::new();
|
||||
// Prev red: open 12, close 10. Curr green: opens at 9.8 (< prev low 10),
|
||||
// closes at 11.5 (> midpoint 11, < prev open 12).
|
||||
assert_eq!(p.update(c(12.0, 12.5, 10.0, 10.0, 0)), Some(0.0));
|
||||
assert_eq!(p.update(c(9.8, 11.8, 9.5, 11.5, 1)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_cloud_cover_is_minus_one() {
|
||||
let mut p = PiercingDarkCloud::new();
|
||||
// Prev green: open 10, close 12. Curr red: opens 12.3 (> prev high 12.2),
|
||||
// closes 10.5 (< midpoint 11, > prev open 10).
|
||||
assert_eq!(p.update(c(10.0, 12.2, 9.5, 12.0, 0)), Some(0.0));
|
||||
assert_eq!(p.update(c(12.3, 12.4, 10.4, 10.5, 1)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_below_midpoint_is_not_piercing() {
|
||||
let mut p = PiercingDarkCloud::new();
|
||||
p.update(c(12.0, 12.5, 10.0, 10.0, 0));
|
||||
// Closes only at 10.8 (below midpoint 11) -> not piercing.
|
||||
assert_eq!(p.update(c(9.8, 11.0, 9.5, 10.8, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_engulf_is_not_piercing() {
|
||||
let mut p = PiercingDarkCloud::new();
|
||||
p.update(c(12.0, 12.5, 10.0, 10.0, 0));
|
||||
// Closes above prev.open (12) -> engulfs, not piercing.
|
||||
assert_eq!(p.update(c(9.8, 13.0, 9.5, 12.5, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_returns_zero() {
|
||||
let mut p = PiercingDarkCloud::new();
|
||||
assert_eq!(p.update(c(12.0, 12.5, 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;
|
||||
if i % 2 == 0 {
|
||||
c(base + 2.0, base + 2.5, base, base, i)
|
||||
} else {
|
||||
c(base - 0.2, base + 1.8, base - 0.5, base + 1.5, i)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut a = PiercingDarkCloud::new();
|
||||
let mut b = PiercingDarkCloud::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut p = PiercingDarkCloud::new();
|
||||
p.update(c(12.0, 12.5, 10.0, 10.0, 0));
|
||||
p.update(c(9.8, 11.8, 9.5, 11.5, 1));
|
||||
assert!(p.is_ready());
|
||||
p.reset();
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.update(c(12.0, 12.5, 10.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Shooting Star candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Shooting Star — a single-bar bearish reversal candidate.
|
||||
///
|
||||
/// A Shooting Star has the same geometry as an Inverted Hammer (small body
|
||||
/// near the bottom, long upper shadow ≥ 2× body, short lower shadow) but is
|
||||
/// read bearishly because it appears at the top of an uptrend.
|
||||
///
|
||||
/// ```text
|
||||
/// body = |close − open|
|
||||
/// upper_shadow = high − max(open, close)
|
||||
/// lower_shadow = min(open, close) − low
|
||||
/// star = upper_shadow >= 2 * body
|
||||
/// && lower_shadow <= body
|
||||
/// && body > 0
|
||||
/// ```
|
||||
///
|
||||
/// Output is `−1.0` when the shape matches, `0.0` otherwise. Pattern-shape
|
||||
/// check only — no trend filter is applied; combine with a trend indicator
|
||||
/// for actionable signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ShootingStar};
|
||||
///
|
||||
/// let mut indicator = ShootingStar::new();
|
||||
/// let candle = Candle::new(10.0, 15.0, 9.9, 10.5, 1.0, 0).unwrap();
|
||||
/// assert_eq!(indicator.update(candle), Some(-1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ShootingStar {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl ShootingStar {
|
||||
/// Construct a new Shooting Star detector.
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ShootingStar {
|
||||
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).abs();
|
||||
if body <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let upper = candle.high - candle.open.max(candle.close);
|
||||
let lower = candle.open.min(candle.close) - candle.low;
|
||||
Some(if upper >= 2.0 * body && lower <= body {
|
||||
-1.0
|
||||
} else {
|
||||
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 {
|
||||
"ShootingStar"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 s = ShootingStar::new();
|
||||
assert_eq!(s.name(), "ShootingStar");
|
||||
assert_eq!(s.warmup_period(), 1);
|
||||
assert!(!s.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_shooting_star_is_minus_one() {
|
||||
let mut s = ShootingStar::new();
|
||||
assert_eq!(s.update(c(10.0, 15.0, 9.9, 10.5, 0)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hammer_shape_is_not_shooting_star() {
|
||||
let mut s = ShootingStar::new();
|
||||
assert_eq!(s.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doji_is_not_shooting_star() {
|
||||
let mut s = ShootingStar::new();
|
||||
assert_eq!(s.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_yields_zero() {
|
||||
let mut s = ShootingStar::new();
|
||||
assert_eq!(s.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base, base + 4.0, base - 0.1, base + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = ShootingStar::new();
|
||||
let mut b = ShootingStar::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut s = ShootingStar::new();
|
||||
s.update(c(10.0, 15.0, 9.9, 10.5, 0));
|
||||
assert!(s.is_ready());
|
||||
s.reset();
|
||||
assert!(!s.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Spinning Top candlestick pattern.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Spinning Top — a single-bar indecision candle with a small body and two
|
||||
/// long shadows.
|
||||
///
|
||||
/// ```text
|
||||
/// body = |close − open|
|
||||
/// upper_shadow = high − max(open, close)
|
||||
/// lower_shadow = min(open, close) − low
|
||||
/// range = high − low
|
||||
/// spinning = body <= body_threshold * range
|
||||
/// && upper_shadow >= 2 * body
|
||||
/// && lower_shadow >= 2 * body
|
||||
/// && body > 0
|
||||
/// ```
|
||||
///
|
||||
/// While direction is ambiguous by intent, the output is direction-signed so
|
||||
/// downstream filters can distinguish a green spinning top (`+1.0`) from a red
|
||||
/// one (`−1.0`). A clean Doji (body == 0) is *not* a Spinning Top.
|
||||
///
|
||||
/// `body_threshold` defaults to `0.3` and must lie in `(0, 1]`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, SpinningTop};
|
||||
///
|
||||
/// let mut indicator = SpinningTop::new();
|
||||
/// // Body 0.5, both shadows 3.0 -> spinning.
|
||||
/// let candle = Candle::new(10.0, 13.5, 7.0, 10.5, 1.0, 0).unwrap();
|
||||
/// assert_eq!(indicator.update(candle), Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpinningTop {
|
||||
body_threshold: f64,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Default for SpinningTop {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SpinningTop {
|
||||
/// Construct a Spinning Top detector with the default body threshold.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
body_threshold: 0.3,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a Spinning Top detector with a custom body / range threshold.
|
||||
pub fn with_threshold(body_threshold: f64) -> Result<Self> {
|
||||
if !(body_threshold > 0.0 && body_threshold <= 1.0) {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "spinning top body threshold must lie in (0, 1]",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
body_threshold,
|
||||
has_emitted: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured body / range threshold.
|
||||
pub fn body_threshold(&self) -> f64 {
|
||||
self.body_threshold
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for SpinningTop {
|
||||
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_signed = candle.close - candle.open;
|
||||
let body = body_signed.abs();
|
||||
if body <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
if body > self.body_threshold * range {
|
||||
return Some(0.0);
|
||||
}
|
||||
let upper = candle.high - candle.open.max(candle.close);
|
||||
let lower = candle.open.min(candle.close) - candle.low;
|
||||
if upper >= 2.0 * body && lower >= 2.0 * body {
|
||||
Some(if body_signed > 0.0 { 1.0 } else { -1.0 })
|
||||
} else {
|
||||
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 {
|
||||
"SpinningTop"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 rejects_invalid_threshold() {
|
||||
assert!(SpinningTop::with_threshold(0.0).is_err());
|
||||
assert!(SpinningTop::with_threshold(1.5).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_threshold() {
|
||||
let s = SpinningTop::with_threshold(0.25).unwrap();
|
||||
assert!((s.body_threshold() - 0.25).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let s = SpinningTop::default();
|
||||
assert_eq!(s.name(), "SpinningTop");
|
||||
assert_eq!(s.warmup_period(), 1);
|
||||
assert!(!s.is_ready());
|
||||
assert!((s.body_threshold() - 0.3).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn green_spinning_top_is_plus_one() {
|
||||
let mut s = SpinningTop::new();
|
||||
// body 0.5 (10 -> 10.5), upper 3.0, lower 3.0, range 6.5 -> 0.5/6.5 < 0.3.
|
||||
assert_eq!(s.update(c(10.0, 13.5, 7.0, 10.5, 0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn red_spinning_top_is_minus_one() {
|
||||
let mut s = SpinningTop::new();
|
||||
assert_eq!(s.update(c(10.5, 13.5, 7.0, 10.0, 0)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marubozu_is_not_spinning() {
|
||||
let mut s = SpinningTop::new();
|
||||
assert_eq!(s.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doji_is_not_spinning() {
|
||||
// body == 0 fails the body > 0 guard.
|
||||
let mut s = SpinningTop::new();
|
||||
assert_eq!(s.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hammer_shape_is_not_spinning_top() {
|
||||
// Lower shadow is long but upper is tiny -> only one long shadow.
|
||||
let mut s = SpinningTop::new();
|
||||
assert_eq!(s.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_yields_zero() {
|
||||
let mut s = SpinningTop::new();
|
||||
assert_eq!(s.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base, base + 3.0, base - 3.0, base + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = SpinningTop::new();
|
||||
let mut b = SpinningTop::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut s = SpinningTop::new();
|
||||
s.update(c(10.0, 13.5, 7.0, 10.5, 0));
|
||||
assert!(s.is_ready());
|
||||
s.reset();
|
||||
assert!(!s.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Three Inside Up / Down candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Three Inside Up / Down — a confirmed Harami: the first two bars form a
|
||||
/// Harami and the third bar confirms direction by closing beyond the first
|
||||
/// bar's body.
|
||||
///
|
||||
/// **Three Inside Up** (`+1.0`):
|
||||
/// 1. Bar 1 is a long red candle.
|
||||
/// 2. Bar 2 is a small green candle whose body sits inside Bar 1's body.
|
||||
/// 3. Bar 3 is a green candle whose close exceeds Bar 1's open.
|
||||
///
|
||||
/// **Three Inside Down** (`−1.0`): the mirror — long green, small red inside,
|
||||
/// red closing below Bar 1's open.
|
||||
///
|
||||
/// Pattern-shape check only — no trend filter is applied; combine with a trend
|
||||
/// indicator for actionable signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ThreeInside};
|
||||
///
|
||||
/// let mut indicator = ThreeInside::new();
|
||||
/// indicator.update(Candle::new(12.0, 12.5, 9.5, 10.0, 1.0, 0).unwrap());
|
||||
/// indicator.update(Candle::new(10.5, 11.5, 10.4, 11.0, 1.0, 1).unwrap());
|
||||
/// let out = indicator
|
||||
/// .update(Candle::new(11.0, 13.0, 10.9, 12.5, 1.0, 2).unwrap());
|
||||
/// assert_eq!(out, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ThreeInside {
|
||||
prev: Option<Candle>,
|
||||
prev_prev: Option<Candle>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl ThreeInside {
|
||||
/// Construct a new Three Inside Up / Down detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev: None,
|
||||
prev_prev: None,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ThreeInside {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let pp = self.prev_prev;
|
||||
let p = self.prev;
|
||||
self.prev_prev = self.prev;
|
||||
self.prev = Some(candle);
|
||||
let (Some(b1), Some(b2)) = (pp, p) else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let body1 = (b1.close - b1.open).abs();
|
||||
let body2 = (b2.close - b2.open).abs();
|
||||
if body1 <= 0.0 || body2 <= 0.0 || body2 >= body1 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let b1_red = b1.close < b1.open;
|
||||
let b1_green = b1.close > b1.open;
|
||||
let b2_green = b2.close > b2.open;
|
||||
let b2_red = b2.close < b2.open;
|
||||
let b3_green = candle.close > candle.open;
|
||||
let b3_red = candle.close < candle.open;
|
||||
// Bullish: prior red, inside green harami, then green confirms above b1.open.
|
||||
if b1_red
|
||||
&& b2_green
|
||||
&& b2.open >= b1.close
|
||||
&& b2.close <= b1.open
|
||||
&& b3_green
|
||||
&& candle.close > b1.open
|
||||
{
|
||||
return Some(1.0);
|
||||
}
|
||||
// Bearish: prior green, inside red harami, then red confirms below b1.open.
|
||||
if b1_green
|
||||
&& b2_red
|
||||
&& b2.open <= b1.close
|
||||
&& b2.close >= b1.open
|
||||
&& b3_red
|
||||
&& candle.close < b1.open
|
||||
{
|
||||
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 {
|
||||
"ThreeInside"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 = ThreeInside::new();
|
||||
assert_eq!(t.name(), "ThreeInside");
|
||||
assert_eq!(t.warmup_period(), 3);
|
||||
assert!(!t.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_inside_up_is_plus_one() {
|
||||
let mut t = ThreeInside::new();
|
||||
assert_eq!(t.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(10.5, 11.5, 10.4, 11.0, 1)), Some(0.0));
|
||||
// Bar 3 closes 12.5 > Bar 1 open 12.
|
||||
assert_eq!(t.update(c(11.0, 13.0, 10.9, 12.5, 2)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_inside_down_is_minus_one() {
|
||||
let mut t = ThreeInside::new();
|
||||
assert_eq!(t.update(c(10.0, 12.5, 9.5, 12.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(11.5, 11.6, 10.9, 11.0, 1)), Some(0.0));
|
||||
// Bar 3 closes 9.5 < Bar 1 open 10.
|
||||
assert_eq!(t.update(c(11.0, 11.1, 9.3, 9.5, 2)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unconfirmed_third_bar_yields_zero() {
|
||||
let mut t = ThreeInside::new();
|
||||
t.update(c(12.0, 12.5, 9.5, 10.0, 0));
|
||||
t.update(c(10.5, 11.5, 10.4, 11.0, 1));
|
||||
// Bar 3 green but closes below b1.open (12).
|
||||
assert_eq!(t.update(c(11.0, 11.8, 10.9, 11.5, 2)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_two_bars_return_zero() {
|
||||
let mut t = ThreeInside::new();
|
||||
assert_eq!(t.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(10.5, 11.5, 10.4, 11.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;
|
||||
c(base, base + 1.0, base - 0.5, base + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = ThreeInside::new();
|
||||
let mut b = ThreeInside::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = ThreeInside::new();
|
||||
t.update(c(12.0, 12.5, 9.5, 10.0, 0));
|
||||
t.update(c(10.5, 11.5, 10.4, 11.0, 1));
|
||||
t.update(c(11.0, 13.0, 10.9, 12.5, 2));
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Three Outside Up / Down candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Three Outside Up / Down — a confirmed Engulfing: the first two bars form
|
||||
/// an Engulfing pattern and the third bar confirms direction.
|
||||
///
|
||||
/// **Three Outside Up** (`+1.0`):
|
||||
/// 1. Bar 1 is a red candle.
|
||||
/// 2. Bar 2 is a green candle that engulfs Bar 1's body.
|
||||
/// 3. Bar 3 is a green candle with `close > b2.close`.
|
||||
///
|
||||
/// **Three Outside Down** (`−1.0`): the mirror — green, bearish engulfing,
|
||||
/// followed by a red bar closing below Bar 2's close.
|
||||
///
|
||||
/// Pattern-shape check only — no trend filter is applied; combine with a trend
|
||||
/// indicator for actionable signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ThreeOutside};
|
||||
///
|
||||
/// let mut indicator = ThreeOutside::new();
|
||||
/// indicator.update(Candle::new(11.0, 11.2, 9.8, 10.0, 1.0, 0).unwrap());
|
||||
/// indicator.update(Candle::new(9.5, 12.0, 9.5, 11.5, 1.0, 1).unwrap());
|
||||
/// let out = indicator
|
||||
/// .update(Candle::new(11.5, 13.0, 11.4, 12.5, 1.0, 2).unwrap());
|
||||
/// assert_eq!(out, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ThreeOutside {
|
||||
prev: Option<Candle>,
|
||||
prev_prev: Option<Candle>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl ThreeOutside {
|
||||
/// Construct a new Three Outside Up / Down detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev: None,
|
||||
prev_prev: None,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ThreeOutside {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let pp = self.prev_prev;
|
||||
let p = self.prev;
|
||||
self.prev_prev = self.prev;
|
||||
self.prev = Some(candle);
|
||||
let (Some(b1), Some(b2)) = (pp, p) else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let body1 = (b1.close - b1.open).abs();
|
||||
let body2 = (b2.close - b2.open).abs();
|
||||
if body1 <= 0.0 || body2 <= body1 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let b1_red = b1.close < b1.open;
|
||||
let b1_green = b1.close > b1.open;
|
||||
let b2_green = b2.close > b2.open;
|
||||
let b2_red = b2.close < b2.open;
|
||||
let b3_green = candle.close > candle.open;
|
||||
let b3_red = candle.close < candle.open;
|
||||
// Bullish: prior red, engulfing green (b2 fully engulfs b1 body), then green confirms above b2.close.
|
||||
if b1_red
|
||||
&& b2_green
|
||||
&& b2.open <= b1.close
|
||||
&& b2.close >= b1.open
|
||||
&& b3_green
|
||||
&& candle.close > b2.close
|
||||
{
|
||||
return Some(1.0);
|
||||
}
|
||||
// Bearish: prior green, engulfing red, then red confirms below b2.close.
|
||||
if b1_green
|
||||
&& b2_red
|
||||
&& b2.open >= b1.close
|
||||
&& b2.close <= b1.open
|
||||
&& b3_red
|
||||
&& candle.close < b2.close
|
||||
{
|
||||
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 {
|
||||
"ThreeOutside"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 = ThreeOutside::new();
|
||||
assert_eq!(t.name(), "ThreeOutside");
|
||||
assert_eq!(t.warmup_period(), 3);
|
||||
assert!(!t.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_outside_up_is_plus_one() {
|
||||
let mut t = ThreeOutside::new();
|
||||
assert_eq!(t.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(0.0));
|
||||
// Bar 3 green close 12.5 > b2.close 11.5.
|
||||
assert_eq!(t.update(c(11.5, 13.0, 11.4, 12.5, 2)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_outside_down_is_minus_one() {
|
||||
let mut t = ThreeOutside::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, 12.0, 9.0, 9.0, 1)), Some(0.0));
|
||||
// Bar 3 red close 8.0 < b2.close 9.0.
|
||||
assert_eq!(t.update(c(9.0, 9.1, 7.9, 8.0, 2)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unconfirmed_third_bar_yields_zero() {
|
||||
let mut t = ThreeOutside::new();
|
||||
t.update(c(11.0, 11.2, 9.8, 10.0, 0));
|
||||
t.update(c(9.5, 12.0, 9.5, 11.5, 1));
|
||||
// Bar 3 green but does not exceed b2.close 11.5.
|
||||
assert_eq!(t.update(c(11.0, 11.4, 10.9, 11.3, 2)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_two_bars_return_zero() {
|
||||
let mut t = ThreeOutside::new();
|
||||
assert_eq!(t.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(9.5, 12.0, 9.5, 11.5, 1)), 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 + 1.5, base - 0.5, base + 1.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = ThreeOutside::new();
|
||||
let mut b = ThreeOutside::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = ThreeOutside::new();
|
||||
t.update(c(11.0, 11.2, 9.8, 10.0, 0));
|
||||
t.update(c(9.5, 12.0, 9.5, 11.5, 1));
|
||||
t.update(c(11.5, 13.0, 11.4, 12.5, 2));
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Three White Soldiers / Three Black Crows candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Three White Soldiers / Three Black Crows — a 3-bar continuation pattern of
|
||||
/// three consecutive long candles in the same direction, each opening inside
|
||||
/// the previous body and closing beyond it.
|
||||
///
|
||||
/// **Three White Soldiers** (`+1.0`):
|
||||
/// ```text
|
||||
/// all three green & monotonically rising closes
|
||||
/// & each open in [prev.open, prev.close]
|
||||
/// & each close > prev.close
|
||||
/// ```
|
||||
///
|
||||
/// **Three Black Crows** (`−1.0`): the mirror — three red candles with
|
||||
/// monotonically falling closes.
|
||||
///
|
||||
/// Pattern-shape check only — no trend filter is applied; combine with a trend
|
||||
/// indicator for actionable signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ThreeSoldiersOrCrows};
|
||||
///
|
||||
/// let mut indicator = ThreeSoldiersOrCrows::new();
|
||||
/// indicator.update(Candle::new(10.0, 11.5, 9.9, 11.0, 1.0, 0).unwrap());
|
||||
/// indicator.update(Candle::new(10.5, 12.5, 10.4, 12.0, 1.0, 1).unwrap());
|
||||
/// let out = indicator
|
||||
/// .update(Candle::new(11.5, 13.5, 11.4, 13.0, 1.0, 2).unwrap());
|
||||
/// assert_eq!(out, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ThreeSoldiersOrCrows {
|
||||
prev: Option<Candle>,
|
||||
prev_prev: Option<Candle>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl ThreeSoldiersOrCrows {
|
||||
/// Construct a new Three White Soldiers / Black Crows detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev: None,
|
||||
prev_prev: None,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ThreeSoldiersOrCrows {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let pp = self.prev_prev;
|
||||
let p = self.prev;
|
||||
self.prev_prev = self.prev;
|
||||
self.prev = Some(candle);
|
||||
let (Some(b1), Some(b2)) = (pp, p) else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let g1 = b1.close > b1.open;
|
||||
let g2 = b2.close > b2.open;
|
||||
let g3 = candle.close > candle.open;
|
||||
let r1 = b1.close < b1.open;
|
||||
let r2 = b2.close < b2.open;
|
||||
let r3 = candle.close < candle.open;
|
||||
// Three White Soldiers
|
||||
if g1
|
||||
&& g2
|
||||
&& g3
|
||||
&& b2.close > b1.close
|
||||
&& candle.close > b2.close
|
||||
&& b2.open >= b1.open
|
||||
&& b2.open <= b1.close
|
||||
&& candle.open >= b2.open
|
||||
&& candle.open <= b2.close
|
||||
{
|
||||
return Some(1.0);
|
||||
}
|
||||
// Three Black Crows
|
||||
if r1
|
||||
&& r2
|
||||
&& r3
|
||||
&& b2.close < b1.close
|
||||
&& candle.close < b2.close
|
||||
&& b2.open <= b1.open
|
||||
&& b2.open >= b1.close
|
||||
&& candle.open <= b2.open
|
||||
&& candle.open >= b2.close
|
||||
{
|
||||
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 {
|
||||
"ThreeSoldiersOrCrows"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 = ThreeSoldiersOrCrows::new();
|
||||
assert_eq!(t.name(), "ThreeSoldiersOrCrows");
|
||||
assert_eq!(t.warmup_period(), 3);
|
||||
assert!(!t.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_white_soldiers_is_plus_one() {
|
||||
let mut t = ThreeSoldiersOrCrows::new();
|
||||
assert_eq!(t.update(c(10.0, 11.5, 9.9, 11.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(10.5, 12.5, 10.4, 12.0, 1)), Some(0.0));
|
||||
assert_eq!(t.update(c(11.5, 13.5, 11.4, 13.0, 2)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_black_crows_is_minus_one() {
|
||||
let mut t = ThreeSoldiersOrCrows::new();
|
||||
// Bar1 13->12 (red), Bar2 opens inside [12,13] at 12.5, closes 11.
|
||||
// Bar3 opens inside [11,12.5] at 11.5, closes 10.
|
||||
assert_eq!(t.update(c(13.0, 13.1, 11.9, 12.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(12.5, 12.6, 10.9, 11.0, 1)), Some(0.0));
|
||||
assert_eq!(t.update(c(11.5, 11.6, 9.9, 10.0, 2)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_directions_yield_zero() {
|
||||
let mut t = ThreeSoldiersOrCrows::new();
|
||||
t.update(c(10.0, 11.5, 9.9, 11.0, 0));
|
||||
t.update(c(11.0, 11.2, 10.0, 10.5, 1));
|
||||
assert_eq!(t.update(c(10.5, 11.5, 10.4, 11.4, 2)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_two_bars_return_zero() {
|
||||
let mut t = ThreeSoldiersOrCrows::new();
|
||||
assert_eq!(t.update(c(10.0, 11.5, 9.9, 11.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(10.5, 12.5, 10.4, 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;
|
||||
c(base, base + 1.5, base - 0.1, base + 1.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = ThreeSoldiersOrCrows::new();
|
||||
let mut b = ThreeSoldiersOrCrows::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = ThreeSoldiersOrCrows::new();
|
||||
t.update(c(10.0, 11.5, 9.9, 11.0, 0));
|
||||
t.update(c(10.5, 12.5, 10.4, 12.0, 1));
|
||||
t.update(c(11.5, 13.5, 11.4, 13.0, 2));
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.update(c(10.0, 11.5, 9.9, 11.0, 0)), Some(0.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Tweezer Top / Bottom candlestick pattern.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Tweezer — a 2-bar reversal pattern where two consecutive candles share an
|
||||
/// extreme.
|
||||
///
|
||||
/// ```text
|
||||
/// tol = tolerance * |prev.high| + tolerance * |prev.low| (per leg)
|
||||
/// tweezer_top = |curr.high − prev.high| <= tol_high
|
||||
/// tweezer_bot = |curr.low − prev.low| <= tol_low
|
||||
/// ```
|
||||
///
|
||||
/// The output is `−1.0` for a Tweezer Top (matched highs), `+1.0` for a
|
||||
/// Tweezer Bottom (matched lows), and `0.0` otherwise. If *both* extremes
|
||||
/// match — a flat pair of candles — the bottom wins by convention (bullish
|
||||
/// rejection of the low). `tolerance` defaults to `0.001` (10 bps relative)
|
||||
/// and must lie in `[0, 1)`.
|
||||
///
|
||||
/// Pattern-shape check only — no trend filter is applied; combine with a trend
|
||||
/// indicator for actionable signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Tweezer};
|
||||
///
|
||||
/// let mut indicator = Tweezer::new();
|
||||
/// indicator.update(Candle::new(11.0, 12.0, 9.5, 9.6, 1.0, 0).unwrap());
|
||||
/// // Matching low.
|
||||
/// let out = indicator.update(Candle::new(9.7, 10.5, 9.5, 10.2, 1.0, 1).unwrap());
|
||||
/// assert_eq!(out, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tweezer {
|
||||
tolerance: f64,
|
||||
prev: Option<Candle>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Default for Tweezer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Tweezer {
|
||||
/// Construct a Tweezer detector with the default relative tolerance (1e-3).
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
tolerance: 0.001,
|
||||
prev: None,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a Tweezer detector with a custom relative tolerance.
|
||||
///
|
||||
/// `tolerance` must lie in `[0, 1)`.
|
||||
pub fn with_tolerance(tolerance: f64) -> Result<Self> {
|
||||
if !(0.0..1.0).contains(&tolerance) {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "tweezer tolerance must lie in [0, 1)",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
tolerance,
|
||||
prev: None,
|
||||
has_emitted: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured relative tolerance.
|
||||
pub fn tolerance(&self) -> f64 {
|
||||
self.tolerance
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Tweezer {
|
||||
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(p) = prev else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let tol_high = self.tolerance * p.high.abs().max(candle.high.abs());
|
||||
let tol_low = self.tolerance * p.low.abs().max(candle.low.abs());
|
||||
let match_low = (candle.low - p.low).abs() <= tol_low;
|
||||
let match_high = (candle.high - p.high).abs() <= tol_high;
|
||||
if match_low {
|
||||
Some(1.0)
|
||||
} else if match_high {
|
||||
Some(-1.0)
|
||||
} else {
|
||||
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 {
|
||||
"Tweezer"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 rejects_invalid_tolerance() {
|
||||
assert!(Tweezer::with_tolerance(-0.01).is_err());
|
||||
assert!(Tweezer::with_tolerance(1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_tolerance() {
|
||||
let t = Tweezer::with_tolerance(0.0).unwrap();
|
||||
assert!((t.tolerance() - 0.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let t = Tweezer::default();
|
||||
assert_eq!(t.name(), "Tweezer");
|
||||
assert_eq!(t.warmup_period(), 2);
|
||||
assert!(!t.is_ready());
|
||||
assert!((t.tolerance() - 0.001).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tweezer_bottom_is_plus_one() {
|
||||
let mut t = Tweezer::new();
|
||||
assert_eq!(t.update(c(11.0, 12.0, 9.5, 9.6, 0)), Some(0.0));
|
||||
// Matching low 9.5.
|
||||
assert_eq!(t.update(c(9.7, 10.5, 9.5, 10.2, 1)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tweezer_top_is_minus_one() {
|
||||
let mut t = Tweezer::new();
|
||||
assert_eq!(t.update(c(9.0, 12.0, 8.5, 11.0, 0)), Some(0.0));
|
||||
// Matching high 12.0.
|
||||
assert_eq!(t.update(c(11.5, 12.0, 11.0, 11.4, 1)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_extremes_yield_zero() {
|
||||
let mut t = Tweezer::new();
|
||||
t.update(c(10.0, 11.0, 9.0, 10.5, 0));
|
||||
assert_eq!(t.update(c(10.6, 11.5, 9.6, 11.2, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_returns_zero() {
|
||||
let mut t = Tweezer::new();
|
||||
assert_eq!(t.update(c(10.0, 11.0, 9.0, 10.5, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matched_both_extremes_prefers_bottom() {
|
||||
// Identical candles match both highs and lows -> bottom (+1.0).
|
||||
let mut t = Tweezer::new();
|
||||
t.update(c(10.0, 11.0, 9.0, 10.5, 0));
|
||||
assert_eq!(t.update(c(10.0, 11.0, 9.0, 10.5, 1)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (i as f64 * 0.1).sin();
|
||||
c(base, base + 2.0, base - 2.0, base + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Tweezer::new();
|
||||
let mut b = Tweezer::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = Tweezer::new();
|
||||
t.update(c(10.0, 11.0, 9.0, 10.5, 0));
|
||||
t.update(c(10.0, 11.0, 9.0, 10.5, 1));
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.update(c(10.0, 11.0, 9.0, 10.5, 0)), Some(0.0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user