feat(patterns): add the Chart Patterns family (8 swing-based detectors) (#166)
## Summary Adds a new **Chart Patterns** indicator family (counter 351 → 359, families 21 → 22), the first half of the A4 roadmap item (the harmonic patterns follow in a second PR). All eight detectors are built on a shared, non-repainting swing-pivot tracker — the internal, **uncounted** `indicators::pattern_swing` module (declared `pub(crate) mod`, re-exported nowhere). Each consumes candles and emits the uniform pattern sign convention already used by the candlestick family — `+1.0` bullish / `-1.0` bearish / `0.0` otherwise, never `None`. They are parameter-free, baking the swing threshold (5%) and level tolerance (3%) in as documented constants, mirroring how candlestick patterns bake in their geometric thresholds. ## Detectors | Indicator | Signal | |-----------|--------| | `DoubleTopBottom` | twin-peak / twin-trough reversal | | `TripleTopBottom` | three matching extremes (stronger reversal) | | `HeadAndShoulders` | central head + matching shoulders + flat neckline (and inverse) | | `Triangle` | ascending (+1) / descending (-1) / symmetrical | | `Wedge` | rising wedge (-1) / falling wedge (+1) | | `FlagPennant` | shallow consolidation against a pole → continuation | | `RectangleRange` | flat support/resistance mean-reversion | | `CupAndHandle` | rounded base + shallow handle (and inverse) | ## Touchpoints Core modules + `FAMILIES` group and assert, crate root re-exports, Python/Node/WASM bindings via the candle-pattern macros (Node `index.d.ts`/`index.js` regenerated), the candle fuzz target, Python reference + `CANDLE_SCALAR` registry tests and the Node candle-scalar factory, README catalogue counter + banner cache-buster + family table row + family-count word, `docs/README.md` counter, and the changelog. ## Verification - `cargo test -p wickra-core --lib` — 2915 passed - `cargo test -p wickra-core --doc` — 335 passed - `cargo clippy --workspace --all-targets --all-features -- -D warnings` — clean - Node `npm run build && npm test` — 436 passed - Python `maturin develop --release` + `pytest` — 732 passed Every detector branch is unit-tested; multi-condition predicates were flattened to single-line precomputed booleans to keep patch coverage at 100%.
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
//! Cup-and-Handle (and Inverse) continuation chart pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{
|
||||
approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
|
||||
};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Cup-and-Handle / Inverse — a rounded base (the cup) followed by a shallow
|
||||
/// pullback (the handle) near the rim, then a breakout in the cup's direction.
|
||||
///
|
||||
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%) and read from the
|
||||
/// last four pivots:
|
||||
///
|
||||
/// ```text
|
||||
/// cup-and-handle (bullish, +1): Rim(high) , Cup(low) , Rim(high) , Handle(low)
|
||||
/// the two rims match (±3%) ; the handle low sits ABOVE the cup low (a shallow
|
||||
/// pullback) and below the right rim
|
||||
///
|
||||
/// inverse (bearish, -1): Rim(low) , Cap(high) , Rim(low) , Handle(high)
|
||||
/// the two rims match ; the handle high sits BELOW the cap high and above the
|
||||
/// right rim
|
||||
/// ```
|
||||
///
|
||||
/// The shallow handle (closer to the rim than the cup extreme) is what
|
||||
/// distinguishes a cup-and-handle from a plain double bottom/top. Output is
|
||||
/// `+1.0` / `-1.0` / `0.0`; never `None`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CupAndHandle {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl CupAndHandle {
|
||||
/// Construct a new Cup-and-Handle detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 4),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CupAndHandle {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CupAndHandle {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 4 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let n = pivots.len();
|
||||
let rim_left = pivots[n - 4];
|
||||
let extreme = pivots[n - 3];
|
||||
let rim_right = pivots[n - 2];
|
||||
let handle = pivots[n - 1];
|
||||
let rims_match = approx_equal(rim_left.price, rim_right.price, LEVEL_TOLERANCE);
|
||||
|
||||
if handle.direction < 0.0 {
|
||||
// Bullish cup-and-handle: rims are highs, cup is the low between them,
|
||||
// handle is a shallow low above the cup but below the right rim.
|
||||
if rims_match && handle.price > extreme.price && handle.price < rim_right.price {
|
||||
return Some(1.0);
|
||||
}
|
||||
} else if rims_match && handle.price < extreme.price && handle.price > rim_right.price {
|
||||
// Inverse: rims are lows, cap is the high, handle a shallow high.
|
||||
return Some(-1.0);
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Four confirmed pivots; the earliest confirmation of the fourth is bar 5.
|
||||
5
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CupAndHandle"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = CupAndHandle::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = CupAndHandle::new();
|
||||
assert_eq!(indicator.name(), "CupAndHandle");
|
||||
assert_eq!(indicator.warmup_period(), 5);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!CupAndHandle::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cup_and_handle_is_plus_one() {
|
||||
// Rims 120/121, cup 90 (deep), handle 110 (shallow, above the cup).
|
||||
let out = run(&[120.0, 90.0, 121.0, 110.0]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_cup_and_handle_is_minus_one() {
|
||||
// Lead high then rims 100/101, cap 130, handle 110 (below cap, above rim).
|
||||
let out = run(&[140.0, 100.0, 130.0, 101.0, 110.0]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_handle_is_not_cup_and_handle() {
|
||||
// Handle (85) below the cup low (90) → a double bottom, not cup-and-handle.
|
||||
let out = run(&[120.0, 90.0, 121.0, 85.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_with_mismatched_rims_does_not_trigger() {
|
||||
// Inverse shape (ends high) but the rims (100 / 90) diverge → enters the
|
||||
// inverse branch yet reports no pattern.
|
||||
let out = run(&[140.0, 100.0, 130.0, 90.0, 110.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = CupAndHandle::new();
|
||||
for c in candles_for_pivots(&[120.0, 90.0, 121.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[120.0, 90.0, 121.0, 110.0]);
|
||||
let mut a = CupAndHandle::new();
|
||||
let mut b = CupAndHandle::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Double Top / Double Bottom reversal chart pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{
|
||||
approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
|
||||
};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Double Top / Double Bottom — a two-peak (or two-trough) reversal pattern.
|
||||
///
|
||||
/// The detector tracks confirmed swing pivots (a non-repainting percent-threshold
|
||||
/// zig-zag, [`SWING_THRESHOLD`] = 5%). A pattern is recognised on the bar that
|
||||
/// confirms the **second** matching extreme:
|
||||
///
|
||||
/// ```text
|
||||
/// double top : … High₁ , Low , High₂ with High₁ ≈ High₂ → -1 (bearish)
|
||||
/// double bottom : … Low₁ , High , Low₂ with Low₁ ≈ Low₂ → +1 (bullish)
|
||||
/// ```
|
||||
///
|
||||
/// Two extremes count as the same level when they are within
|
||||
/// [`LEVEL_TOLERANCE`] (3%) of each other. Because pivots strictly alternate
|
||||
/// high/low, the trough between the twin tops (or the peak between the twin
|
||||
/// bottoms) is guaranteed to sit beyond both, so no extra separation check is
|
||||
/// needed.
|
||||
///
|
||||
/// Output is `+1.0` for a double bottom, `-1.0` for a double top, and `0.0` on
|
||||
/// every other bar (including warmup and bars that confirm a pivot which does
|
||||
/// not complete the pattern). Like the candlestick family this detector never
|
||||
/// returns `None`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, DoubleTopBottom, Indicator};
|
||||
///
|
||||
/// let mut indicator = DoubleTopBottom::new();
|
||||
/// for (i, &(high, low)) in [
|
||||
/// (100.0, 99.5),
|
||||
/// (120.0, 119.5),
|
||||
/// (110.0, 100.0), // confirms the first top at 120
|
||||
/// (120.0, 119.0), // confirms the trough at 100
|
||||
/// (115.0, 110.0), // confirms the second top at 120 → double top
|
||||
/// ]
|
||||
/// .iter()
|
||||
/// .enumerate()
|
||||
/// {
|
||||
/// let c = Candle::new(low, high, low, low, 1.0, i as i64).unwrap();
|
||||
/// let signal = indicator.update(c).unwrap();
|
||||
/// if i == 4 {
|
||||
/// assert_eq!(signal, -1.0);
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DoubleTopBottom {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl DoubleTopBottom {
|
||||
/// Construct a new Double Top / Double Bottom detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 3),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DoubleTopBottom {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DoubleTopBottom {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 3 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let first = pivots[pivots.len() - 3];
|
||||
let last = pivots[pivots.len() - 1];
|
||||
if approx_equal(first.price, last.price, LEVEL_TOLERANCE) {
|
||||
// `last` is the just-confirmed extreme: a high → double top (bearish),
|
||||
// a low → double bottom (bullish).
|
||||
return Some(if last.direction > 0.0 { -1.0 } else { 1.0 });
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The first complete pattern needs three confirmed pivots; the earliest
|
||||
// bar that can confirm a third pivot is the fifth.
|
||||
5
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DoubleTopBottom"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = DoubleTopBottom::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = DoubleTopBottom::new();
|
||||
assert_eq!(indicator.name(), "DoubleTopBottom");
|
||||
assert_eq!(indicator.warmup_period(), 5);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!DoubleTopBottom::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_top_is_minus_one() {
|
||||
// Twin highs 120 / 120 with a 100 trough → double top on the second.
|
||||
let out = run(&[120.0, 100.0, 120.0]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
// All earlier bars are warmup / non-completing.
|
||||
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_bottom_is_plus_one() {
|
||||
// Lead high, then twin lows 100 / 99 around a 120 peak → double bottom.
|
||||
let out = run(&[130.0, 100.0, 120.0, 99.0]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unequal_tops_do_not_trigger() {
|
||||
// Second top 140 diverges from the first (120) → no pattern.
|
||||
let out = run(&[120.0, 100.0, 140.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
assert!(out.iter().all(|&x| x == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = DoubleTopBottom::new();
|
||||
for c in candles_for_pivots(&[120.0, 100.0, 120.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[120.0, 100.0, 120.0]);
|
||||
let mut a = DoubleTopBottom::new();
|
||||
let mut b = DoubleTopBottom::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Flag / Pennant continuation chart pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Maximum size of the consolidation swing relative to the pole for a
|
||||
/// flag/pennant to qualify — the pullback must retrace less than half the pole.
|
||||
const MAX_RETRACE_FRACTION: f64 = 0.5;
|
||||
|
||||
/// Flag / Pennant — a brief consolidation against a sharp prior move (the
|
||||
/// "pole"), resolving in the pole's direction.
|
||||
///
|
||||
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); evaluated from the
|
||||
/// last three pivots `pole_start → pole_end → consolidation`:
|
||||
///
|
||||
/// ```text
|
||||
/// pole = |pole_end − pole_start| (the sharp impulse)
|
||||
/// pullback = |consolidation − pole_end| (the shallow counter-move)
|
||||
/// qualifies when pullback < 0.5 · pole
|
||||
/// bull flag : pole_end is a swing high → +1 (up-pole, continuation up)
|
||||
/// bear flag : pole_end is a swing low → -1 (down-pole, continuation down)
|
||||
/// ```
|
||||
///
|
||||
/// The detector fires on the bar that confirms the consolidation pivot (the flag
|
||||
/// is complete; the breakout is expected to follow). Output is `+1.0` / `-1.0` /
|
||||
/// `0.0`; never `None`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlagPennant {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl FlagPennant {
|
||||
/// Construct a new Flag / Pennant detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 3),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FlagPennant {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FlagPennant {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 3 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let n = pivots.len();
|
||||
let pole_start = pivots[n - 3];
|
||||
let pole_end = pivots[n - 2];
|
||||
let consolidation = pivots[n - 1];
|
||||
let pole = (pole_end.price - pole_start.price).abs();
|
||||
let pullback = (consolidation.price - pole_end.price).abs();
|
||||
|
||||
if pole > 0.0 && pullback < MAX_RETRACE_FRACTION * pole {
|
||||
// pole_end a high → up-pole → bull flag; a low → bear flag.
|
||||
return Some(if pole_end.direction > 0.0 { 1.0 } else { -1.0 });
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Three confirmed pivots; the earliest confirmation of the third is bar 4.
|
||||
4
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FlagPennant"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = FlagPennant::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = FlagPennant::new();
|
||||
assert_eq!(indicator.name(), "FlagPennant");
|
||||
assert_eq!(indicator.warmup_period(), 4);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!FlagPennant::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bull_flag_is_plus_one() {
|
||||
// Up-pole 100 → 140 (40), shallow pullback to 130 (10 < 20) → bull flag.
|
||||
let out = run(&[150.0, 100.0, 140.0, 130.0]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bear_flag_is_minus_one() {
|
||||
// Down-pole 140 → 100 (40), shallow pullback to 110 (10 < 20) → bear flag.
|
||||
let out = run(&[140.0, 100.0, 110.0]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_pullback_is_not_a_flag() {
|
||||
// Pole 100 → 140 (40) but pullback to 104 (36 > 20) → not a flag.
|
||||
let out = run(&[150.0, 100.0, 140.0, 104.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = FlagPennant::new();
|
||||
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 130.0]);
|
||||
let mut a = FlagPennant::new();
|
||||
let mut b = FlagPennant::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Head-and-Shoulders (and Inverse) reversal chart pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{
|
||||
approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
|
||||
};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Head-and-Shoulders / Inverse Head-and-Shoulders — a five-pivot reversal
|
||||
/// pattern with a central extreme (the head) flanked by two lower/higher
|
||||
/// shoulders at a similar level, joined by a roughly horizontal neckline.
|
||||
///
|
||||
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); recognised on the
|
||||
/// bar that confirms the right shoulder:
|
||||
///
|
||||
/// ```text
|
||||
/// head-and-shoulders top (bearish, -1):
|
||||
/// LeftShoulder(high) , Trough , Head(high) , Trough , RightShoulder(high)
|
||||
/// Head > both shoulders ; LeftShoulder ≈ RightShoulder ; Trough₁ ≈ Trough₂
|
||||
///
|
||||
/// inverse head-and-shoulders (bullish, +1):
|
||||
/// LeftShoulder(low) , Peak , Head(low) , Peak , RightShoulder(low)
|
||||
/// Head < both shoulders ; LeftShoulder ≈ RightShoulder ; Peak₁ ≈ Peak₂
|
||||
/// ```
|
||||
///
|
||||
/// The shoulders must match within [`LEVEL_TOLERANCE`] (3%) and the two neckline
|
||||
/// points within the same tolerance. Output is `-1.0` for a top, `+1.0` for an
|
||||
/// inverse, `0.0` otherwise; never `None`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeadAndShoulders {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl HeadAndShoulders {
|
||||
/// Construct a new Head-and-Shoulders detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 5),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HeadAndShoulders {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HeadAndShoulders {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 5 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let n = pivots.len();
|
||||
let left_shoulder = pivots[n - 5];
|
||||
let neck_1 = pivots[n - 4];
|
||||
let head = pivots[n - 3];
|
||||
let neck_2 = pivots[n - 2];
|
||||
let right_shoulder = pivots[n - 1];
|
||||
|
||||
let shoulders_match =
|
||||
approx_equal(left_shoulder.price, right_shoulder.price, LEVEL_TOLERANCE);
|
||||
let neckline_flat = approx_equal(neck_1.price, neck_2.price, LEVEL_TOLERANCE);
|
||||
let head_is_peak = head.price > left_shoulder.price && head.price > right_shoulder.price;
|
||||
let head_is_trough = head.price < left_shoulder.price && head.price < right_shoulder.price;
|
||||
let frame_matches = shoulders_match && neckline_flat;
|
||||
|
||||
if right_shoulder.direction > 0.0 {
|
||||
// Head-and-shoulders top: head is the highest of the three highs.
|
||||
if head_is_peak && frame_matches {
|
||||
return Some(-1.0);
|
||||
}
|
||||
} else if head_is_trough && frame_matches {
|
||||
// Inverse: head is the lowest of the three lows.
|
||||
return Some(1.0);
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Five confirmed pivots; the earliest confirmation of the fifth is bar 6.
|
||||
6
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HeadAndShoulders"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = HeadAndShoulders::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = HeadAndShoulders::new();
|
||||
assert_eq!(indicator.name(), "HeadAndShoulders");
|
||||
assert_eq!(indicator.warmup_period(), 6);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!HeadAndShoulders::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_and_shoulders_top_is_minus_one() {
|
||||
// LS 100, trough 90, head 120, trough 92, RS 101.
|
||||
let out = run(&[100.0, 90.0, 120.0, 92.0, 101.0]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_head_and_shoulders_is_plus_one() {
|
||||
// Lead high then LS 100, peak 110, head 80, peak 108, RS 101.
|
||||
let out = run(&[130.0, 100.0, 110.0, 80.0, 108.0, 101.0]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_shoulders_do_not_trigger() {
|
||||
// Right shoulder (115) far from left (100) → no pattern.
|
||||
let out = run(&[100.0, 90.0, 130.0, 92.0, 115.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_mismatched_shoulders_do_not_trigger() {
|
||||
// Inverse shape (ends on a low) but the right shoulder (90) diverges from
|
||||
// the left (100) → enters the inverse branch yet reports no pattern.
|
||||
let out = run(&[130.0, 100.0, 110.0, 80.0, 108.0, 90.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_highs_without_taller_head_do_not_trigger() {
|
||||
// Three equal highs (no dominant head) → not H&S (that is a triple top).
|
||||
let out = run(&[120.0, 90.0, 120.0, 92.0, 120.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = HeadAndShoulders::new();
|
||||
for c in candles_for_pivots(&[100.0, 90.0, 120.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[100.0, 90.0, 120.0, 92.0, 101.0]);
|
||||
let mut a = HeadAndShoulders::new();
|
||||
let mut b = HeadAndShoulders::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@
|
||||
//! [`FAMILIES`]. Every public name is re-exported flat from this module and
|
||||
//! from the crate root for convenience.
|
||||
|
||||
// Internal shared building block for the chart- and harmonic-pattern detectors.
|
||||
// Declared `pub(crate)` (not `mod`) so it is excluded from the public-catalogue
|
||||
// counter (`grep -c '^mod '`) and re-exported nowhere.
|
||||
pub(crate) mod pattern_swing;
|
||||
|
||||
mod abandoned_baby;
|
||||
mod absolute_breadth_index;
|
||||
mod acceleration_bands;
|
||||
@@ -66,6 +71,7 @@ mod connors_rsi;
|
||||
mod coppock;
|
||||
mod counterattack;
|
||||
mod cumulative_volume_index;
|
||||
mod cup_and_handle;
|
||||
mod cvd;
|
||||
mod cybernetic_cycle;
|
||||
mod day_of_week_profile;
|
||||
@@ -82,6 +88,7 @@ mod doji_star;
|
||||
mod donchian;
|
||||
mod donchian_stop;
|
||||
mod double_bollinger;
|
||||
mod double_top_bottom;
|
||||
mod downside_gap_three_methods;
|
||||
mod dpo;
|
||||
mod dragonfly_doji;
|
||||
@@ -100,6 +107,7 @@ mod falling_three_methods;
|
||||
mod fama;
|
||||
mod fibonacci_pivots;
|
||||
mod fisher_transform;
|
||||
mod flag_pennant;
|
||||
mod footprint;
|
||||
mod force_index;
|
||||
mod fractal_chaos_bands;
|
||||
@@ -116,6 +124,7 @@ mod gravestone_doji;
|
||||
mod hammer;
|
||||
mod hanging_man;
|
||||
mod harami;
|
||||
mod head_and_shoulders;
|
||||
mod heikin_ashi;
|
||||
mod high_low_index;
|
||||
mod high_wave;
|
||||
@@ -229,6 +238,7 @@ mod quoted_spread;
|
||||
mod r_squared;
|
||||
mod realized_spread;
|
||||
mod recovery_factor;
|
||||
mod rectangle_range;
|
||||
mod relative_strength_ab;
|
||||
mod renko_bars;
|
||||
mod renko_trailing_stop;
|
||||
@@ -308,8 +318,10 @@ mod time_of_day_return_profile;
|
||||
mod tpo_profile;
|
||||
mod trade_imbalance;
|
||||
mod treynor_ratio;
|
||||
mod triangle;
|
||||
mod trima;
|
||||
mod trin;
|
||||
mod triple_top_bottom;
|
||||
mod trix;
|
||||
mod true_range;
|
||||
mod tsf;
|
||||
@@ -343,6 +355,7 @@ mod vwap_stddev_bands;
|
||||
mod vwma;
|
||||
mod vzo;
|
||||
mod wave_trend;
|
||||
mod wedge;
|
||||
mod weighted_close;
|
||||
mod williams_fractals;
|
||||
mod williams_r;
|
||||
@@ -417,6 +430,7 @@ pub use connors_rsi::ConnorsRsi;
|
||||
pub use coppock::Coppock;
|
||||
pub use counterattack::Counterattack;
|
||||
pub use cumulative_volume_index::CumulativeVolumeIndex;
|
||||
pub use cup_and_handle::CupAndHandle;
|
||||
pub use cvd::CumulativeVolumeDelta;
|
||||
pub use cybernetic_cycle::CyberneticCycle;
|
||||
pub use day_of_week_profile::{DayOfWeekProfile, DayOfWeekProfileOutput};
|
||||
@@ -433,6 +447,7 @@ pub use doji_star::DojiStar;
|
||||
pub use donchian::{Donchian, DonchianOutput};
|
||||
pub use donchian_stop::{DonchianStop, DonchianStopOutput};
|
||||
pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput};
|
||||
pub use double_top_bottom::DoubleTopBottom;
|
||||
pub use downside_gap_three_methods::DownsideGapThreeMethods;
|
||||
pub use dpo::Dpo;
|
||||
pub use dragonfly_doji::DragonflyDoji;
|
||||
@@ -451,6 +466,7 @@ pub use falling_three_methods::FallingThreeMethods;
|
||||
pub use fama::Fama;
|
||||
pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput};
|
||||
pub use fisher_transform::FisherTransform;
|
||||
pub use flag_pennant::FlagPennant;
|
||||
pub use footprint::{Footprint, FootprintLevel, FootprintOutput};
|
||||
pub use force_index::ForceIndex;
|
||||
pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput};
|
||||
@@ -467,6 +483,7 @@ pub use gravestone_doji::GravestoneDoji;
|
||||
pub use hammer::Hammer;
|
||||
pub use hanging_man::HangingMan;
|
||||
pub use harami::Harami;
|
||||
pub use head_and_shoulders::HeadAndShoulders;
|
||||
pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
|
||||
pub use high_low_index::HighLowIndex;
|
||||
pub use high_wave::HighWave;
|
||||
@@ -580,6 +597,7 @@ pub use quoted_spread::QuotedSpread;
|
||||
pub use r_squared::RSquared;
|
||||
pub use realized_spread::RealizedSpread;
|
||||
pub use recovery_factor::RecoveryFactor;
|
||||
pub use rectangle_range::RectangleRange;
|
||||
pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
|
||||
pub use renko_bars::{RenkoBars, RenkoBrick};
|
||||
pub use renko_trailing_stop::RenkoTrailingStop;
|
||||
@@ -659,8 +677,10 @@ pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProf
|
||||
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
|
||||
pub use trade_imbalance::TradeImbalance;
|
||||
pub use treynor_ratio::TreynorRatio;
|
||||
pub use triangle::Triangle;
|
||||
pub use trima::Trima;
|
||||
pub use trin::Trin;
|
||||
pub use triple_top_bottom::TripleTopBottom;
|
||||
pub use trix::Trix;
|
||||
pub use true_range::TrueRange;
|
||||
pub use tsf::Tsf;
|
||||
@@ -694,6 +714,7 @@ pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput};
|
||||
pub use vwma::Vwma;
|
||||
pub use vzo::Vzo;
|
||||
pub use wave_trend::{WaveTrend, WaveTrendOutput};
|
||||
pub use wedge::Wedge;
|
||||
pub use weighted_close::WeightedClose;
|
||||
pub use williams_fractals::{WilliamsFractals, WilliamsFractalsOutput};
|
||||
pub use williams_r::WilliamsR;
|
||||
@@ -1159,6 +1180,19 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"VolumeByTimeProfile",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Chart Patterns",
|
||||
&[
|
||||
"DoubleTopBottom",
|
||||
"TripleTopBottom",
|
||||
"HeadAndShoulders",
|
||||
"Triangle",
|
||||
"Wedge",
|
||||
"FlagPennant",
|
||||
"RectangleRange",
|
||||
"CupAndHandle",
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1187,6 +1221,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, 351, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 359, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
//! Internal swing-pivot tracker shared by the chart-pattern and harmonic-pattern
|
||||
//! detectors. Not a public indicator — it carries no `Indicator` impl and is
|
||||
//! re-exported nowhere, so it is excluded from the public catalogue counter.
|
||||
//!
|
||||
//! The tracker mirrors [`crate::indicators::ZigZag`]'s non-repainting
|
||||
//! percent-threshold confirmation logic, but differs in two ways that make it a
|
||||
//! reusable building block rather than a standalone indicator:
|
||||
//!
|
||||
//! * It is **parameter-free at the call site** — the reversal threshold is baked
|
||||
//! in by each detector as a compile-time constant, so construction is
|
||||
//! infallible (`const fn new`) and there is no user-facing validation branch.
|
||||
//! * It **accumulates a bounded history** of the most recently confirmed pivots
|
||||
//! (capped at `cap`), so a detector can inspect the last few swings to match a
|
||||
//! geometric template (double top, head-and-shoulders, the XABCD legs of a
|
||||
//! harmonic pattern, …).
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
|
||||
/// Default fractional reversal threshold for pattern swing detection (5%). A
|
||||
/// pivot is confirmed once price reverses by this fraction away from the running
|
||||
/// extreme. Baked in so the pattern detectors stay parameter-free, mirroring the
|
||||
/// candlestick-pattern family's fixed geometric thresholds.
|
||||
pub(crate) const SWING_THRESHOLD: f64 = 0.05;
|
||||
|
||||
/// Default relative tolerance for two swing levels to count as "equal" (3%) —
|
||||
/// the twin tops of a double top, the shoulders of a head-and-shoulders, the
|
||||
/// flat boundary of a rectangle.
|
||||
pub(crate) const LEVEL_TOLERANCE: f64 = 0.03;
|
||||
|
||||
/// A confirmed swing pivot: the extreme price the swing turned from and its
|
||||
/// direction (`+1.0` for a swing high, `-1.0` for a swing low).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct Pivot {
|
||||
/// Price of the confirmed swing extreme.
|
||||
pub price: f64,
|
||||
/// `+1.0` if the pivot is a swing high, `-1.0` if it is a swing low.
|
||||
pub direction: f64,
|
||||
}
|
||||
|
||||
/// Non-repainting percent-threshold swing tracker with a bounded pivot history.
|
||||
///
|
||||
/// Feeding a candle returns `true` exactly on the bar where a new pivot is
|
||||
/// confirmed (price has reversed by the configured fraction away from the
|
||||
/// running extreme); the newly confirmed pivot is appended to [`pivots`] and the
|
||||
/// oldest is dropped once the cap is exceeded. Bars that merely extend the
|
||||
/// running extreme, or that move less than the threshold, return `false`.
|
||||
///
|
||||
/// [`pivots`]: SwingTracker::pivots
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SwingTracker {
|
||||
threshold: f64,
|
||||
cap: usize,
|
||||
state: Option<State>,
|
||||
pivots: Vec<Pivot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct State {
|
||||
/// `+1.0` while tracking a candidate high (uptrend), `-1.0` while tracking a
|
||||
/// candidate low (downtrend).
|
||||
direction: f64,
|
||||
/// The running candidate extreme price.
|
||||
extreme: f64,
|
||||
}
|
||||
|
||||
impl SwingTracker {
|
||||
/// Construct a tracker with a fractional reversal `threshold` (e.g. `0.05`
|
||||
/// for 5%) and a pivot history capped at `cap` entries.
|
||||
///
|
||||
/// The threshold is supplied by the detectors as a compile-time constant in
|
||||
/// `(0, 1)`, so no runtime validation is performed — an out-of-range
|
||||
/// constant would be a library bug caught by the unit tests, not invalid
|
||||
/// caller input.
|
||||
pub(crate) const fn new(threshold: f64, cap: usize) -> Self {
|
||||
Self {
|
||||
threshold,
|
||||
cap,
|
||||
state: None,
|
||||
pivots: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one candle. Returns `true` when a new pivot was confirmed this bar.
|
||||
pub(crate) fn update(&mut self, candle: Candle) -> bool {
|
||||
let Some(s) = self.state else {
|
||||
// Bootstrap: seed an uptrend tracking the first candle's high.
|
||||
self.state = Some(State {
|
||||
direction: 1.0,
|
||||
extreme: candle.high,
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
if s.direction > 0.0 {
|
||||
if candle.high > s.extreme {
|
||||
// Extend the candidate high.
|
||||
self.state = Some(State {
|
||||
direction: 1.0,
|
||||
extreme: candle.high,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if candle.low <= s.extreme * (1.0 - self.threshold) {
|
||||
// Confirm the swing high; flip to tracking this bar's low.
|
||||
self.push(Pivot {
|
||||
price: s.extreme,
|
||||
direction: 1.0,
|
||||
});
|
||||
self.state = Some(State {
|
||||
direction: -1.0,
|
||||
extreme: candle.low,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
false
|
||||
} else {
|
||||
if candle.low < s.extreme {
|
||||
// Extend the candidate low.
|
||||
self.state = Some(State {
|
||||
direction: -1.0,
|
||||
extreme: candle.low,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if candle.high >= s.extreme * (1.0 + self.threshold) {
|
||||
// Confirm the swing low; flip to tracking this bar's high.
|
||||
self.push(Pivot {
|
||||
price: s.extreme,
|
||||
direction: -1.0,
|
||||
});
|
||||
self.state = Some(State {
|
||||
direction: 1.0,
|
||||
extreme: candle.high,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, pivot: Pivot) {
|
||||
self.pivots.push(pivot);
|
||||
if self.pivots.len() > self.cap {
|
||||
self.pivots.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The confirmed pivots in chronological order (oldest first, newest last).
|
||||
pub(crate) fn pivots(&self) -> &[Pivot] {
|
||||
&self.pivots
|
||||
}
|
||||
|
||||
/// Clear all state, returning the tracker to its just-constructed condition.
|
||||
pub(crate) fn reset(&mut self) {
|
||||
self.state = None;
|
||||
self.pivots.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// The two most recent swing highs and lows from the last four (strictly
|
||||
/// alternating) pivots, returned as `(high_old, high_new, low_old, low_new)`.
|
||||
/// Used by the converging/diverging trendline patterns (triangle, wedge,
|
||||
/// rectangle). The slice must hold at least four pivots.
|
||||
pub(crate) fn recent_legs(pivots: &[Pivot]) -> (f64, f64, f64, f64) {
|
||||
let n = pivots.len();
|
||||
if pivots[n - 1].direction > 0.0 {
|
||||
// … low_old, high_old, low_new, high_new (newest is a high)
|
||||
(
|
||||
pivots[n - 3].price,
|
||||
pivots[n - 1].price,
|
||||
pivots[n - 4].price,
|
||||
pivots[n - 2].price,
|
||||
)
|
||||
} else {
|
||||
// … high_old, low_old, high_new, low_new (newest is a low)
|
||||
(
|
||||
pivots[n - 4].price,
|
||||
pivots[n - 2].price,
|
||||
pivots[n - 3].price,
|
||||
pivots[n - 1].price,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Relative-tolerance equality: `true` when `a` and `b` are within `tol`
|
||||
/// (a fraction) of the larger magnitude. Used to decide whether two swing
|
||||
/// levels (the twin highs of a double top, the shoulders of a head-and-shoulders,
|
||||
/// a harmonic Fibonacci ratio) count as "the same".
|
||||
pub(crate) fn approx_equal(a: f64, b: f64, tol: f64) -> bool {
|
||||
let scale = a.abs().max(b.abs()).max(f64::MIN_POSITIVE);
|
||||
(a - b).abs() <= tol * scale
|
||||
}
|
||||
|
||||
/// Build a candle sequence that drives a `SwingTracker` (or any detector built
|
||||
/// on one) to confirm exactly the given alternating pivot prices, in order.
|
||||
///
|
||||
/// `pivots` must start with a **high** and strictly alternate high/low, with
|
||||
/// each consecutive pair differing by at least the swing threshold (5%) in the
|
||||
/// correct direction (`high > adjacent low * 1.05`). The returned vector has one
|
||||
/// seed candle plus one confirming candle per pivot; pivot `k` is confirmed by
|
||||
/// candle `k + 1`. Only the high/low of each candle is meaningful — the pattern
|
||||
/// detectors read swings, not bodies.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn candles_for_pivots(pivots: &[f64]) -> Vec<Candle> {
|
||||
fn bar(high: f64, low: f64, ts: i64) -> Candle {
|
||||
Candle::new(low, high, low, low, 1.0, ts).unwrap()
|
||||
}
|
||||
let mut out = vec![bar(pivots[0], pivots[0] * 0.999, 0)];
|
||||
let mut ts: i64 = 0;
|
||||
for (k, &price) in pivots.iter().enumerate() {
|
||||
ts += 1;
|
||||
let is_high = k % 2 == 0;
|
||||
let next = if k + 1 < pivots.len() {
|
||||
pivots[k + 1]
|
||||
} else if is_high {
|
||||
price * 0.90
|
||||
} else {
|
||||
price * 1.10
|
||||
};
|
||||
let candle = if is_high {
|
||||
// Reverse down from the candidate high `price` to confirm it.
|
||||
bar(price * 0.99, next, ts)
|
||||
} else {
|
||||
// Reverse up from the candidate low `price` to confirm it.
|
||||
bar(next, price * 1.01, ts)
|
||||
};
|
||||
out.push(candle);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn c_hl(high: f64, low: f64, ts: i64) -> Candle {
|
||||
Candle::new(low, high, low, low, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_only_bootstraps_no_pivot() {
|
||||
let mut t = SwingTracker::new(0.05, 6);
|
||||
assert!(!t.update(c_hl(100.0, 99.5, 0)));
|
||||
assert!(t.pivots().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extends_candidate_high_without_confirming() {
|
||||
let mut t = SwingTracker::new(0.10, 6);
|
||||
assert!(!t.update(c_hl(100.0, 99.5, 0)));
|
||||
// A higher high merely raises the candidate — no pivot yet.
|
||||
assert!(!t.update(c_hl(110.0, 109.0, 1)));
|
||||
assert!(t.pivots().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_small_move_does_not_confirm() {
|
||||
let mut t = SwingTracker::new(0.10, 6);
|
||||
let _ = t.update(c_hl(100.0, 99.5, 0));
|
||||
// A 1% dip is below the 10% threshold — neither extends nor confirms.
|
||||
assert!(!t.update(c_hl(99.8, 99.0, 1)));
|
||||
assert!(t.pivots().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirms_high_then_low_alternating() {
|
||||
let mut t = SwingTracker::new(0.10, 6);
|
||||
let _ = t.update(c_hl(100.0, 99.5, 0)); // seed uptrend
|
||||
let _ = t.update(c_hl(120.0, 119.5, 1)); // raise candidate high to 120
|
||||
// Drop ≥10% below 120 → confirm the high at 120, flip to downtrend.
|
||||
assert!(t.update(c_hl(101.0, 100.0, 2)));
|
||||
assert_eq!(
|
||||
t.pivots().last().copied(),
|
||||
Some(Pivot {
|
||||
price: 120.0,
|
||||
direction: 1.0,
|
||||
})
|
||||
);
|
||||
// Now in a downtrend: a lower low extends the candidate low.
|
||||
assert!(!t.update(c_hl(100.5, 90.0, 3)));
|
||||
// Rise ≥10% above 90 → confirm the low at 90.
|
||||
assert!(t.update(c_hl(100.0, 99.0, 4)));
|
||||
assert_eq!(
|
||||
t.pivots().last().copied(),
|
||||
Some(Pivot {
|
||||
price: 90.0,
|
||||
direction: -1.0,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downtrend_tiny_rise_does_not_confirm() {
|
||||
let mut t = SwingTracker::new(0.10, 6);
|
||||
let _ = t.update(c_hl(100.0, 99.5, 0));
|
||||
let _ = t.update(c_hl(120.0, 119.5, 1));
|
||||
let _ = t.update(c_hl(101.0, 90.0, 2)); // confirm high, now downtrend at 90
|
||||
// A 1% bounce is below threshold — no confirmation, no new candidate low.
|
||||
assert!(!t.update(c_hl(91.0, 90.5, 3)));
|
||||
assert_eq!(t.pivots().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_is_capped() {
|
||||
let mut t = SwingTracker::new(0.10, 2);
|
||||
// Drive an oscillation that confirms several pivots; only the last 2 stay.
|
||||
let path = [
|
||||
(100.0, 99.5),
|
||||
(120.0, 119.5),
|
||||
(101.0, 90.0), // confirm 120 (high)
|
||||
(91.0, 90.5),
|
||||
(110.0, 109.0), // confirm 90 (low)
|
||||
(109.0, 95.0), // confirm 110 (high)
|
||||
];
|
||||
for (i, (h, l)) in path.iter().enumerate() {
|
||||
let _ = t.update(c_hl(*h, *l, i64::try_from(i).unwrap()));
|
||||
}
|
||||
assert_eq!(t.pivots().len(), 2);
|
||||
// The two most recent confirmations: low 90 then high 110.
|
||||
assert_eq!(t.pivots()[0].price, 90.0);
|
||||
assert_eq!(t.pivots()[1].price, 110.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state_and_history() {
|
||||
let mut t = SwingTracker::new(0.10, 6);
|
||||
let _ = t.update(c_hl(100.0, 99.5, 0));
|
||||
let _ = t.update(c_hl(120.0, 119.5, 1));
|
||||
let _ = t.update(c_hl(101.0, 90.0, 2));
|
||||
assert_eq!(t.pivots().len(), 1);
|
||||
t.reset();
|
||||
assert!(t.pivots().is_empty());
|
||||
// After reset the next bar bootstraps again (returns false).
|
||||
assert!(!t.update(c_hl(100.0, 99.5, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_legs_extracts_highs_and_lows_either_ending() {
|
||||
// Newest pivot a high: [low_old, high_old, low_new, high_new].
|
||||
let ending_high = [
|
||||
Pivot {
|
||||
price: 100.0,
|
||||
direction: -1.0,
|
||||
},
|
||||
Pivot {
|
||||
price: 120.0,
|
||||
direction: 1.0,
|
||||
},
|
||||
Pivot {
|
||||
price: 110.0,
|
||||
direction: -1.0,
|
||||
},
|
||||
Pivot {
|
||||
price: 121.0,
|
||||
direction: 1.0,
|
||||
},
|
||||
];
|
||||
assert_eq!(recent_legs(&ending_high), (120.0, 121.0, 100.0, 110.0));
|
||||
// Newest pivot a low: [high_old, low_old, high_new, low_new].
|
||||
let ending_low = [
|
||||
Pivot {
|
||||
price: 120.0,
|
||||
direction: 1.0,
|
||||
},
|
||||
Pivot {
|
||||
price: 100.0,
|
||||
direction: -1.0,
|
||||
},
|
||||
Pivot {
|
||||
price: 110.0,
|
||||
direction: 1.0,
|
||||
},
|
||||
Pivot {
|
||||
price: 99.0,
|
||||
direction: -1.0,
|
||||
},
|
||||
];
|
||||
assert_eq!(recent_legs(&ending_low), (120.0, 110.0, 100.0, 99.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candles_for_pivots_realizes_the_requested_swings() {
|
||||
let want = [120.0, 100.0, 125.0, 95.0];
|
||||
let mut t = SwingTracker::new(0.05, 6);
|
||||
for candle in candles_for_pivots(&want) {
|
||||
let _ = t.update(candle);
|
||||
}
|
||||
let got: Vec<f64> = t.pivots().iter().map(|p| p.price).collect();
|
||||
assert_eq!(got, want);
|
||||
// Directions alternate starting from a high.
|
||||
assert_eq!(t.pivots()[0].direction, 1.0);
|
||||
assert_eq!(t.pivots()[1].direction, -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approx_equal_relative_tolerance() {
|
||||
assert!(approx_equal(100.0, 102.0, 0.03)); // 2% apart, within 3%
|
||||
assert!(!approx_equal(100.0, 110.0, 0.03)); // 10% apart, outside 3%
|
||||
assert!(approx_equal(0.0, 0.0, 0.01)); // both zero
|
||||
assert!(approx_equal(-50.0, -49.0, 0.05)); // negative magnitudes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Rectangle / Range chart pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{
|
||||
approx_equal, recent_legs, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
|
||||
};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rectangle / Range — price oscillating between a roughly horizontal support
|
||||
/// and resistance, a mean-reversion (range-trading) structure.
|
||||
///
|
||||
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); recognised when the
|
||||
/// last two highs and the last two lows are each flat within [`LEVEL_TOLERANCE`]
|
||||
/// (3%):
|
||||
///
|
||||
/// ```text
|
||||
/// flat highs (resistance) AND flat lows (support):
|
||||
/// last pivot a low → +1 (a bounce off support — buy the range)
|
||||
/// last pivot a high → -1 (a rejection at resistance — sell the range)
|
||||
/// ```
|
||||
///
|
||||
/// Unlike the breakout patterns the rectangle is range-bound, so the sign
|
||||
/// encodes the actionable mean-reversion direction of the just-confirmed touch.
|
||||
/// Output is `+1.0` / `-1.0` / `0.0`; never `None`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RectangleRange {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl RectangleRange {
|
||||
/// Construct a new Rectangle / Range detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 4),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RectangleRange {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for RectangleRange {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 4 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let (high_old, high_new, low_old, low_new) = recent_legs(pivots);
|
||||
let flat_highs = approx_equal(high_old, high_new, LEVEL_TOLERANCE);
|
||||
let flat_lows = approx_equal(low_old, low_new, LEVEL_TOLERANCE);
|
||||
if flat_highs && flat_lows {
|
||||
let last_is_high = pivots[pivots.len() - 1].direction > 0.0;
|
||||
return Some(if last_is_high { -1.0 } else { 1.0 });
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Four confirmed pivots; the earliest confirmation of the fourth is bar 5.
|
||||
5
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"RectangleRange"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = RectangleRange::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = RectangleRange::new();
|
||||
assert_eq!(indicator.name(), "RectangleRange");
|
||||
assert_eq!(indicator.warmup_period(), 5);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!RectangleRange::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_bounce_off_support_is_plus_one() {
|
||||
// Flat highs (120, 121), flat lows (100, 99); last pivot a low → +1.
|
||||
let out = run(&[120.0, 100.0, 121.0, 99.0]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_rejection_at_resistance_is_minus_one() {
|
||||
// Same range but ending on a high pivot → -1.
|
||||
let out = run(&[130.0, 100.0, 120.0, 99.0, 121.0]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trending_highs_are_not_a_rectangle() {
|
||||
// Rising highs break the flat-resistance requirement → no rectangle.
|
||||
let out = run(&[120.0, 100.0, 140.0, 99.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = RectangleRange::new();
|
||||
for c in candles_for_pivots(&[120.0, 100.0, 121.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[120.0, 100.0, 121.0, 99.0]);
|
||||
let mut a = RectangleRange::new();
|
||||
let mut b = RectangleRange::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Triangle (ascending / descending / symmetrical) chart pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{
|
||||
approx_equal, recent_legs, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
|
||||
};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Triangle — a consolidation pattern bounded by two converging trendlines,
|
||||
/// detected from the two most recent swing highs and lows.
|
||||
///
|
||||
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); evaluated on every
|
||||
/// bar that confirms a new pivot once four pivots exist:
|
||||
///
|
||||
/// ```text
|
||||
/// ascending : flat highs + rising lows → +1 (bullish bias)
|
||||
/// descending : falling highs + flat lows → -1 (bearish bias)
|
||||
/// symmetrical : falling highs + rising lows → +1 if the last pivot is a low
|
||||
/// (an up-bounce), else -1
|
||||
/// ```
|
||||
///
|
||||
/// "Flat" means the two highs (or lows) are within [`LEVEL_TOLERANCE`] (3%) of
|
||||
/// each other; "rising"/"falling" means they differ by more than that tolerance.
|
||||
/// The symmetrical case is directionally neutral, so its sign follows the
|
||||
/// momentum of the most recently confirmed swing. Output is `+1.0` / `-1.0` /
|
||||
/// `0.0`; never `None`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Triangle {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Triangle {
|
||||
/// Construct a new Triangle detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 4),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Triangle {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Triangle {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 4 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let (high_old, high_new, low_old, low_new) = recent_legs(pivots);
|
||||
let flat_highs = approx_equal(high_old, high_new, LEVEL_TOLERANCE);
|
||||
let flat_lows = approx_equal(low_old, low_new, LEVEL_TOLERANCE);
|
||||
let rising_lows = low_new > low_old * (1.0 + LEVEL_TOLERANCE);
|
||||
let falling_highs = high_new < high_old * (1.0 - LEVEL_TOLERANCE);
|
||||
let last_is_high = pivots[pivots.len() - 1].direction > 0.0;
|
||||
|
||||
if flat_highs && rising_lows {
|
||||
return Some(1.0); // ascending
|
||||
}
|
||||
if falling_highs && flat_lows {
|
||||
return Some(-1.0); // descending
|
||||
}
|
||||
if falling_highs && rising_lows {
|
||||
// symmetrical: lean with the latest swing's momentum.
|
||||
return Some(if last_is_high { -1.0 } else { 1.0 });
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Four confirmed pivots; the earliest confirmation of the fourth is bar 5.
|
||||
5
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Triangle"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = Triangle::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = Triangle::new();
|
||||
assert_eq!(indicator.name(), "Triangle");
|
||||
assert_eq!(indicator.warmup_period(), 5);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!Triangle::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascending_triangle_is_plus_one() {
|
||||
// Flat highs (120, 120), rising lows (100 → 110).
|
||||
let out = run(&[130.0, 100.0, 120.0, 110.0, 120.0]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descending_triangle_is_minus_one() {
|
||||
// Falling highs (120 → 110), flat lows (100, 99).
|
||||
let out = run(&[120.0, 100.0, 110.0, 99.0]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetrical_triangle_ending_low_is_plus_one() {
|
||||
// Falling highs (120 → 113), rising lows (100 → 106); last pivot a low.
|
||||
let out = run(&[120.0, 100.0, 113.0, 106.0]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetrical_triangle_ending_high_is_minus_one() {
|
||||
// Same convergence but ending on a high pivot.
|
||||
let out = run(&[130.0, 100.0, 120.0, 106.0, 113.0]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanding_swings_are_not_a_triangle() {
|
||||
// Rising highs and falling lows (broadening) → no converging triangle.
|
||||
let out = run(&[110.0, 100.0, 130.0, 80.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = Triangle::new();
|
||||
for c in candles_for_pivots(&[130.0, 100.0, 120.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[130.0, 100.0, 120.0, 110.0, 120.0]);
|
||||
let mut a = Triangle::new();
|
||||
let mut b = Triangle::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Triple Top / Triple Bottom reversal chart pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{
|
||||
approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
|
||||
};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Triple Top / Triple Bottom — a three-peak (or three-trough) reversal pattern,
|
||||
/// a stronger variant of the double top/bottom.
|
||||
///
|
||||
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%). A pattern is
|
||||
/// recognised on the bar that confirms the **third** matching extreme:
|
||||
///
|
||||
/// ```text
|
||||
/// triple top : High₁ , Low , High₂ , Low , High₃ High₁ ≈ High₂ ≈ High₃ → -1
|
||||
/// triple bottom : Low₁ , High, Low₂ , High, Low₃ Low₁ ≈ Low₂ ≈ Low₃ → +1
|
||||
/// ```
|
||||
///
|
||||
/// The three same-direction extremes (positions `n-5`, `n-3`, `n-1` in the pivot
|
||||
/// history) must all lie within [`LEVEL_TOLERANCE`] (3%) of one another.
|
||||
///
|
||||
/// Output is `+1.0` for a triple bottom, `-1.0` for a triple top, and `0.0`
|
||||
/// otherwise; never `None`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TripleTopBottom {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl TripleTopBottom {
|
||||
/// Construct a new Triple Top / Triple Bottom detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 5),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TripleTopBottom {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TripleTopBottom {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 5 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let n = pivots.len();
|
||||
let first = pivots[n - 5];
|
||||
let middle = pivots[n - 3];
|
||||
let last = pivots[n - 1];
|
||||
let outer_match = approx_equal(first.price, middle.price, LEVEL_TOLERANCE);
|
||||
let inner_match = approx_equal(middle.price, last.price, LEVEL_TOLERANCE);
|
||||
if outer_match && inner_match {
|
||||
return Some(if last.direction > 0.0 { -1.0 } else { 1.0 });
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Five confirmed pivots are needed; the earliest bar that can confirm a
|
||||
// fifth pivot is the sixth.
|
||||
6
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TripleTopBottom"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = TripleTopBottom::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = TripleTopBottom::new();
|
||||
assert_eq!(indicator.name(), "TripleTopBottom");
|
||||
assert_eq!(indicator.warmup_period(), 6);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!TripleTopBottom::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triple_top_is_minus_one() {
|
||||
// Three ~equal highs (120, 121, 119) → triple top on the third.
|
||||
let out = run(&[120.0, 100.0, 121.0, 99.0, 119.0]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triple_bottom_is_plus_one() {
|
||||
// Lead high then three ~equal lows (100, 99, 101) → triple bottom.
|
||||
let out = run(&[130.0, 100.0, 120.0, 99.0, 122.0, 101.0]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unequal_third_peak_does_not_trigger() {
|
||||
// Third high (140) diverges from the first two (120, 121) → no pattern.
|
||||
let out = run(&[120.0, 100.0, 121.0, 99.0, 140.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
assert!(out.iter().all(|&x| x == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = TripleTopBottom::new();
|
||||
for c in candles_for_pivots(&[120.0, 100.0, 121.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[120.0, 100.0, 121.0, 99.0, 119.0]);
|
||||
let mut a = TripleTopBottom::new();
|
||||
let mut b = TripleTopBottom::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Wedge (rising / falling) reversal chart pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{recent_legs, SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Wedge — a pattern where both trendlines slope the same way but converge,
|
||||
/// signalling exhaustion of the prevailing move.
|
||||
///
|
||||
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); evaluated from the
|
||||
/// last two swing highs and lows:
|
||||
///
|
||||
/// ```text
|
||||
/// rising wedge : highs rising AND lows rising, lows rising faster → -1 (bearish)
|
||||
/// falling wedge : highs falling AND lows falling, highs falling faster → +1 (bullish)
|
||||
/// ```
|
||||
///
|
||||
/// Convergence is the key: in a rising wedge the lower trendline climbs faster
|
||||
/// than the upper (the range narrows from below); in a falling wedge the upper
|
||||
/// trendline drops faster than the lower. Output is `+1.0` / `-1.0` / `0.0`;
|
||||
/// never `None`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Wedge {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Wedge {
|
||||
/// Construct a new Wedge detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 4),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Wedge {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Wedge {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 4 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let (high_old, high_new, low_old, low_new) = recent_legs(pivots);
|
||||
let high_slope = high_new - high_old;
|
||||
let low_slope = low_new - low_old;
|
||||
|
||||
// Rising wedge: both lines slope up, lower line steeper (converging) → bearish.
|
||||
if high_slope > 0.0 && low_slope > 0.0 && low_slope > high_slope {
|
||||
return Some(-1.0);
|
||||
}
|
||||
// Falling wedge: both lines slope down, upper line steeper → bullish.
|
||||
if high_slope < 0.0 && low_slope < 0.0 && high_slope < low_slope {
|
||||
return Some(1.0);
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Four confirmed pivots; the earliest confirmation of the fourth is bar 5.
|
||||
5
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Wedge"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = Wedge::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = Wedge::new();
|
||||
assert_eq!(indicator.name(), "Wedge");
|
||||
assert_eq!(indicator.warmup_period(), 5);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!Wedge::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rising_wedge_is_minus_one() {
|
||||
// Highs 100 → 103 (+3), lows 90 → 94 (+4, steeper) → rising wedge.
|
||||
let out = run(&[110.0, 90.0, 100.0, 94.0, 103.0]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falling_wedge_is_plus_one() {
|
||||
// Highs 120 → 106 (-14, steeper), lows 100 → 99 (-1) → falling wedge.
|
||||
let out = run(&[120.0, 100.0, 106.0, 99.0]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diverging_swings_are_not_a_wedge() {
|
||||
// Rising highs but falling lows (broadening) → no wedge.
|
||||
let out = run(&[110.0, 100.0, 130.0, 80.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = Wedge::new();
|
||||
for c in candles_for_pivots(&[110.0, 90.0, 100.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[110.0, 90.0, 100.0, 94.0, 103.0]);
|
||||
let mut a = Wedge::new();
|
||||
let mut b = Wedge::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user