feat: signed candlestick directional ±1 encoding (Doji signed mode) (#111)

* feat(core): add signed dragonfly/gravestone encoding to Doji

Doji gains an opt-in `.signed()` mode that classifies a detected Doji by the
position of its body within the bar range: dragonfly (long lower shadow) emits
+1.0 (bullish), gravestone (long upper shadow) emits -1.0 (bearish), and a
long-legged/standard Doji emits 0.0. The default detection-flag behaviour
(+1.0/0.0) is unchanged, so existing callers are unaffected.

The other 14 candlestick patterns already emit the uniform +1 bull / -1 bear /
0 none convention; document that explicitly with a "Signed +-1 encoding"
section on each so the whole family is a consistent drop-in ML feature.

* feat(bindings): expose Doji signed mode in python, node, wasm

Hand-write the Doji binding in all three language bindings (instead of the
shared candle-pattern macro) so it accepts an opt-in `signed` flag and exposes
an `is_signed`/`isSigned` accessor:

- Python: `Doji(signed=False)` keyword argument
- Node: `new Doji(signed?)` optional constructor argument (index.d.ts/.js
  regenerated via napi build)
- WASM: `new Doji(signed?)` optional constructor argument

The default construction is unchanged, so existing callers keep the
direction-less +1/0 detection flag.

* test(bindings,fuzz): cover Doji signed dragonfly/gravestone encoding

- python: dragonfly(+1)/gravestone(-1)/neutral(0) and default-flag cases in
  test_known_values
- node: equivalent signed/default assertions in indicators.test.js
- fuzz: drive a signed Doji alongside the default in indicator_update_candle

* docs: document signed candlestick convention and Doji signed mode

README gains a candlestick sign-convention note; CHANGELOG records the new
opt-in Doji signed dragonfly/gravestone encoding under [Unreleased].
This commit is contained in:
kingchenc
2026-06-01 14:37:20 +02:00
committed by GitHub
parent 4631519885
commit 0479191b66
24 changed files with 531 additions and 15 deletions
+12
View File
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Signed Doji encoding.** `Doji` gains an opt-in `.signed()` mode
(`Doji(signed=True)` in Python, `new Doji(true)` in Node and WASM) that
classifies a detected Doji by the position of its body within the bar range —
a dragonfly (long lower shadow) emits `+1.0` (bullish), a gravestone (long
upper shadow) emits `1.0` (bearish), and a long-legged / standard Doji emits
`0.0` (neutral). The default construction is unchanged — a direction-less
`+1.0` / `0.0` detection flag — so existing callers are unaffected. This
completes the uniform `+1` bull / `1` bear / `0` none sign convention across
every candlestick pattern, making the family a drop-in machine-learning
feature where bullish and bearish instances share a single dimension.
## [0.4.1] - 2026-06-01
### Added
+6
View File
@@ -159,6 +159,12 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
Every candlestick pattern emits a signed per-bar value — `+1.0` bullish,
`1.0` bearish, `0.0` none — so the family drops straight into a feature matrix
as one column each. `Doji` is direction-less by default (`+1.0` / `0.0`);
construct it in signed mode (`Doji::new().signed()`, `Doji(signed=True)`,
`new Doji(true)`) for a dragonfly / gravestone `±1` reading.
Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically.
@@ -896,3 +896,19 @@ test('ALMA(3, 0.85, 6) reference value on [10, 20, 30]', () => {
// simple mean of 20.
assert.ok(out[2] > 20);
});
test('Doji signed mode encodes dragonfly/gravestone/neutral direction', () => {
// Default: direction-less detection flag (+1 doji / 0 otherwise).
const flag = new wickra.Doji();
assert.equal(flag.isSigned(), false);
assert.equal(flag.update(10, 11, 9, 10), 1); // body 0, range 2 -> doji
assert.equal(flag.update(10, 12, 10, 12), 0); // body == range -> not a doji
// Signed: classify a detected doji by its body position within the range.
const d = new wickra.Doji(true);
assert.equal(d.isSigned(), true);
assert.equal(d.update(10, 10.05, 6, 10), 1); // dragonfly -> bullish +1
assert.equal(d.update(10, 14, 9.95, 10), -1); // gravestone -> bearish -1
assert.equal(d.update(10, 12, 8, 10), 0); // long-legged -> neutral 0
assert.equal(d.update(10, 12, 10, 12), 0); // not a doji -> 0
});
+2 -1
View File
@@ -2055,12 +2055,13 @@ export declare class OpeningRange {
}
export type DojiNode = Doji
export declare class Doji {
constructor()
constructor(signed?: boolean | undefined | null)
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
isSigned(): boolean
}
export type HammerNode = Hammer
export declare class Hammer {
+76 -2
View File
@@ -8581,7 +8581,8 @@ impl OpeningRangeNode {
//
// All 15 patterns take Candles (open, high, low, close) and emit a signed f64
// signal per bar: +1.0 bullish, -1.0 bearish, 0.0 no pattern. Doji is
// direction-less and emits 0/+1 only.
// direction-less by default (0/+1); pass `signed = true` to its constructor for
// the dragonfly/gravestone signed +-1 encoding.
macro_rules! node_candle_pattern {
($node:ident, $inner:ty, $js:literal) => {
@@ -8652,7 +8653,80 @@ macro_rules! node_candle_pattern {
};
}
node_candle_pattern!(DojiNode, wc::Doji, "Doji");
// Doji is the one pattern with an opt-in signed mode, so it is hand-written
// rather than generated by `node_candle_pattern!`.
#[napi(js_name = "Doji")]
pub struct DojiNode {
inner: wc::Doji,
}
impl Default for DojiNode {
fn default() -> Self {
Self::new(None)
}
}
#[napi]
impl DojiNode {
#[napi(constructor)]
pub fn new(signed: Option<bool>) -> Self {
let inner = if signed.unwrap_or(false) {
wc::Doji::new().signed()
} else {
wc::Doji::new()
};
Self { inner }
}
#[napi]
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<f64>> {
let candle = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?;
Ok(self.inner.update(candle))
}
#[napi]
pub fn batch(
&mut self,
open: Vec<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"open, high, low, close must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(open.len());
for i in 0..open.len() {
let candle =
wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
#[napi(js_name = "isSigned")]
pub fn is_signed(&self) -> bool {
self.inner.is_signed()
}
}
node_candle_pattern!(HammerNode, wc::Hammer, "Hammer");
node_candle_pattern!(InvertedHammerNode, wc::InvertedHammer, "InvertedHammer");
node_candle_pattern!(HangingManNode, wc::HangingMan, "HangingMan");
+83 -3
View File
@@ -11433,8 +11433,9 @@ impl PyOpeningRange {
// ============================== Candlestick Patterns ==============================
//
// All 15 patterns take Candles and emit a signed f64 signal per bar:
// +1.0 bullish, -1.0 bearish, 0.0 no pattern. Doji is direction-less, so it
// uses +1.0 / 0.0 only.
// +1.0 bullish, -1.0 bearish, 0.0 no pattern. Doji is direction-less by
// default (+1.0 / 0.0); construct it with `signed=True` for the
// dragonfly/gravestone signed +-1 encoding.
macro_rules! candle_pattern_no_param {
($name:ident, $inner:ty, $repr:expr) => {
@@ -11505,7 +11506,86 @@ macro_rules! candle_pattern_no_param {
};
}
candle_pattern_no_param!(PyDoji, wc::Doji, "Doji");
// Doji is the one pattern with an opt-in signed mode, so it is hand-written
// rather than generated by `candle_pattern_no_param!`.
#[pyclass(name = "Doji", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyDoji {
inner: wc::Doji,
}
#[pymethods]
impl PyDoji {
#[new]
#[pyo3(signature = (signed = false))]
fn new(signed: bool) -> Self {
let inner = if signed {
wc::Doji::new().signed()
} else {
wc::Doji::new()
};
Self { inner }
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let o = open
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if o.len() != h.len() || h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"open, high, low, close must be equal length",
));
}
let mut out = Vec::with_capacity(o.len());
for i in 0..o.len() {
let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn is_signed(&self) -> bool {
self.inner.is_signed()
}
fn __repr__(&self) -> String {
format!(
"Doji(signed={})",
if self.inner.is_signed() {
"True"
} else {
"False"
}
)
}
}
candle_pattern_no_param!(PyHammer, wc::Hammer, "Hammer");
candle_pattern_no_param!(PyInvertedHammer, wc::InvertedHammer, "InvertedHammer");
candle_pattern_no_param!(PyHangingMan, wc::HangingMan, "HangingMan");
@@ -823,3 +823,27 @@ def test_yang_zhang_zero_movement_yields_zero():
ready = out[~np.isnan(out)]
assert ready.size > 0
np.testing.assert_allclose(ready, 0.0, atol=1e-12)
def test_doji_default_is_directionless_flag():
# Default Doji is a direction-less detection flag: +1 on a doji, 0 else.
d = ta.Doji()
assert d.is_signed() is False
# body 0, range 2 -> doji.
assert d.update((10.0, 11.0, 9.0, 10.0, 1.0, 0)) == pytest.approx(1.0)
# body 2 == range -> not a doji.
assert d.update((10.0, 12.0, 10.0, 12.0, 1.0, 1)) == pytest.approx(0.0)
def test_doji_signed_dragonfly_gravestone_neutral():
# Signed Doji classifies by body position within the range.
d = ta.Doji(signed=True)
assert d.is_signed() is True
# Dragonfly: body at the top, long lower shadow -> bullish +1.
assert d.update((10.0, 10.05, 6.0, 10.0, 1.0, 0)) == pytest.approx(1.0)
# Gravestone: body at the bottom, long upper shadow -> bearish -1.
assert d.update((10.0, 14.0, 9.95, 10.0, 1.0, 1)) == pytest.approx(-1.0)
# Long-legged: body centred, symmetric shadows -> neutral 0.
assert d.update((10.0, 12.0, 8.0, 10.0, 1.0, 2)) == pytest.approx(0.0)
# A large body is not a doji at all -> 0 regardless of position.
assert d.update((10.0, 12.0, 10.0, 12.0, 1.0, 3)) == pytest.approx(0.0)
+71 -2
View File
@@ -6167,7 +6167,8 @@ impl WasmOpeningRange {
//
// All 15 patterns take Candles (open, high, low, close) and emit a signed f64
// signal per bar: +1.0 bullish, -1.0 bearish, 0.0 no pattern. Doji is
// direction-less and emits 0/+1 only.
// direction-less by default (0/+1); pass `signed = true` to its constructor for
// the dragonfly/gravestone signed +-1 encoding.
macro_rules! wasm_candle_pattern {
($wasm:ident, $inner:ty, $js:ident) => {
@@ -6234,7 +6235,75 @@ macro_rules! wasm_candle_pattern {
};
}
wasm_candle_pattern!(WasmDoji, wc::Doji, Doji);
// Doji is the one pattern with an opt-in signed mode, so it is hand-written
// rather than generated by `wasm_candle_pattern!`.
#[wasm_bindgen(js_name = Doji)]
pub struct WasmDoji {
inner: wc::Doji,
}
impl Default for WasmDoji {
fn default() -> Self {
Self::new(None)
}
}
#[wasm_bindgen(js_class = Doji)]
impl WasmDoji {
#[wasm_bindgen(constructor)]
pub fn new(signed: Option<bool>) -> WasmDoji {
let inner = if signed.unwrap_or(false) {
wc::Doji::new().signed()
} else {
wc::Doji::new()
};
Self { inner }
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> Result<Option<f64>, JsError> {
let c = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = open.len();
if high.len() != n || low.len() != n || close.len() != n {
return Err(JsError::new("open, high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(n);
for i in 0..n {
let c = wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
#[wasm_bindgen(js_name = isSigned)]
pub fn is_signed(&self) -> bool {
self.inner.is_signed()
}
}
wasm_candle_pattern!(WasmHammer, wc::Hammer, Hammer);
wasm_candle_pattern!(WasmInvertedHammer, wc::InvertedHammer, InvertedHammer);
wasm_candle_pattern!(WasmHangingMan, wc::HangingMan, HangingMan);
+138 -7
View File
@@ -16,22 +16,44 @@ use crate::traits::Indicator;
/// 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.
/// # Signed ±1 encoding
///
/// By default the output is `+1.0` when a Doji is detected and `0.0`
/// otherwise — a direction-less detection flag. For a drop-in machine-learning
/// feature where every candlestick pattern shares the same sign convention
/// (`+1.0` bullish, `1.0` bearish, `0.0` none), switch the detector into
/// signed mode with [`Doji::signed`]. A detected Doji is then classified by
/// where its (negligible) body sits within the bar's range:
///
/// ```text
/// pos = (0.5 * (open + close) low) / (high low)
/// pos > 2/3 -> +1.0 dragonfly (long lower shadow, bullish)
/// pos < 1/3 -> 1.0 gravestone (long upper shadow, bearish)
/// else -> 0.0 long-legged / standard (neutral)
/// ```
///
/// Pattern-shape check only — no trend filter is applied; combine with a trend
/// indicator for actionable signals.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Doji, Indicator};
///
/// // Default: direction-less detection flag.
/// 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));
///
/// // Signed: a dragonfly Doji (body at the top, long lower shadow) is bullish.
/// let mut signed = Doji::new().signed();
/// let dragonfly = Candle::new(10.0, 10.05, 6.0, 10.0, 1.0, 0).unwrap();
/// assert_eq!(signed.update(dragonfly), Some(1.0));
/// ```
#[derive(Debug, Clone)]
pub struct Doji {
body_threshold: f64,
signed: bool,
has_emitted: bool,
}
@@ -46,6 +68,7 @@ impl Doji {
pub const fn new() -> Self {
Self {
body_threshold: 0.1,
signed: false,
has_emitted: false,
}
}
@@ -61,14 +84,32 @@ impl Doji {
}
Ok(Self {
body_threshold,
signed: false,
has_emitted: false,
})
}
/// Switch to the signed dragonfly / gravestone encoding (consuming builder).
///
/// In signed mode a detected Doji emits `+1.0` (dragonfly, bullish),
/// `1.0` (gravestone, bearish) or `0.0` (long-legged / neutral) instead of
/// the default direction-less `+1.0` detection flag. See the type-level
/// docs for the exact classification rule.
#[must_use]
pub fn signed(mut self) -> Self {
self.signed = true;
self
}
/// Configured body / range threshold.
pub fn body_threshold(&self) -> f64 {
self.body_threshold
}
/// Whether this detector emits the signed dragonfly / gravestone encoding.
pub fn is_signed(&self) -> bool {
self.signed
}
}
impl Indicator for Doji {
@@ -82,11 +123,23 @@ impl Indicator for Doji {
return Some(0.0);
}
let body = (candle.close - candle.open).abs();
Some(if body <= self.body_threshold * range {
1.0
if body > self.body_threshold * range {
return Some(0.0);
}
if !self.signed {
return Some(1.0);
}
// Signed mode: classify the Doji by where its (negligible) body sits
// within the highlow range.
let body_mid = 0.5 * (candle.open + candle.close);
let pos = (body_mid - candle.low) / range;
if pos > 2.0 / 3.0 {
Some(1.0)
} else if pos < 1.0 / 3.0 {
Some(-1.0)
} else {
0.0
})
Some(0.0)
}
}
fn reset(&mut self) {
@@ -134,6 +187,7 @@ mod tests {
assert_eq!(d.name(), "Doji");
assert_eq!(d.warmup_period(), 1);
assert!(!d.is_ready());
assert!(!d.is_signed());
assert!((d.body_threshold() - 0.1).abs() < 1e-12);
}
@@ -182,4 +236,81 @@ mod tests {
d.reset();
assert!(!d.is_ready());
}
#[test]
fn signed_accessor_and_builder() {
let d = Doji::new().signed();
assert!(d.is_signed());
// The consuming builder composes with `with_threshold`.
let t = Doji::with_threshold(0.05).unwrap().signed();
assert!(t.is_signed());
assert!((t.body_threshold() - 0.05).abs() < 1e-12);
}
#[test]
fn signed_dragonfly_is_plus_one() {
// Body at the top of the range, long lower shadow -> bullish.
let mut d = Doji::new().signed();
assert_eq!(d.update(c(10.0, 10.05, 6.0, 10.0, 0)), Some(1.0));
}
#[test]
fn signed_gravestone_is_minus_one() {
// Body at the bottom of the range, long upper shadow -> bearish.
let mut d = Doji::new().signed();
assert_eq!(d.update(c(10.0, 14.0, 9.95, 10.0, 0)), Some(-1.0));
}
#[test]
fn signed_long_legged_is_zero() {
// Body centred, symmetric shadows -> neutral.
let mut d = Doji::new().signed();
assert_eq!(d.update(c(10.0, 12.0, 8.0, 10.0, 0)), Some(0.0));
}
#[test]
fn signed_non_doji_is_zero() {
// A large body is not a Doji at all -> 0 regardless of position.
let mut d = Doji::new().signed();
assert_eq!(d.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0));
}
#[test]
fn signed_zero_range_is_zero() {
let mut d = Doji::new().signed();
assert_eq!(d.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
}
#[test]
fn signed_batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
// Alternate dragonfly / gravestone / centred Doji shapes.
match i % 3 {
0 => c(base, base + 0.05, base - 4.0, base, i),
1 => c(base, base + 4.0, base - 0.05, base, i),
_ => c(base, base + 2.0, base - 2.0, base, i),
}
})
.collect();
let mut a = Doji::new().signed();
let mut b = Doji::new().signed();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn signed_survives_reset() {
let mut d = Doji::new().signed();
d.update(c(10.0, 10.05, 6.0, 10.0, 0));
assert!(d.is_ready());
d.reset();
assert!(!d.is_ready());
// `reset` clears only the streaming state, not the signed configuration.
assert!(d.is_signed());
assert_eq!(d.update(c(10.0, 10.05, 6.0, 10.0, 1)), Some(1.0));
}
}
@@ -22,6 +22,13 @@ use crate::traits::Indicator;
/// body exists to engulf. Pattern-shape check only — no trend filter is
/// applied; combine with a trend indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -22,6 +22,14 @@ use crate::traits::Indicator;
/// check only — no trend filter is applied; combine with a trend indicator
/// for actionable signals.
///
/// # Signed ±1 encoding
///
/// A Hammer is bullish by definition, so under the uniform candlestick sign
/// convention (`+1.0` bullish, `1.0` bearish, `0.0` none) it emits `+1.0`
/// when the shape matches and `0.0` otherwise — it never emits `1.0`. The
/// same geometry read at the top of an uptrend is the bearish `HangingMan`,
/// which carries the opposite sign.
///
/// # Example
///
/// ```
@@ -22,6 +22,14 @@ use crate::traits::Indicator;
/// check only — no trend filter is applied; combine with a trend indicator
/// for actionable signals.
///
/// # Signed ±1 encoding
///
/// A Hanging Man is bearish by definition, so under the uniform candlestick
/// sign convention (`+1.0` bullish, `1.0` bearish, `0.0` none) it emits
/// `1.0` when the shape matches and `0.0` otherwise — it never emits `+1.0`.
/// The same geometry read at the bottom of a downtrend is the bullish
/// `Hammer`, which carries the opposite sign.
///
/// # Example
///
/// ```
@@ -23,6 +23,13 @@ use crate::traits::Indicator;
/// no trend filter is applied; combine with a trend indicator for actionable
/// signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -22,6 +22,14 @@ use crate::traits::Indicator;
/// check only — no trend filter is applied; combine with a trend indicator
/// for actionable signals.
///
/// # Signed ±1 encoding
///
/// An Inverted Hammer is bullish by definition, so under the uniform
/// candlestick sign convention (`+1.0` bullish, `1.0` bearish, `0.0` none) it
/// emits `+1.0` when the shape matches and `0.0` otherwise — it never emits
/// `1.0`. The same geometry read at the top of an uptrend is the bearish
/// `ShootingStar`, which carries the opposite sign.
///
/// # Example
///
/// ```
@@ -22,6 +22,13 @@ use crate::traits::Indicator;
/// `shadow_tolerance` defaults to `0.05` (5 % of the bar range allowed on each
/// side) and must lie in `[0, 1)`.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -18,6 +18,13 @@ use crate::traits::Indicator;
/// trend filter is applied; combine with a trend indicator for actionable
/// signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -26,6 +26,13 @@ use crate::traits::Indicator;
/// only — no trend filter is applied; combine with a trend indicator for
/// actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -22,6 +22,14 @@ use crate::traits::Indicator;
/// check only — no trend filter is applied; combine with a trend indicator
/// for actionable signals.
///
/// # Signed ±1 encoding
///
/// A Shooting Star is bearish by definition, so under the uniform candlestick
/// sign convention (`+1.0` bullish, `1.0` bearish, `0.0` none) it emits
/// `1.0` when the shape matches and `0.0` otherwise — it never emits `+1.0`.
/// The same geometry read at the bottom of a downtrend is the bullish
/// `InvertedHammer`, which carries the opposite sign.
///
/// # Example
///
/// ```
@@ -24,6 +24,13 @@ use crate::traits::Indicator;
///
/// `body_threshold` defaults to `0.3` and must lie in `(0, 1]`.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -18,6 +18,13 @@ use crate::traits::Indicator;
/// Pattern-shape check only — no trend filter is applied; combine with a trend
/// indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -17,6 +17,13 @@ use crate::traits::Indicator;
/// Pattern-shape check only — no trend filter is applied; combine with a trend
/// indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -20,6 +20,13 @@ use crate::traits::Indicator;
/// Pattern-shape check only — no trend filter is applied; combine with a trend
/// indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -22,6 +22,13 @@ use crate::traits::Indicator;
/// Pattern-shape check only — no trend filter is applied; combine with a trend
/// indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -295,6 +295,7 @@ fuzz_target!(|data: Vec<f64>| {
// --- Candlestick Patterns (family 14) ---
drive(Doji::new, &candles);
drive(|| Doji::new().signed(), &candles);
drive(Hammer::new, &candles);
drive(InvertedHammer::new, &candles);
drive(HangingMan::new, &candles);