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:
kingchenc
2026-06-03 22:55:36 +02:00
committed by GitHub
parent 05d2e5dc61
commit 995f119010
23 changed files with 2127 additions and 49 deletions
@@ -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<_>>()
);
}
}
+35 -1
View File
@@ -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<_>>()
);
}
}
+157
View File
@@ -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<_>>()
);
}
}