Files
wickra/crates/wickra-core/src/indicators/hammer.rs
T
kingchencandGitHub 55284a3042 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
2026-05-26 00:54:11 +02:00

161 lines
4.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}
}