feat: add Candlestick Patterns deepening (B14, 6 indicators) (#209)
B14 of the family-deepening roadmap — six candlestick patterns (479 -> 485), all in the **Candlestick Patterns** family. **Fixed-lookback (candle-pattern macro bindings, neutral 0.0 during warmup):** - **Tristar** — three-doji star reversal. - **Harami Cross** — Harami whose second candle is a contained doji. - **Tower Top/Bottom** — tall bar, small pause, tall opposite bar. **Windowed / parameterized (hand-bound, `candle -> f64`):** - **Frying Pan Bottom** — rounded U-shaped accumulation base, recovery-confirmed. - **Dumpling Top** — rounded dome-shaped distribution top, breakdown-confirmed. - **New Price Lines** — run of N consecutive new closing highs (+1) / lows (-1). Window/Gap (Rising-Falling) dropped (SKIP — existing gap coverage). Wiring complete across core, Python, Node, WASM, fuzz, tests, README + docs counter (485) and CHANGELOG. Verified: core 3966 + doc 435, clippy clean, node 560, python 922.
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
//! Dumpling Top — a rounded top (dome) confirmed by a breakdown.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Dumpling Top — the bearish mirror of the [`FryPanBottom`](crate::FryPanBottom):
|
||||
/// a gently rounded **top** (dome) across the window, confirmed by a close back
|
||||
/// below where it started.
|
||||
///
|
||||
/// ```text
|
||||
/// over the last `period` closes:
|
||||
/// the maximum close sits in the middle third of the window (the "dome")
|
||||
/// the latest close is below the first close (the breakdown)
|
||||
/// signal = −1 when both hold, else 0
|
||||
/// ```
|
||||
///
|
||||
/// The dumpling top is a distribution pattern: price rounds over at the top as
|
||||
/// buying fades, then rolls down through the level it rose from. Detection requires
|
||||
/// a *central* high (a symmetric dome, not a one-sided spike) and a close below the
|
||||
/// window's opening level. The output is `−1.0` (pattern) or `0.0`.
|
||||
///
|
||||
/// The first value lands after `period` inputs; each `update` scans the window in
|
||||
/// O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, DumplingTop};
|
||||
///
|
||||
/// let mut indicator = DumplingTop::new(9).unwrap();
|
||||
/// let closes = [100.0, 102.0, 104.0, 105.0, 104.0, 102.0, 99.0, 97.0, 95.0];
|
||||
/// let mut last = None;
|
||||
/// for &cl in &closes {
|
||||
/// let c = Candle::new(cl, cl + 0.5, cl - 0.5, cl, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert_eq!(last, Some(-1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DumplingTop {
|
||||
period: usize,
|
||||
closes: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl DumplingTop {
|
||||
/// Construct a Dumpling Top over `period` bars.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 5`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 5 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "dumpling top needs period >= 5",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
closes: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DumplingTop {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
if self.closes.len() == self.period {
|
||||
self.closes.pop_front();
|
||||
}
|
||||
self.closes.push_back(candle.close);
|
||||
if self.closes.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let first = *self.closes.front().expect("non-empty");
|
||||
let last = *self.closes.back().expect("non-empty");
|
||||
let mut max_idx = 0;
|
||||
let mut max_val = f64::NEG_INFINITY;
|
||||
for (i, &v) in self.closes.iter().enumerate() {
|
||||
if v > max_val {
|
||||
max_val = v;
|
||||
max_idx = i;
|
||||
}
|
||||
}
|
||||
let lo = self.period / 4;
|
||||
let hi = self.period - self.period / 4;
|
||||
let dome = max_idx >= lo && max_idx < hi;
|
||||
let broke_down = last < first && last < max_val;
|
||||
let v = if dome && broke_down { -1.0 } else { 0.0 };
|
||||
self.last = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.closes.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DumplingTop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(close: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close + 0.5, close - 0.5, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_small_period() {
|
||||
assert!(matches!(
|
||||
DumplingTop::new(4),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(DumplingTop::new(5).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let d = DumplingTop::new(9).unwrap();
|
||||
assert_eq!(d.period(), 9);
|
||||
assert_eq!(d.warmup_period(), 9);
|
||||
assert_eq!(d.name(), "DumplingTop");
|
||||
assert!(!d.is_ready());
|
||||
assert_eq!(d.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut d = DumplingTop::new(5).unwrap();
|
||||
let out = d.batch(&[c(100.0), c(101.0), c(102.0), c(101.0), c(99.0), c(98.0)]);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_top_then_breakdown_signals() {
|
||||
let mut d = DumplingTop::new(9).unwrap();
|
||||
let closes = [100.0, 102.0, 104.0, 105.0, 104.0, 102.0, 99.0, 97.0, 95.0];
|
||||
let candles: Vec<Candle> = closes.iter().map(|&x| c(x)).collect();
|
||||
let last = d.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_sided_rise_is_zero() {
|
||||
let mut d = DumplingTop::new(9).unwrap();
|
||||
let candles: Vec<Candle> = (0..9).map(|i| c(100.0 + f64::from(i))).collect();
|
||||
let last = d.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_breakdown_is_zero() {
|
||||
let mut d = DumplingTop::new(9).unwrap();
|
||||
let closes = [
|
||||
100.0, 102.0, 104.0, 105.0, 104.0, 103.0, 102.0, 101.0, 100.5,
|
||||
];
|
||||
let candles: Vec<Candle> = closes.iter().map(|&x| c(x)).collect();
|
||||
let last = d.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut d = DumplingTop::new(5).unwrap();
|
||||
d.batch(&[c(100.0), c(101.0), c(102.0), c(101.0), c(99.0)]);
|
||||
assert!(d.is_ready());
|
||||
d.reset();
|
||||
assert!(!d.is_ready());
|
||||
assert_eq!(d.value(), None);
|
||||
assert_eq!(d.update(c(100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| c(100.0 + (f64::from(i) * 0.3).sin() * 5.0))
|
||||
.collect();
|
||||
let batch = DumplingTop::new(9).unwrap().batch(&candles);
|
||||
let mut b = DumplingTop::new(9).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Frying Pan Bottom — a rounded bottom (U) confirmed by recovery.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Frying Pan Bottom — a gently rounded bottom across the lookback window: prices
|
||||
/// decline, flatten near the centre, then recover above where they started.
|
||||
///
|
||||
/// ```text
|
||||
/// over the last `period` closes:
|
||||
/// the minimum close sits in the middle third of the window (the "bowl")
|
||||
/// the latest close is above the first close (the rim is recovered)
|
||||
/// signal = +1 when both hold, else 0
|
||||
/// ```
|
||||
///
|
||||
/// The frying pan is a bullish accumulation pattern: a saucer-shaped base where
|
||||
/// selling dries up, the curve flattens, and price lifts off the rim. Detecting it
|
||||
/// requires the low point to be central (a symmetric bowl, not a one-sided drop)
|
||||
/// and the close to have climbed back above the window's opening level, confirming
|
||||
/// the breakout from the base. The output is `+1.0` (pattern) or `0.0`.
|
||||
///
|
||||
/// The first value lands after `period` inputs; each `update` scans the window in
|
||||
/// O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, FryPanBottom};
|
||||
///
|
||||
/// let mut indicator = FryPanBottom::new(9).unwrap();
|
||||
/// // A U-shaped base then recovery.
|
||||
/// let closes = [100.0, 98.0, 96.0, 95.0, 96.0, 98.0, 101.0, 103.0, 105.0];
|
||||
/// let mut last = None;
|
||||
/// for &cl in &closes {
|
||||
/// let c = Candle::new(cl, cl + 0.5, cl - 0.5, cl, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert_eq!(last, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FryPanBottom {
|
||||
period: usize,
|
||||
closes: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl FryPanBottom {
|
||||
/// Construct a Frying Pan Bottom over `period` bars.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 5` (a bowl needs room for a
|
||||
/// central low between recovering sides).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 5 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "frying pan bottom needs period >= 5",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
closes: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FryPanBottom {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
if self.closes.len() == self.period {
|
||||
self.closes.pop_front();
|
||||
}
|
||||
self.closes.push_back(candle.close);
|
||||
if self.closes.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let first = *self.closes.front().expect("non-empty");
|
||||
let last = *self.closes.back().expect("non-empty");
|
||||
// Index of the minimum close.
|
||||
let mut min_idx = 0;
|
||||
let mut min_val = f64::INFINITY;
|
||||
for (i, &v) in self.closes.iter().enumerate() {
|
||||
if v < min_val {
|
||||
min_val = v;
|
||||
min_idx = i;
|
||||
}
|
||||
}
|
||||
let lo = self.period / 4;
|
||||
let hi = self.period - self.period / 4;
|
||||
let bowl = min_idx >= lo && min_idx < hi;
|
||||
let recovered = last > first && last > min_val;
|
||||
let v = if bowl && recovered { 1.0 } else { 0.0 };
|
||||
self.last = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.closes.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FryPanBottom"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(close: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close + 0.5, close - 0.5, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_small_period() {
|
||||
assert!(matches!(
|
||||
FryPanBottom::new(4),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(FryPanBottom::new(5).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let f = FryPanBottom::new(9).unwrap();
|
||||
assert_eq!(f.period(), 9);
|
||||
assert_eq!(f.warmup_period(), 9);
|
||||
assert_eq!(f.name(), "FryPanBottom");
|
||||
assert!(!f.is_ready());
|
||||
assert_eq!(f.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut f = FryPanBottom::new(5).unwrap();
|
||||
let out = f.batch(&[c(100.0), c(99.0), c(98.0), c(99.0), c(101.0), c(102.0)]);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_bottom_then_recovery_signals() {
|
||||
let mut f = FryPanBottom::new(9).unwrap();
|
||||
let closes = [100.0, 98.0, 96.0, 95.0, 96.0, 98.0, 101.0, 103.0, 105.0];
|
||||
let candles: Vec<Candle> = closes.iter().map(|&x| c(x)).collect();
|
||||
let last = f.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_sided_drop_is_zero() {
|
||||
// A straight decline (min at the end) is not a bowl.
|
||||
let mut f = FryPanBottom::new(9).unwrap();
|
||||
let candles: Vec<Candle> = (0..9).map(|i| c(100.0 - f64::from(i))).collect();
|
||||
let last = f.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_recovery_is_zero() {
|
||||
// Bowl shape but the last close never climbs above the first.
|
||||
let mut f = FryPanBottom::new(9).unwrap();
|
||||
let closes = [100.0, 98.0, 96.0, 95.0, 96.0, 97.0, 98.0, 99.0, 99.5];
|
||||
let candles: Vec<Candle> = closes.iter().map(|&x| c(x)).collect();
|
||||
let last = f.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut f = FryPanBottom::new(5).unwrap();
|
||||
f.batch(&[c(100.0), c(99.0), c(98.0), c(99.0), c(101.0)]);
|
||||
assert!(f.is_ready());
|
||||
f.reset();
|
||||
assert!(!f.is_ready());
|
||||
assert_eq!(f.value(), None);
|
||||
assert_eq!(f.update(c(100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| c(100.0 + (f64::from(i) * 0.3).sin() * 5.0))
|
||||
.collect();
|
||||
let batch = FryPanBottom::new(9).unwrap().batch(&candles);
|
||||
let mut b = FryPanBottom::new(9).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
//! Harami Cross — a Harami whose second candle is a Doji.
|
||||
//!
|
||||
//! A Harami Cross is a stronger Harami: a large real body followed by a Doji whose
|
||||
//! body sits *within* the prior body. The Doji's total indecision after a strong
|
||||
//! move makes the reversal signal more potent than a plain Harami.
|
||||
//!
|
||||
//! - **Bullish** (`+1.0`): the prior candle is a large **bearish** body
|
||||
//! (`close < open`) and the current candle is a Doji whose open and close lie
|
||||
//! within the prior body.
|
||||
//! - **Bearish** (`-1.0`): the prior candle is a large **bullish** body and the
|
||||
//! current is a contained Doji.
|
||||
//! - Otherwise the output is `0.0`.
|
||||
//!
|
||||
//! A doji is a candle whose body is `<= 0.1 * range`. The two-bar lookback means
|
||||
//! the first value lands on the second candle.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
fn is_doji(candle: Candle) -> bool {
|
||||
let body = (candle.close - candle.open).abs();
|
||||
let range = candle.high - candle.low;
|
||||
range > 0.0 && body <= 0.1 * range
|
||||
}
|
||||
|
||||
/// Harami Cross — large-body-then-contained-doji reversal detector.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HaramiCross {
|
||||
prev: Option<Candle>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl HaramiCross {
|
||||
/// Construct a new `HaramiCross`.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Latest emitted signal if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HaramiCross {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let Some(prev) = self.prev else {
|
||||
self.prev = Some(candle);
|
||||
self.last_value = Some(0.0);
|
||||
return Some(0.0);
|
||||
};
|
||||
let prev_body_low = prev.open.min(prev.close);
|
||||
let prev_body_high = prev.open.max(prev.close);
|
||||
let prev_is_solid = !is_doji(prev);
|
||||
let curr_is_doji = is_doji(candle);
|
||||
let contained = candle.open >= prev_body_low
|
||||
&& candle.open <= prev_body_high
|
||||
&& candle.close >= prev_body_low
|
||||
&& candle.close <= prev_body_high;
|
||||
|
||||
let v = if prev_is_solid && curr_is_doji && contained {
|
||||
if prev.close < prev.open {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.prev = Some(candle);
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HaramiCross"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn solid(open: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(
|
||||
open,
|
||||
open.max(close) + 0.2,
|
||||
open.min(close) - 0.2,
|
||||
close,
|
||||
0.0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
fn doji(mid: f64) -> Candle {
|
||||
Candle::new_unchecked(mid, mid + 1.0, mid - 1.0, mid + 0.02, 0.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let h = HaramiCross::new();
|
||||
assert_eq!(h.warmup_period(), 2);
|
||||
assert_eq!(h.name(), "HaramiCross");
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_seeds_without_signal() {
|
||||
let mut h = HaramiCross::new();
|
||||
assert_eq!(h.update(solid(110.0, 100.0)), Some(0.0));
|
||||
assert!(h.update(doji(105.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_harami_cross() {
|
||||
// prior big bearish body [100, 110]; doji centred at 105 inside it -> +1.
|
||||
let mut h = HaramiCross::new();
|
||||
h.update(solid(110.0, 100.0));
|
||||
assert_eq!(h.update(doji(105.0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_harami_cross() {
|
||||
// prior big bullish body [100, 110]; doji inside -> -1.
|
||||
let mut h = HaramiCross::new();
|
||||
h.update(solid(100.0, 110.0));
|
||||
assert_eq!(h.update(doji(105.0)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doji_outside_body_is_zero() {
|
||||
let mut h = HaramiCross::new();
|
||||
h.update(solid(110.0, 100.0));
|
||||
// doji centred at 120, outside the prior body -> 0.
|
||||
assert_eq!(h.update(doji(120.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_doji_second_is_zero() {
|
||||
let mut h = HaramiCross::new();
|
||||
h.update(solid(110.0, 100.0));
|
||||
assert_eq!(h.update(solid(104.0, 106.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = HaramiCross::new();
|
||||
h.update(solid(110.0, 100.0));
|
||||
h.update(doji(105.0));
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.update(solid(110.0, 100.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
if i % 2 == 0 {
|
||||
solid(110.0, 100.0)
|
||||
} else {
|
||||
doji(105.0)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let batch = HaramiCross::new().batch(&candles);
|
||||
let mut b = HaramiCross::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,7 @@ mod downside_gap_three_methods;
|
||||
mod dpo;
|
||||
mod dragonfly_doji;
|
||||
mod drawdown_duration;
|
||||
mod dumpling_top;
|
||||
mod dx;
|
||||
mod dynamic_momentum_index;
|
||||
mod ease_of_movement;
|
||||
@@ -153,6 +154,7 @@ mod footprint;
|
||||
mod force_index;
|
||||
mod fractal_chaos_bands;
|
||||
mod frama;
|
||||
mod fry_pan_bottom;
|
||||
mod funding_basis;
|
||||
mod funding_rate;
|
||||
mod funding_rate_mean;
|
||||
@@ -171,6 +173,7 @@ mod gravestone_doji;
|
||||
mod hammer;
|
||||
mod hanging_man;
|
||||
mod harami;
|
||||
mod harami_cross;
|
||||
mod head_and_shoulders;
|
||||
mod heikin_ashi;
|
||||
mod heikin_ashi_oscillator;
|
||||
@@ -265,6 +268,7 @@ mod morning_evening_star;
|
||||
mod murrey_math_lines;
|
||||
mod natr;
|
||||
mod new_highs_new_lows;
|
||||
mod new_price_lines;
|
||||
mod nrtr;
|
||||
mod nvi;
|
||||
mod ob_imbalance_full;
|
||||
@@ -416,6 +420,7 @@ mod tick_index;
|
||||
mod tii;
|
||||
mod time_based_stop;
|
||||
mod time_of_day_return_profile;
|
||||
mod tower_top_bottom;
|
||||
mod tpo_profile;
|
||||
mod trade_imbalance;
|
||||
mod trade_volume_index;
|
||||
@@ -427,6 +432,7 @@ mod triangle;
|
||||
mod trima;
|
||||
mod trin;
|
||||
mod triple_top_bottom;
|
||||
mod tristar;
|
||||
mod trix;
|
||||
mod true_range;
|
||||
mod tsf;
|
||||
@@ -596,6 +602,7 @@ pub use downside_gap_three_methods::DownsideGapThreeMethods;
|
||||
pub use dpo::Dpo;
|
||||
pub use dragonfly_doji::DragonflyDoji;
|
||||
pub use drawdown_duration::DrawdownDuration;
|
||||
pub use dumpling_top::DumplingTop;
|
||||
pub use dx::Dx;
|
||||
pub use dynamic_momentum_index::DynamicMomentumIndex;
|
||||
pub use ease_of_movement::EaseOfMovement;
|
||||
@@ -632,6 +639,7 @@ pub use footprint::{Footprint, FootprintLevel, FootprintOutput};
|
||||
pub use force_index::ForceIndex;
|
||||
pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput};
|
||||
pub use frama::Frama;
|
||||
pub use fry_pan_bottom::FryPanBottom;
|
||||
pub use funding_basis::FundingBasis;
|
||||
pub use funding_rate::FundingRate;
|
||||
pub use funding_rate_mean::FundingRateMean;
|
||||
@@ -650,6 +658,7 @@ pub use gravestone_doji::GravestoneDoji;
|
||||
pub use hammer::Hammer;
|
||||
pub use hanging_man::HangingMan;
|
||||
pub use harami::Harami;
|
||||
pub use harami_cross::HaramiCross;
|
||||
pub use head_and_shoulders::HeadAndShoulders;
|
||||
pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
|
||||
pub use heikin_ashi_oscillator::HeikinAshiOscillator;
|
||||
@@ -744,6 +753,7 @@ pub use morning_evening_star::MorningEveningStar;
|
||||
pub use murrey_math_lines::{MurreyMathLines, MurreyMathLinesOutput};
|
||||
pub use natr::Natr;
|
||||
pub use new_highs_new_lows::NewHighsNewLows;
|
||||
pub use new_price_lines::NewPriceLines;
|
||||
pub use nrtr::{Nrtr, NrtrOutput};
|
||||
pub use nvi::Nvi;
|
||||
pub use ob_imbalance_full::OrderBookImbalanceFull;
|
||||
@@ -895,6 +905,7 @@ pub use tick_index::TickIndex;
|
||||
pub use tii::Tii;
|
||||
pub use time_based_stop::TimeBasedStop;
|
||||
pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput};
|
||||
pub use tower_top_bottom::TowerTopBottom;
|
||||
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
|
||||
pub use trade_imbalance::TradeImbalance;
|
||||
pub use trade_volume_index::TradeVolumeIndex;
|
||||
@@ -906,6 +917,7 @@ pub use triangle::Triangle;
|
||||
pub use trima::Trima;
|
||||
pub use trin::Trin;
|
||||
pub use triple_top_bottom::TripleTopBottom;
|
||||
pub use tristar::Tristar;
|
||||
pub use trix::Trix;
|
||||
pub use true_range::TrueRange;
|
||||
pub use tsf::Tsf;
|
||||
@@ -1412,6 +1424,12 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"TasukiGap",
|
||||
"UniqueThreeRiver",
|
||||
"ConcealingBabySwallow",
|
||||
"Tristar",
|
||||
"HaramiCross",
|
||||
"TowerTopBottom",
|
||||
"FryPanBottom",
|
||||
"DumplingTop",
|
||||
"NewPriceLines",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1597,6 +1615,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, 479, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 485, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! New Price Lines — the "eight/ten new price lines" exhaustion count.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// New Price Lines — the Japanese "shinne" (new-price) exhaustion count: when the
|
||||
/// close has made `count` consecutive new highs (or lows), the trend is considered
|
||||
/// stretched and ripe for a pause or reversal.
|
||||
///
|
||||
/// ```text
|
||||
/// consecutive higher closes form "new price lines" up
|
||||
/// consecutive lower closes form "new price lines" down
|
||||
/// signal = −1 once `count` consecutive higher closes (overbought / sell warning)
|
||||
/// signal = +1 once `count` consecutive lower closes (oversold / buy warning)
|
||||
/// signal = 0 otherwise
|
||||
/// ```
|
||||
///
|
||||
/// Traditional Japanese practice flags **eight** new price lines (and a stronger
|
||||
/// **ten** or twelve) as the point where a directional run becomes exhausted —
|
||||
/// the market has gone up (or down) so many bars in a row that a corrective pause
|
||||
/// is statistically due. The signal stays active for every bar the streak remains
|
||||
/// at or above `count`, and clears the moment a close breaks the streak.
|
||||
///
|
||||
/// The first value lands on the second bar (one prior close is needed). The
|
||||
/// output is `+1` / `0` / `−1`. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, NewPriceLines};
|
||||
///
|
||||
/// let mut indicator = NewPriceLines::new(8).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..12 {
|
||||
/// let close = 100.0 + f64::from(i); // 11 consecutive higher closes
|
||||
/// let c = Candle::new(close, close, close, close, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert_eq!(last, Some(-1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewPriceLines {
|
||||
count: usize,
|
||||
prev_close: Option<f64>,
|
||||
consec_up: usize,
|
||||
consec_down: usize,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl NewPriceLines {
|
||||
/// Construct a New Price Lines counter that fires at `count` consecutive new
|
||||
/// closes (classic `8`, stronger `10`/`12`).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `count < 2`.
|
||||
pub fn new(count: usize) -> Result<Self> {
|
||||
if count < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "new price lines count must be >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
count,
|
||||
prev_close: None,
|
||||
consec_up: 0,
|
||||
consec_down: 0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured count threshold.
|
||||
pub const fn count(&self) -> usize {
|
||||
self.count
|
||||
}
|
||||
|
||||
/// Current consecutive streak `(up, down)`.
|
||||
pub const fn streak(&self) -> (usize, usize) {
|
||||
(self.consec_up, self.consec_down)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for NewPriceLines {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let close = candle.close;
|
||||
let Some(prev) = self.prev_close else {
|
||||
self.prev_close = Some(close);
|
||||
return None;
|
||||
};
|
||||
if close > prev {
|
||||
self.consec_up += 1;
|
||||
self.consec_down = 0;
|
||||
} else if close < prev {
|
||||
self.consec_down += 1;
|
||||
self.consec_up = 0;
|
||||
} else {
|
||||
self.consec_up = 0;
|
||||
self.consec_down = 0;
|
||||
}
|
||||
self.prev_close = Some(close);
|
||||
|
||||
let v = if self.consec_up >= self.count {
|
||||
-1.0
|
||||
} else if self.consec_down >= self.count {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.last = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.consec_up = 0;
|
||||
self.consec_down = 0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"NewPriceLines"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(close: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_small_count() {
|
||||
assert!(matches!(
|
||||
NewPriceLines::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(NewPriceLines::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let n = NewPriceLines::new(8).unwrap();
|
||||
assert_eq!(n.count(), 8);
|
||||
assert_eq!(n.streak(), (0, 0));
|
||||
assert_eq!(n.warmup_period(), 2);
|
||||
assert_eq!(n.name(), "NewPriceLines");
|
||||
assert!(!n.is_ready());
|
||||
assert_eq!(n.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_seeds_without_signal() {
|
||||
let mut n = NewPriceLines::new(3).unwrap();
|
||||
assert_eq!(n.update(c(100.0)), None);
|
||||
assert!(n.update(c(101.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_higher_closes_signal_sell() {
|
||||
let mut n = NewPriceLines::new(8).unwrap();
|
||||
// 11 consecutive higher closes -> by the 9th the count reaches 8 -> -1.
|
||||
let candles: Vec<Candle> = (0..12).map(|i| c(100.0 + f64::from(i))).collect();
|
||||
let last = n.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_lower_closes_signal_buy() {
|
||||
let mut n = NewPriceLines::new(8).unwrap();
|
||||
let candles: Vec<Candle> = (0..12).map(|i| c(200.0 - f64::from(i))).collect();
|
||||
let last = n.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_in_streak_clears_signal() {
|
||||
let mut n = NewPriceLines::new(3).unwrap();
|
||||
n.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]); // streak 3 -> -1
|
||||
assert_eq!(n.value(), Some(-1.0));
|
||||
// A lower close breaks the up streak.
|
||||
assert_eq!(n.update(c(102.0)), Some(0.0));
|
||||
assert_eq!(n.streak(), (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_close_resets_streak() {
|
||||
let mut n = NewPriceLines::new(3).unwrap();
|
||||
n.batch(&[c(100.0), c(101.0), c(102.0)]);
|
||||
assert_eq!(n.update(c(102.0)), Some(0.0)); // equal -> reset
|
||||
assert_eq!(n.streak(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut n = NewPriceLines::new(3).unwrap();
|
||||
n.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]);
|
||||
assert!(n.is_ready());
|
||||
n.reset();
|
||||
assert!(!n.is_ready());
|
||||
assert_eq!(n.value(), None);
|
||||
assert_eq!(n.streak(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
|
||||
.collect();
|
||||
let batch = NewPriceLines::new(8).unwrap().batch(&candles);
|
||||
let mut b = NewPriceLines::new(8).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
//! Tower Top / Tower Bottom — a tall bar, a pause, then a tall opposite bar.
|
||||
//!
|
||||
//! A Tower is a reversal where a strong directional bar is followed by a small
|
||||
//! "pause" bar and then a strong bar in the *opposite* direction, like two towers
|
||||
//! flanking a low wall. This is the compact three-bar form of the classic
|
||||
//! multi-bar Tower pattern.
|
||||
//!
|
||||
//! - **Tower Bottom** (`+1.0`): a tall **bearish** bar, a small-bodied bar, then a
|
||||
//! tall **bullish** bar.
|
||||
//! - **Tower Top** (`-1.0`): a tall **bullish** bar, a small-bodied bar, then a
|
||||
//! tall **bearish** bar.
|
||||
//! - Otherwise the output is `0.0`.
|
||||
//!
|
||||
//! "Tall" = body `>= 0.5 * range`; "small" = body `<= 0.3 * range`. The three-bar
|
||||
//! lookback means the first value lands on the third candle.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
fn body_fraction(candle: Candle) -> f64 {
|
||||
let range = candle.high - candle.low;
|
||||
if range > 0.0 {
|
||||
(candle.close - candle.open).abs() / range
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn is_tall(candle: Candle) -> bool {
|
||||
body_fraction(candle) >= 0.5
|
||||
}
|
||||
|
||||
fn is_small(candle: Candle) -> bool {
|
||||
body_fraction(candle) <= 0.3
|
||||
}
|
||||
|
||||
/// Tower Top / Bottom — three-bar reversal detector.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TowerTopBottom {
|
||||
c1: Option<Candle>,
|
||||
c2: Option<Candle>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl TowerTopBottom {
|
||||
/// Construct a new `TowerTopBottom`.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Latest emitted signal if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TowerTopBottom {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let (Some(first), Some(middle)) = (self.c1, self.c2) else {
|
||||
self.c1 = self.c2;
|
||||
self.c2 = Some(candle);
|
||||
self.last_value = Some(0.0);
|
||||
return Some(0.0);
|
||||
};
|
||||
let pause = is_small(middle);
|
||||
let first_tall = is_tall(first);
|
||||
let last_tall = is_tall(candle);
|
||||
let v = if pause && first_tall && last_tall {
|
||||
let first_up = first.close > first.open;
|
||||
let last_up = candle.close > candle.open;
|
||||
if !first_up && last_up {
|
||||
1.0
|
||||
} else if first_up && !last_up {
|
||||
-1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.c1 = self.c2;
|
||||
self.c2 = Some(candle);
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.c1 = None;
|
||||
self.c2 = None;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TowerTopBottom"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
/// A tall candle from `open` to `close` (body fills most of the range).
|
||||
fn tall(open: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(
|
||||
open,
|
||||
open.max(close) + 0.1,
|
||||
open.min(close) - 0.1,
|
||||
close,
|
||||
0.0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
/// A small-bodied candle (long shadows, tiny body).
|
||||
fn small(mid: f64) -> Candle {
|
||||
Candle::new_unchecked(mid, mid + 2.0, mid - 2.0, mid + 0.1, 0.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let t = TowerTopBottom::new();
|
||||
assert_eq!(t.warmup_period(), 3);
|
||||
assert_eq!(t.name(), "TowerTopBottom");
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_two_bars_seed_without_signal() {
|
||||
let mut t = TowerTopBottom::new();
|
||||
assert_eq!(t.update(tall(100.0, 110.0)), Some(0.0));
|
||||
assert_eq!(t.update(small(105.0)), Some(0.0));
|
||||
assert!(t.update(tall(110.0, 100.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tower_top() {
|
||||
// tall bullish, small pause, tall bearish -> top -> -1.
|
||||
let mut t = TowerTopBottom::new();
|
||||
t.update(tall(100.0, 110.0));
|
||||
t.update(small(110.0));
|
||||
assert_eq!(t.update(tall(110.0, 100.0)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tower_bottom() {
|
||||
let mut t = TowerTopBottom::new();
|
||||
t.update(tall(110.0, 100.0));
|
||||
t.update(small(100.0));
|
||||
assert_eq!(t.update(tall(100.0, 110.0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_direction_is_zero() {
|
||||
let mut t = TowerTopBottom::new();
|
||||
t.update(tall(100.0, 110.0));
|
||||
t.update(small(110.0));
|
||||
// last bar also bullish -> not a tower -> 0.
|
||||
assert_eq!(t.update(tall(110.0, 120.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pause_is_zero() {
|
||||
let mut t = TowerTopBottom::new();
|
||||
t.update(tall(100.0, 110.0));
|
||||
t.update(tall(110.0, 120.0)); // middle is tall, not a pause
|
||||
assert_eq!(t.update(tall(120.0, 110.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = TowerTopBottom::new();
|
||||
t.update(tall(100.0, 110.0));
|
||||
t.update(small(110.0));
|
||||
t.update(tall(110.0, 100.0));
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.update(tall(100.0, 110.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_bar_has_zero_body_fraction() {
|
||||
// A flat bar (high == low) exercises the zero-range body-fraction branch;
|
||||
// it counts as a small "pause" bar, so tall-flat-tall still reverses.
|
||||
fn flat(mid: f64) -> Candle {
|
||||
Candle::new_unchecked(mid, mid, mid, mid, 0.0, 0)
|
||||
}
|
||||
let mut t = TowerTopBottom::new();
|
||||
t.update(tall(100.0, 110.0));
|
||||
t.update(flat(110.0));
|
||||
assert_eq!(t.update(tall(110.0, 100.0)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| match i % 3 {
|
||||
0 => tall(100.0, 110.0),
|
||||
1 => small(110.0),
|
||||
_ => tall(110.0, 100.0),
|
||||
})
|
||||
.collect();
|
||||
let batch = TowerTopBottom::new().batch(&candles);
|
||||
let mut b = TowerTopBottom::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
//! Tristar — a three-doji reversal pattern.
|
||||
//!
|
||||
//! A Tristar is three consecutive Doji candles where the middle one gaps away
|
||||
//! from its neighbours, forming a star. A bearish Tristar (top) has the middle
|
||||
//! doji sitting above the other two; a bullish Tristar (bottom) has it below.
|
||||
//!
|
||||
//! - **Bullish** (`+1.0`): three dojis, the middle doji's body centre below both
|
||||
//! neighbours' body centres.
|
||||
//! - **Bearish** (`-1.0`): three dojis, the middle above both neighbours.
|
||||
//! - Otherwise the output is `0.0`.
|
||||
//!
|
||||
//! A doji is a candle whose body is `<= 0.1 * range`. The three-bar lookback means
|
||||
//! the first value lands on the third candle.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Body-centre of a candle.
|
||||
fn body_mid(candle: Candle) -> f64 {
|
||||
f64::midpoint(candle.open, candle.close)
|
||||
}
|
||||
|
||||
/// Whether a candle is a doji (body small relative to range).
|
||||
fn is_doji(candle: Candle) -> bool {
|
||||
let body = (candle.close - candle.open).abs();
|
||||
let range = candle.high - candle.low;
|
||||
range > 0.0 && body <= 0.1 * range
|
||||
}
|
||||
|
||||
/// Tristar — three-doji star reversal detector.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Tristar {
|
||||
c1: Option<Candle>,
|
||||
c2: Option<Candle>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl Tristar {
|
||||
/// Construct a new `Tristar`.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Latest emitted signal if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Tristar {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let (Some(first), Some(middle)) = (self.c1, self.c2) else {
|
||||
self.c1 = self.c2;
|
||||
self.c2 = Some(candle);
|
||||
self.last_value = Some(0.0);
|
||||
return Some(0.0);
|
||||
};
|
||||
let v = if is_doji(first) && is_doji(middle) && is_doji(candle) {
|
||||
let mid = body_mid(middle);
|
||||
let n1 = body_mid(first);
|
||||
let n3 = body_mid(candle);
|
||||
if mid > n1 && mid > n3 {
|
||||
-1.0
|
||||
} else if mid < n1 && mid < n3 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.c1 = self.c2;
|
||||
self.c2 = Some(candle);
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.c1 = None;
|
||||
self.c2 = None;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Tristar"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
/// A doji centred at `mid` (tiny body, symmetric shadows).
|
||||
fn doji(mid: f64) -> Candle {
|
||||
Candle::new_unchecked(mid, mid + 1.0, mid - 1.0, mid + 0.02, 0.0, 0)
|
||||
}
|
||||
|
||||
/// A non-doji (big body).
|
||||
fn solid(open: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(
|
||||
open,
|
||||
open.max(close) + 0.1,
|
||||
open.min(close) - 0.1,
|
||||
close,
|
||||
0.0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let t = Tristar::new();
|
||||
assert_eq!(t.warmup_period(), 3);
|
||||
assert_eq!(t.name(), "Tristar");
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_two_bars_seed_without_signal() {
|
||||
let mut t = Tristar::new();
|
||||
assert_eq!(t.update(doji(100.0)), Some(0.0));
|
||||
assert_eq!(t.update(doji(100.0)), Some(0.0));
|
||||
assert!(t.update(doji(100.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_tristar_top() {
|
||||
// middle doji centred above the two neighbours -> top -> -1.
|
||||
let mut t = Tristar::new();
|
||||
t.update(doji(100.0));
|
||||
t.update(doji(105.0)); // middle, highest
|
||||
assert_eq!(t.update(doji(100.0)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_tristar_bottom() {
|
||||
let mut t = Tristar::new();
|
||||
t.update(doji(100.0));
|
||||
t.update(doji(95.0)); // middle, lowest
|
||||
assert_eq!(t.update(doji(100.0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_doji_is_zero() {
|
||||
let mut t = Tristar::new();
|
||||
t.update(doji(100.0));
|
||||
t.update(solid(100.0, 110.0)); // not a doji
|
||||
assert_eq!(t.update(doji(100.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = Tristar::new();
|
||||
t.update(doji(100.0));
|
||||
t.update(doji(105.0));
|
||||
t.update(doji(100.0));
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.update(doji(100.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| doji(100.0 + (f64::from(i) * 0.4).sin() * 5.0))
|
||||
.collect();
|
||||
let batch = Tristar::new().batch(&candles);
|
||||
let mut b = Tristar::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user