feat: TA-Lib candlestick patterns — doji-star/gap/high-wave/hikkake (part 4 of 9) (#135)

* feat: add doji-star, gap, high-wave and hikkake candlestick patterns

Five patterns, all `Input = Candle`, `Output = f64`:

- Evening Doji Star (CDLEVENINGDOJISTAR) — bearish top reversal: long white bar,
  a doji gapping up, then a black bar closing deep into the first body; -1
  (penetration configurable, default 0.3).
- Morning Doji Star (CDLMORNINGDOJISTAR) — bullish bottom reversal mirror; +1.
- Gap Side-by-Side White (CDLGAPSIDESIDEWHITE) — two similar white candles
  opening side by side after a gap, a continuation; gap up +1, gap down -1.
- High-Wave (CDLHIGHWAVE) — a small body with very long shadows on both sides,
  an extreme indecision flag; +1 on detection.
- Hikkake (CDLHIKKAKE) — an inside bar followed by a failed breakout (a trap);
  bullish +1, bearish -1.

Counter 259 -> 264 (mod-count == lib counted block; FAMILIES total 254 -> 259).

* chore: sync indicator count to 264

---------

Co-authored-by: wickra-bot <wickra-bot@users.noreply.github.com>
This commit is contained in:
kingchenc
2026-06-02 16:54:47 +02:00
committed by GitHub
co-authored by wickra-bot
parent 244d754707
commit d43bc9ddf3
19 changed files with 1280 additions and 45 deletions
@@ -0,0 +1,241 @@
//! Evening Doji Star candlestick pattern.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Evening Doji Star — a 3-bar bearish top reversal. A long white bar extends the
/// advance, a doji gaps up above it (the star of indecision), then a black bar
/// gaps back down and closes deep into the first body, confirming the turn.
///
/// ```text
/// long body = |close open| >= 0.5 * (high low)
/// doji = |close open| <= 0.1 * (high low)
/// bar1 white & long
/// bar2 doji, body gaps UP above bar1 body (min(o2,c2) > close1)
/// bar3 black, body gaps DOWN below the doji (max(o3,c3) < min(o2,c2))
/// bar3 closes deep into bar1 body (close3 < close1 penetration·body1)
/// ```
///
/// Output is `1.0` when the pattern completes and `0.0` otherwise. Evening Doji
/// Star is a single-direction (bearish-only) reversal, so it never emits `+1.0`.
/// The first two bars always return `0.0` because the three-bar window is not yet
/// filled. `penetration` is how far into the first body the third bar must close;
/// it defaults to `0.3` (TA-Lib's `CDLEVENINGDOJISTAR` default) and must lie in
/// `[0, 1)`. Body and doji thresholds follow the geometric house style rather than
/// TA-Lib's rolling averages. Pattern-shape check only — no trend filter is
/// applied; combine with a trend indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector emits the uniform candlestick sign convention shared across the
/// pattern family — `1.0` bearish, `0.0` no pattern — so it drops straight into
/// a machine-learning feature matrix as a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, EveningDojiStar, Indicator};
///
/// let mut indicator = EveningDojiStar::new();
/// indicator.update(Candle::new(10.0, 15.1, 9.9, 15.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(17.0, 17.1, 16.9, 17.0, 1.0, 1).unwrap());
/// let out = indicator
/// .update(Candle::new(16.0, 16.1, 11.9, 12.0, 1.0, 2).unwrap());
/// assert_eq!(out, Some(-1.0));
/// ```
#[derive(Debug, Clone)]
pub struct EveningDojiStar {
penetration: f64,
prev: Option<Candle>,
prev_prev: Option<Candle>,
has_emitted: bool,
}
impl Default for EveningDojiStar {
fn default() -> Self {
Self::new()
}
}
impl EveningDojiStar {
/// Construct an Evening Doji Star detector with the default 0.3 penetration.
pub const fn new() -> Self {
Self {
penetration: 0.3,
prev: None,
prev_prev: None,
has_emitted: false,
}
}
/// Construct an Evening Doji Star detector with a custom penetration fraction.
///
/// `penetration` must lie in `[0, 1)`.
pub fn with_penetration(penetration: f64) -> Result<Self> {
if !(0.0..1.0).contains(&penetration) {
return Err(Error::InvalidPeriod {
message: "evening doji star penetration must lie in [0, 1)",
});
}
Ok(Self {
penetration,
prev: None,
prev_prev: None,
has_emitted: false,
})
}
/// Configured penetration fraction.
pub fn penetration(&self) -> f64 {
self.penetration
}
}
impl Indicator for EveningDojiStar {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let bar1 = self.prev_prev;
let bar2 = self.prev;
self.prev_prev = self.prev;
self.prev = Some(candle);
let (Some(bar1), Some(bar2)) = (bar1, bar2) else {
return Some(0.0);
};
let range1 = bar1.high - bar1.low;
let range2 = bar2.high - bar2.low;
if range1 <= 0.0 || range2 <= 0.0 {
return Some(0.0);
}
let body1 = bar1.close - bar1.open;
if body1 < 0.5 * range1 {
return Some(0.0); // bar1 must be a long white body
}
if (bar2.close - bar2.open).abs() > 0.1 * range2 {
return Some(0.0); // bar2 must be a doji
}
let star_bottom = bar2.open.min(bar2.close);
let bar3_top = candle.open.max(candle.close);
if star_bottom > bar1.close
&& candle.close < candle.open
&& bar3_top < star_bottom
&& candle.close < bar1.close - self.penetration * body1
{
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 {
"EveningDojiStar"
}
}
#[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_penetration() {
assert!(EveningDojiStar::with_penetration(-0.01).is_err());
assert!(EveningDojiStar::with_penetration(1.0).is_err());
}
#[test]
fn accepts_valid_penetration() {
let t = EveningDojiStar::with_penetration(0.5).unwrap();
assert!((t.penetration() - 0.5).abs() < 1e-12);
}
#[test]
fn accessors_and_metadata() {
let t = EveningDojiStar::default();
assert_eq!(t.name(), "EveningDojiStar");
assert_eq!(t.warmup_period(), 3);
assert!(!t.is_ready());
assert!((t.penetration() - 0.3).abs() < 1e-12);
}
#[test]
fn evening_doji_star_is_minus_one() {
let mut t = EveningDojiStar::new();
assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(17.0, 17.1, 16.9, 17.0, 1)), Some(0.0));
assert_eq!(t.update(c(16.0, 16.1, 11.9, 12.0, 2)), Some(-1.0));
}
#[test]
fn middle_not_doji_yields_zero() {
let mut t = EveningDojiStar::new();
t.update(c(10.0, 15.1, 9.9, 15.0, 0));
// Wide-bodied star, not a doji.
t.update(c(16.0, 18.1, 15.9, 18.0, 1));
assert_eq!(t.update(c(16.0, 16.1, 11.9, 12.0, 2)), Some(0.0));
}
#[test]
fn shallow_close_yields_zero() {
let mut t = EveningDojiStar::new();
t.update(c(10.0, 15.1, 9.9, 15.0, 0));
t.update(c(17.0, 17.1, 16.9, 17.0, 1));
// bar3 black but closes at 14.0 -> only 1.0 into the 5.0 body (< 0.3·5).
assert_eq!(t.update(c(16.0, 16.1, 13.9, 14.0, 2)), Some(0.0));
}
#[test]
fn first_two_bars_return_zero() {
let mut t = EveningDojiStar::new();
assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0));
assert_eq!(t.update(c(17.0, 17.1, 16.9, 17.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 + 5.2, base - 0.1, base + 5.0, i)
})
.collect();
let mut a = EveningDojiStar::new();
let mut b = EveningDojiStar::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = EveningDojiStar::new();
t.update(c(10.0, 15.1, 9.9, 15.0, 0));
t.update(c(17.0, 17.1, 16.9, 17.0, 1));
t.update(c(16.0, 16.1, 11.9, 12.0, 2));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), Some(0.0));
}
}
@@ -0,0 +1,220 @@
//! Gap Side-by-Side White Lines candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Gap Side-by-Side White Lines — a 3-bar continuation. After a gap away from the
/// first bar, two white candles of similar size open at roughly the same level
/// (side by side) and hold the gap open, signalling the trend resumes in the gap
/// direction.
///
/// ```text
/// bar2, bar3 both white
/// bar2 body gaps away from bar1 body (up or down)
/// bar3 opens beside bar2 (|open3 open2| <= 0.1 · range2)
/// bar3 body is similar in size to bar2 (neither more than twice the other)
/// gap up -> +1.0 (bullish continuation)
/// gap down -> 1.0 (bearish continuation — "downside" gap side-by-side white)
/// ```
///
/// Output is `+1.0` (gap up) or `1.0` (gap down) when the pattern completes and
/// `0.0` otherwise. The first two bars always return `0.0` because the three-bar
/// window is not yet filled. Open-equality and body-similarity thresholds follow
/// the geometric house style rather than TA-Lib's rolling averages. Pattern-shape
/// check only — no trend filter is applied; combine with a trend indicator for
/// actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector emits the uniform candlestick sign convention shared across the
/// pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no pattern — so it
/// drops straight into a machine-learning feature matrix where the two gap
/// directions occupy a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, GapSideBySideWhite, Indicator};
///
/// let mut indicator = GapSideBySideWhite::new();
/// indicator.update(Candle::new(10.0, 11.1, 9.9, 11.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(13.0, 14.1, 12.9, 14.0, 1.0, 1).unwrap());
/// let out = indicator
/// .update(Candle::new(13.0, 14.1, 12.9, 14.0, 1.0, 2).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct GapSideBySideWhite {
prev: Option<Candle>,
prev_prev: Option<Candle>,
has_emitted: bool,
}
impl GapSideBySideWhite {
/// Construct a new Gap Side-by-Side White Lines detector.
pub const fn new() -> Self {
Self {
prev: None,
prev_prev: None,
has_emitted: false,
}
}
}
impl Indicator for GapSideBySideWhite {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let bar1 = self.prev_prev;
let bar2 = self.prev;
self.prev_prev = self.prev;
self.prev = Some(candle);
let (Some(bar1), Some(bar2)) = (bar1, bar2) else {
return Some(0.0);
};
let range2 = bar2.high - bar2.low;
if range2 <= 0.0 {
return Some(0.0);
}
// Both of the side-by-side bars must be white.
if bar2.close <= bar2.open || candle.close <= candle.open {
return Some(0.0);
}
// Side by side: opens level and bodies of comparable size.
if (candle.open - bar2.open).abs() > 0.1 * range2 {
return Some(0.0);
}
let body2 = bar2.close - bar2.open;
let body3 = candle.close - candle.open;
if body2 > 2.0 * body3 || body3 > 2.0 * body2 {
return Some(0.0);
}
let bar1_top = bar1.open.max(bar1.close);
let bar1_bottom = bar1.open.min(bar1.close);
let bar2_bottom = bar2.open.min(bar2.close);
let bar2_top = bar2.open.max(bar2.close);
if bar2_bottom > bar1_top {
return Some(1.0); // gap up -> bullish continuation
}
if bar2_top < bar1_bottom {
return Some(-1.0); // gap down -> bearish continuation
}
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 {
"GapSideBySideWhite"
}
}
#[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 = GapSideBySideWhite::new();
assert_eq!(t.name(), "GapSideBySideWhite");
assert_eq!(t.warmup_period(), 3);
assert!(!t.is_ready());
}
#[test]
fn gap_up_is_plus_one() {
let mut t = GapSideBySideWhite::new();
assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 0)), Some(0.0));
assert_eq!(t.update(c(13.0, 14.1, 12.9, 14.0, 1)), Some(0.0));
assert_eq!(t.update(c(13.0, 14.1, 12.9, 14.0, 2)), Some(1.0));
}
#[test]
fn gap_down_is_minus_one() {
let mut t = GapSideBySideWhite::new();
assert_eq!(t.update(c(14.0, 14.1, 12.9, 13.0, 0)), Some(0.0));
assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 1)), Some(0.0));
assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 2)), Some(-1.0));
}
#[test]
fn second_bar_black_yields_zero() {
let mut t = GapSideBySideWhite::new();
t.update(c(10.0, 11.1, 9.9, 11.0, 0));
// bar3 is black -> not two white lines.
t.update(c(13.0, 14.1, 12.9, 14.0, 1));
assert_eq!(t.update(c(14.0, 14.1, 12.9, 13.0, 2)), Some(0.0));
}
#[test]
fn not_side_by_side_yields_zero() {
let mut t = GapSideBySideWhite::new();
t.update(c(10.0, 11.1, 9.9, 11.0, 0));
t.update(c(13.0, 14.1, 12.9, 14.0, 1));
// bar3 opens far from bar2's open -> not side by side.
assert_eq!(t.update(c(16.0, 17.1, 15.9, 17.0, 2)), Some(0.0));
}
#[test]
fn no_gap_yields_zero() {
let mut t = GapSideBySideWhite::new();
t.update(c(10.0, 13.1, 9.9, 13.0, 0));
// bar2 overlaps bar1 (no gap).
t.update(c(12.0, 13.1, 11.9, 13.0, 1));
assert_eq!(t.update(c(12.0, 13.1, 11.9, 13.0, 2)), Some(0.0));
}
#[test]
fn first_two_bars_return_zero() {
let mut t = GapSideBySideWhite::new();
assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 0)), Some(0.0));
assert_eq!(t.update(c(13.0, 14.1, 12.9, 14.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 * 3.0;
c(base, base + 1.1, base - 0.1, base + 1.0, i)
})
.collect();
let mut a = GapSideBySideWhite::new();
let mut b = GapSideBySideWhite::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = GapSideBySideWhite::new();
t.update(c(10.0, 11.1, 9.9, 11.0, 0));
t.update(c(13.0, 14.1, 12.9, 14.0, 1));
t.update(c(13.0, 14.1, 12.9, 14.0, 2));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 0)), Some(0.0));
}
}
@@ -0,0 +1,160 @@
//! High-Wave candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// High-Wave — a single-bar extreme-indecision signal. A small body with very
/// long shadows on *both* sides: price swung far up and far down yet finished
/// near the open, a sign that trend conviction has evaporated.
///
/// ```text
/// range = high low
/// long upper = high max(open, close) >= 0.4 * range
/// long lower = min(open, close) low >= 0.4 * range
/// ```
///
/// The two long-shadow conditions force the body below `0.2 * range`, so no
/// separate body test is needed. Output is `+1.0` when the high-wave prints and
/// `0.0` otherwise — a non-directional indecision flag, it never emits `1.0`.
/// Shadow thresholds follow the geometric house style rather than TA-Lib's
/// rolling averages. Pattern-shape check only — no trend filter is applied;
/// combine with a trend indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector emits the uniform candlestick sign convention shared across the
/// pattern family — `+1.0` detected, `0.0` no pattern — so it drops straight into
/// a machine-learning feature matrix as a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, HighWave, Indicator};
///
/// let mut indicator = HighWave::new();
/// // Small body, long shadows both sides.
/// let candle = Candle::new(10.0, 12.0, 8.0, 10.3, 1.0, 0).unwrap();
/// assert_eq!(indicator.update(candle), Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct HighWave {
has_emitted: bool,
}
impl HighWave {
/// Construct a new High-Wave detector.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for HighWave {
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 upper = candle.high - candle.open.max(candle.close);
let lower = candle.open.min(candle.close) - candle.low;
if upper >= 0.4 * range && lower >= 0.4 * range {
return Some(1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"HighWave"
}
}
#[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 = HighWave::new();
assert_eq!(t.name(), "HighWave");
assert_eq!(t.warmup_period(), 1);
assert!(!t.is_ready());
}
#[test]
fn high_wave_is_plus_one() {
let mut t = HighWave::new();
assert_eq!(t.update(c(10.0, 12.0, 8.0, 10.3, 0)), Some(1.0));
}
#[test]
fn short_upper_shadow_yields_zero() {
let mut t = HighWave::new();
// Long lower shadow but short upper -> not a high-wave.
assert_eq!(t.update(c(11.5, 12.0, 8.0, 11.7, 0)), Some(0.0));
}
#[test]
fn short_lower_shadow_yields_zero() {
let mut t = HighWave::new();
// Long upper shadow but short lower -> not a high-wave.
assert_eq!(t.update(c(8.3, 12.0, 8.0, 8.5, 0)), Some(0.0));
}
#[test]
fn big_body_yields_zero() {
let mut t = HighWave::new();
// A large body cannot leave both shadows long.
assert_eq!(t.update(c(8.5, 12.0, 8.0, 11.5, 0)), Some(0.0));
}
#[test]
fn zero_range_yields_zero() {
let mut t = HighWave::new();
assert_eq!(t.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
c(base, base + 3.0, base - 3.0, base + 0.2, i)
})
.collect();
let mut a = HighWave::new();
let mut b = HighWave::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = HighWave::new();
t.update(c(10.0, 12.0, 8.0, 10.3, 0));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
}
}
@@ -0,0 +1,198 @@
//! Hikkake candlestick pattern.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Hikkake — a 3-bar trap. An inside bar (bar2 fully contained by bar1) sets up a
/// breakout that immediately fails on bar3, trapping breakout traders and pointing
/// the opposite way.
///
/// ```text
/// inside bar : bar2.high < bar1.high && bar2.low > bar1.low
/// bullish (+1.0): bar3 makes a LOWER high AND LOWER low than bar2
/// (a false downside break -> expect a move up)
/// bearish (1.0): bar3 makes a HIGHER high AND HIGHER low than bar2
/// (a false upside break -> expect a move down)
/// ```
///
/// Output is `+1.0` (bullish setup), `1.0` (bearish setup), or `0.0` otherwise.
/// The detector fires when the three-bar setup completes on bar3; it does not
/// separately flag the optional later confirmation bar. The first two bars always
/// return `0.0` because the window is not yet filled. Pattern-shape check only —
/// no trend filter is applied; combine with a trend indicator for actionable
/// signals.
///
/// # Signed ±1 encoding
///
/// This detector emits the uniform candlestick sign convention shared across the
/// pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no pattern — so it
/// drops straight into a machine-learning feature matrix where the bullish and
/// bearish setups occupy a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Hikkake, Indicator};
///
/// let mut indicator = Hikkake::new();
/// indicator.update(Candle::new(10.0, 15.0, 5.0, 12.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(11.0, 13.0, 8.0, 12.0, 1.0, 1).unwrap());
/// let out = indicator
/// .update(Candle::new(9.0, 12.0, 6.0, 7.0, 1.0, 2).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct Hikkake {
prev: Option<Candle>,
prev_prev: Option<Candle>,
has_emitted: bool,
}
impl Hikkake {
/// Construct a new Hikkake detector.
pub const fn new() -> Self {
Self {
prev: None,
prev_prev: None,
has_emitted: false,
}
}
}
impl Indicator for Hikkake {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let bar1 = self.prev_prev;
let bar2 = self.prev;
self.prev_prev = self.prev;
self.prev = Some(candle);
let (Some(bar1), Some(bar2)) = (bar1, bar2) else {
return Some(0.0);
};
// bar2 must be an inside bar of bar1.
if !(bar2.high < bar1.high && bar2.low > bar1.low) {
return Some(0.0);
}
// Bullish: bar3 breaks below the inside bar (lower high and lower low).
if candle.high < bar2.high && candle.low < bar2.low {
return Some(1.0);
}
// Bearish: bar3 breaks above the inside bar (higher high and higher low).
if candle.high > bar2.high && candle.low > bar2.low {
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 {
"Hikkake"
}
}
#[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 = Hikkake::new();
assert_eq!(t.name(), "Hikkake");
assert_eq!(t.warmup_period(), 3);
assert!(!t.is_ready());
}
#[test]
fn bullish_hikkake_is_plus_one() {
let mut t = Hikkake::new();
assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 13.0, 8.0, 12.0, 1)), Some(0.0));
assert_eq!(t.update(c(9.0, 12.0, 6.0, 7.0, 2)), Some(1.0));
}
#[test]
fn bearish_hikkake_is_minus_one() {
let mut t = Hikkake::new();
assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 13.0, 8.0, 12.0, 1)), Some(0.0));
assert_eq!(t.update(c(12.0, 14.0, 9.0, 13.0, 2)), Some(-1.0));
}
#[test]
fn not_inside_bar_yields_zero() {
let mut t = Hikkake::new();
t.update(c(10.0, 15.0, 5.0, 12.0, 0));
// bar2 is not contained by bar1 (higher high).
t.update(c(11.0, 16.0, 8.0, 12.0, 1));
assert_eq!(t.update(c(9.0, 12.0, 6.0, 7.0, 2)), Some(0.0));
}
#[test]
fn outside_bar3_yields_zero() {
let mut t = Hikkake::new();
t.update(c(10.0, 15.0, 5.0, 12.0, 0));
t.update(c(11.0, 13.0, 8.0, 12.0, 1));
// bar3 engulfs bar2 (higher high and lower low) -> neither direction.
assert_eq!(t.update(c(11.0, 14.0, 7.0, 9.0, 2)), Some(0.0));
}
#[test]
fn first_two_bars_return_zero() {
let mut t = Hikkake::new();
assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0));
assert_eq!(t.update(c(11.0, 13.0, 8.0, 12.0, 1)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
match i % 3 {
0 => c(base, base + 6.0, base - 6.0, base, i),
1 => c(base, base + 2.0, base - 2.0, base, i),
_ => c(base, base + 1.0, base - 5.0, base - 4.0, i),
}
})
.collect();
let mut a = Hikkake::new();
let mut b = Hikkake::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = Hikkake::new();
t.update(c(10.0, 15.0, 5.0, 12.0, 0));
t.update(c(11.0, 13.0, 8.0, 12.0, 1));
t.update(c(9.0, 12.0, 6.0, 7.0, 2));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), Some(0.0));
}
}
+16 -1
View File
@@ -77,6 +77,7 @@ mod elder_impulse;
mod ema;
mod empirical_mode_decomposition;
mod engulfing;
mod evening_doji_star;
mod evwma;
mod fama;
mod fibonacci_pivots;
@@ -90,12 +91,15 @@ mod funding_rate;
mod funding_rate_mean;
mod funding_rate_zscore;
mod gain_loss_ratio;
mod gap_side_by_side_white;
mod garman_klass;
mod gravestone_doji;
mod hammer;
mod hanging_man;
mod harami;
mod heikin_ashi;
mod high_wave;
mod hikkake;
mod hilbert_dominant_cycle;
mod hilo_activator;
mod historical_volatility;
@@ -140,6 +144,7 @@ mod median_price;
mod mfi;
mod microprice;
mod mom;
mod morning_doji_star;
mod morning_evening_star;
mod natr;
mod nvi;
@@ -336,6 +341,7 @@ pub use elder_impulse::ElderImpulse;
pub use ema::Ema;
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
pub use engulfing::Engulfing;
pub use evening_doji_star::EveningDojiStar;
pub use evwma::Evwma;
pub use fama::Fama;
pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput};
@@ -349,12 +355,15 @@ pub use funding_rate::FundingRate;
pub use funding_rate_mean::FundingRateMean;
pub use funding_rate_zscore::FundingRateZScore;
pub use gain_loss_ratio::GainLossRatio;
pub use gap_side_by_side_white::GapSideBySideWhite;
pub use garman_klass::GarmanKlassVolatility;
pub use gravestone_doji::GravestoneDoji;
pub use hammer::Hammer;
pub use hanging_man::HangingMan;
pub use harami::Harami;
pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
pub use high_wave::HighWave;
pub use hikkake::Hikkake;
pub use hilbert_dominant_cycle::HilbertDominantCycle;
pub use hilo_activator::HiLoActivator;
pub use historical_volatility::HistoricalVolatility;
@@ -399,6 +408,7 @@ pub use median_price::MedianPrice;
pub use mfi::Mfi;
pub use microprice::Microprice;
pub use mom::Mom;
pub use morning_doji_star::MorningDojiStar;
pub use morning_evening_star::MorningEveningStar;
pub use natr::Natr;
pub use nvi::Nvi;
@@ -800,6 +810,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"GravestoneDoji",
"LongLeggedDoji",
"RickshawMan",
"EveningDojiStar",
"MorningDojiStar",
"GapSideBySideWhite",
"HighWave",
"Hikkake",
],
),
(
@@ -891,6 +906,6 @@ mod family_tests {
// the actual indicator count is the early-warning signal that an
// indicator was added without being assigned a family.
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
assert_eq!(total, 254, "FAMILIES total drifted from indicator count");
assert_eq!(total, 259, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,241 @@
//! Morning Doji Star candlestick pattern.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Morning Doji Star — a 3-bar bullish bottom reversal. A long black bar extends
/// the decline, a doji gaps down below it (the star of indecision), then a white
/// bar gaps back up and closes deep into the first body, confirming the turn.
///
/// ```text
/// long body = |close open| >= 0.5 * (high low)
/// doji = |close open| <= 0.1 * (high low)
/// bar1 black & long
/// bar2 doji, body gaps DOWN below bar1 body (max(o2,c2) < close1)
/// bar3 white, body gaps UP above the doji (min(o3,c3) > max(o2,c2))
/// bar3 closes deep into bar1 body (close3 > close1 + penetration·body1)
/// ```
///
/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Morning Doji
/// Star is a single-direction (bullish-only) reversal, so it never emits `1.0`.
/// The first two bars always return `0.0` because the three-bar window is not yet
/// filled. `penetration` is how far into the first body the third bar must close;
/// it defaults to `0.3` (TA-Lib's `CDLMORNINGDOJISTAR` default) and must lie in
/// `[0, 1)`. Body and doji thresholds follow the geometric house style rather than
/// TA-Lib's rolling averages. Pattern-shape check only — no trend filter is
/// applied; combine with a trend indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector emits the uniform candlestick sign convention shared across the
/// pattern family — `+1.0` bullish, `0.0` no pattern — so it drops straight into
/// a machine-learning feature matrix as a single dimension.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, MorningDojiStar};
///
/// let mut indicator = MorningDojiStar::new();
/// indicator.update(Candle::new(15.0, 15.1, 9.9, 10.0, 1.0, 0).unwrap());
/// indicator.update(Candle::new(8.0, 8.1, 7.9, 8.0, 1.0, 1).unwrap());
/// let out = indicator
/// .update(Candle::new(9.0, 13.1, 8.9, 13.0, 1.0, 2).unwrap());
/// assert_eq!(out, Some(1.0));
/// ```
#[derive(Debug, Clone)]
pub struct MorningDojiStar {
penetration: f64,
prev: Option<Candle>,
prev_prev: Option<Candle>,
has_emitted: bool,
}
impl Default for MorningDojiStar {
fn default() -> Self {
Self::new()
}
}
impl MorningDojiStar {
/// Construct a Morning Doji Star detector with the default 0.3 penetration.
pub const fn new() -> Self {
Self {
penetration: 0.3,
prev: None,
prev_prev: None,
has_emitted: false,
}
}
/// Construct a Morning Doji Star detector with a custom penetration fraction.
///
/// `penetration` must lie in `[0, 1)`.
pub fn with_penetration(penetration: f64) -> Result<Self> {
if !(0.0..1.0).contains(&penetration) {
return Err(Error::InvalidPeriod {
message: "morning doji star penetration must lie in [0, 1)",
});
}
Ok(Self {
penetration,
prev: None,
prev_prev: None,
has_emitted: false,
})
}
/// Configured penetration fraction.
pub fn penetration(&self) -> f64 {
self.penetration
}
}
impl Indicator for MorningDojiStar {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let bar1 = self.prev_prev;
let bar2 = self.prev;
self.prev_prev = self.prev;
self.prev = Some(candle);
let (Some(bar1), Some(bar2)) = (bar1, bar2) else {
return Some(0.0);
};
let range1 = bar1.high - bar1.low;
let range2 = bar2.high - bar2.low;
if range1 <= 0.0 || range2 <= 0.0 {
return Some(0.0);
}
let body1 = bar1.open - bar1.close;
if body1 < 0.5 * range1 {
return Some(0.0); // bar1 must be a long black body
}
if (bar2.close - bar2.open).abs() > 0.1 * range2 {
return Some(0.0); // bar2 must be a doji
}
let star_top = bar2.open.max(bar2.close);
let bar3_bottom = candle.open.min(candle.close);
if star_top < bar1.close
&& candle.close > candle.open
&& bar3_bottom > star_top
&& candle.close > bar1.close + self.penetration * body1
{
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 {
"MorningDojiStar"
}
}
#[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_penetration() {
assert!(MorningDojiStar::with_penetration(-0.01).is_err());
assert!(MorningDojiStar::with_penetration(1.0).is_err());
}
#[test]
fn accepts_valid_penetration() {
let t = MorningDojiStar::with_penetration(0.5).unwrap();
assert!((t.penetration() - 0.5).abs() < 1e-12);
}
#[test]
fn accessors_and_metadata() {
let t = MorningDojiStar::default();
assert_eq!(t.name(), "MorningDojiStar");
assert_eq!(t.warmup_period(), 3);
assert!(!t.is_ready());
assert!((t.penetration() - 0.3).abs() < 1e-12);
}
#[test]
fn morning_doji_star_is_plus_one() {
let mut t = MorningDojiStar::new();
assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
assert_eq!(t.update(c(8.0, 8.1, 7.9, 8.0, 1)), Some(0.0));
assert_eq!(t.update(c(9.0, 13.1, 8.9, 13.0, 2)), Some(1.0));
}
#[test]
fn middle_not_doji_yields_zero() {
let mut t = MorningDojiStar::new();
t.update(c(15.0, 15.1, 9.9, 10.0, 0));
// Wide-bodied star, not a doji.
t.update(c(8.0, 10.1, 7.9, 10.0, 1));
assert_eq!(t.update(c(9.0, 13.1, 8.9, 13.0, 2)), Some(0.0));
}
#[test]
fn shallow_close_yields_zero() {
let mut t = MorningDojiStar::new();
t.update(c(15.0, 15.1, 9.9, 10.0, 0));
t.update(c(8.0, 8.1, 7.9, 8.0, 1));
// bar3 white but closes at 11.0 -> only 1.0 into the 5.0 body (< 0.3·5).
assert_eq!(t.update(c(9.0, 11.1, 8.9, 11.0, 2)), Some(0.0));
}
#[test]
fn first_two_bars_return_zero() {
let mut t = MorningDojiStar::new();
assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
assert_eq!(t.update(c(8.0, 8.1, 7.9, 8.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 + 5.0, base + 5.1, base - 0.1, base, i)
})
.collect();
let mut a = MorningDojiStar::new();
let mut b = MorningDojiStar::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut t = MorningDojiStar::new();
t.update(c(15.0, 15.1, 9.9, 10.0, 0));
t.update(c(8.0, 8.1, 7.9, 8.0, 1));
t.update(c(9.0, 13.1, 8.9, 13.0, 2));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
}
}
+36 -36
View File
@@ -67,42 +67,42 @@ pub use indicators::{
DemarkPivotsOutput, DepthSlope, DetrendedStdDev, Doji, DojiStar, Donchian, DonchianOutput,
DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, DragonflyDoji,
DrawdownDuration, EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema,
EmpiricalModeDecomposition, Engulfing, Evwma, Fama, FibonacciPivots, FibonacciPivotsOutput,
FisherTransform, Footprint, FootprintOutput, ForceIndex, FractalChaosBands,
FractalChaosBandsOutput, Frama, FundingBasis, FundingRate, FundingRateMean, FundingRateZScore,
GainLossRatio, GarmanKlassVolatility, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi,
HeikinAshiOutput, HiLoActivator, HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel,
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, Inertia,
InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
InverseFisherTransform, InvertedHammer, Jma, Kama, KellyCriterion, Keltner, KeltnerOutput, Kst,
KstOutput, Kurtosis, Kvo, KylesLambda, LaguerreRsi, LeadLagCrossCorrelation,
LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope,
LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput, LongLeggedDoji,
LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput,
MarketFacilitationIndex, Marubozu, MassIndex, MaxDrawdown, McGinleyDynamic,
MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, Mom, MorningEveningStar, Natr, Nvi,
OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OpenInterestDelta, OpeningRange,
OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN,
PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentB,
PercentageTrailingStop, Pgo, PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi,
QuotedSpread, RSquared, RealizedSpread, RecoveryFactor, RelativeStrengthAB,
RelativeStrengthOutput, RenkoTrailingStop, RickshawMan, Roc, RogersSatchellVolatility,
RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar,
SignedVolume, SineWave, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation,
SpinningTop, StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands,
StarcBandsOutput, Stc, StdDev, StepTrailingStop, StochRsi, Stochastic, StochasticOutput,
SuperSmoother, SuperTrend, SuperTrendOutput, TakerBuySellRatio, TdCombo, TdCountdown,
TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeInside, ThreeLineStrike,
ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Tii, TradeImbalance, TreynorRatio,
Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, Tweezer, TwoCrows,
TypicalPrice, UlcerIndex, UltimateOscillator, UpsideGapTwoCrows, ValueArea, ValueAreaOutput,
ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator,
VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma,
Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsFractals, WilliamsFractalsOutput,
WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore,
ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, Fama, FibonacciPivots,
FibonacciPivotsOutput, FisherTransform, Footprint, FootprintOutput, ForceIndex,
FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate, FundingRateMean,
FundingRateZScore, GainLossRatio, GapSideBySideWhite, GarmanKlassVolatility, GravestoneDoji,
Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput, HiLoActivator, HighWave, Hikkake,
HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput,
HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, Inertia, InformationRatio,
InitialBalance, InitialBalanceOutput, InstantaneousTrendline, InverseFisherTransform,
InvertedHammer, Jma, Kama, KellyCriterion, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis,
Kvo, KylesLambda, LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput,
LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope, LinearRegression,
LiquidationFeatures, LiquidationFeaturesOutput, LongLeggedDoji, LongShortRatio, MaEnvelope,
MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex,
Marubozu, MassIndex, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi,
Microprice, Mom, MorningDojiStar, MorningEveningStar, Natr, Nvi, OIPriceDivergence, OIWeighted,
Obv, OmegaRatio, OpenInterestDelta, OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull,
OrderBookImbalanceTop1, OrderBookImbalanceTopN, PainIndex, PairSpreadZScore, PairwiseBeta,
ParkinsonVolatility, PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo,
PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, RealizedSpread,
RecoveryFactor, RelativeStrengthAB, RelativeStrengthOutput, RenkoTrailingStop, RickshawMan,
Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi,
RwiOutput, SharpeRatio, ShootingStar, SignedVolume, SineWave, Skewness, Sma, Smi, Smma,
SortinoRatio, SpearmanCorrelation, SpinningTop, StandardError, StandardErrorBands,
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput,
TakerBuySellRatio, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput,
TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel,
TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, TermStructureBasis,
ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Tii,
TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput,
Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator, UpsideGapTwoCrows, ValueArea,
ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop,
VolumeOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands,
VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsFractals,
WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility,
YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
};
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
// line so the indicator-count tooling (which scans the braced block above and