feat(breadth): complete the Market Breadth family (14 indicators) (#157)

Completes expansion-roadmap block **A2 — Market Breadth**: the 14 indicators that remained after the `AdvanceDecline` bootstrap, all built on the existing `CrossSection` input.

## Indicators (all scalar `Indicator<Input = CrossSection, Output = f64>`)

| Indicator | Reading |
|-----------|---------|
| `AdvanceDeclineRatio` | advancers / decliners |
| `AdVolumeLine` | cumulative net advancing volume |
| `McClellanOscillator` | 19/39 EMAs of ratio-adjusted net advances |
| `McClellanSummationIndex` | running total of the oscillator |
| `Trin` (Arms Index) | A/D ratio over up/down volume ratio |
| `BreadthThrust` (Zweig) | SMA of the advancing-issues share |
| `NewHighsNewLows` | new highs − new lows |
| `HighLowIndex` | SMA of the record-high percent |
| `PercentAboveMa` | % of the universe above its MA |
| `UpDownVolumeRatio` | advancing / declining volume |
| `BullishPercentIndex` | % on a point-and-figure buy signal |
| `CumulativeVolumeIndex` | volume-normalised cumulative net advancing volume |
| `AbsoluteBreadthIndex` | \|advancers − decliners\| |
| `TickIndex` | instantaneous net advancers − decliners |

## Input model

`AdVolumeLine` and `CumulativeVolumeIndex` are kept distinct (the latter normalises each tick's net advancing volume by total volume, so it stays comparable across volume regimes). `PercentAboveMa` and `BullishPercentIndex` need a per-symbol state signal that `Member` did not carry, so `Member` gains two additive flags (`above_ma`, `on_buy_signal`) via a new `Member::with_signals` constructor; the 4-arg `Member::new` leaves both cleared, so every existing caller and binding is unchanged. `CrossSection` gains volume / new-extreme / state aggregation helpers.

## Wiring

Fully wired across the Rust core, the python/node/wasm bindings, the cross-section fuzz target, the README + docs indicator counters (325 → 339), and dedicated python/node streaming-vs-batch tests. `fmt` / `test --workspace --all-features` / `clippy --workspace -D warnings` / node build+test / pytest all green locally.
This commit is contained in:
kingchenc
2026-06-03 17:24:33 +02:00
committed by GitHub
parent c44f625e69
commit c096943bdf
30 changed files with 5648 additions and 63 deletions
@@ -0,0 +1,144 @@
//! Absolute Breadth Index — the magnitude of net advancing-minus-declining issues.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Absolute Breadth Index (ABI) — the absolute value of net advancing issues,
/// `|advancers - decliners|`.
///
/// The ABI ignores the *direction* of breadth and measures only its *magnitude*:
/// a high reading means the universe moved decisively one way or the other (high
/// internal activity / volatility), while a low reading means advances and
/// declines were nearly balanced (a quiet, directionless market). It is sometimes
/// called a "market thermometer" because elevated readings often cluster around
/// turning points.
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{AbsoluteBreadthIndex, CrossSection, Indicator, Member};
///
/// let mut abi = AbsoluteBreadthIndex::new();
/// // 2 advancers, 5 decliners -> |2 - 5| = 3.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 10.0, false, false),
/// Member::new(1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(abi.update(tick), Some(3.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct AbsoluteBreadthIndex {
has_emitted: bool,
}
impl AbsoluteBreadthIndex {
/// Construct a new Absolute Breadth Index indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for AbsoluteBreadthIndex {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let net = section.advancers() as f64 - section.decliners() as f64;
self.has_emitted = true;
Some(net.abs())
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AbsoluteBreadthIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn section(up: usize, down: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..up {
members.push(Member::new(1.0, 10.0, false, false));
}
for _ in 0..down {
members.push(Member::new(-1.0, 10.0, false, false));
}
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let abi = AbsoluteBreadthIndex::new();
assert_eq!(abi.name(), "AbsoluteBreadthIndex");
assert_eq!(abi.warmup_period(), 1);
assert!(!abi.is_ready());
}
#[test]
fn magnitude_ignores_direction() {
let mut abi = AbsoluteBreadthIndex::new();
assert_eq!(abi.update(section(2, 5)), Some(3.0));
// Same magnitude with the direction reversed.
let mut abi2 = AbsoluteBreadthIndex::new();
assert_eq!(abi2.update(section(5, 2)), Some(3.0));
}
#[test]
fn balanced_universe_yields_zero() {
let mut abi = AbsoluteBreadthIndex::new();
assert_eq!(abi.update(section(3, 3)), Some(0.0));
assert!(abi.is_ready());
}
#[test]
fn reset_clears_state() {
let mut abi = AbsoluteBreadthIndex::new();
abi.update(section(2, 5));
assert!(abi.is_ready());
abi.reset();
assert!(!abi.is_ready());
}
#[test]
fn batch_equals_streaming() {
let sections = vec![section(2, 5), section(5, 2), section(3, 3)];
let mut a = AbsoluteBreadthIndex::new();
let mut b = AbsoluteBreadthIndex::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,157 @@
//! Advance/Decline Volume Line — cumulative net advancing-minus-declining volume.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Advance/Decline Volume Line (AD Volume Line) — the running cumulative sum of
/// net advancing volume across a universe.
///
/// On each [`CrossSection`] tick the net is `advancing volume - declining volume`,
/// where advancing volume is the total volume of symbols with a positive change
/// and declining volume the total volume of symbols with a negative change. The
/// line accumulates this net over time, so a rising line means volume is flowing
/// into advancing issues (healthy participation) while a falling line warns that
/// declining issues are carrying the volume — the volume-weighted analogue of the
/// plain Advance/Decline Line.
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1` (defined from the
/// first tick).
///
/// # Example
///
/// ```
/// use wickra_core::{AdVolumeLine, CrossSection, Indicator, Member};
///
/// let mut adv = AdVolumeLine::new();
/// // advancing volume 150, declining volume 50 -> net +100.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 150.0, false, false),
/// Member::new(-1.0, 50.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(adv.update(tick), Some(100.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct AdVolumeLine {
line: f64,
has_emitted: bool,
}
impl AdVolumeLine {
/// Construct a new Advance/Decline Volume Line indicator.
#[must_use]
pub const fn new() -> Self {
Self {
line: 0.0,
has_emitted: false,
}
}
}
impl Indicator for AdVolumeLine {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let net = section.advancing_volume() - section.declining_volume();
self.line += net;
self.has_emitted = true;
Some(self.line)
}
fn reset(&mut self) {
self.line = 0.0;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AdVolumeLine"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn tick(items: &[(f64, f64)]) -> CrossSection {
CrossSection::new(
items
.iter()
.map(|&(change, volume)| Member::new(change, volume, false, false))
.collect(),
0,
)
.unwrap()
}
#[test]
fn accessors_and_metadata() {
let adv = AdVolumeLine::new();
assert_eq!(adv.name(), "AdVolumeLine");
assert_eq!(adv.warmup_period(), 1);
assert!(!adv.is_ready());
}
#[test]
fn first_tick_emits_net_volume() {
let mut adv = AdVolumeLine::new();
assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0));
assert!(adv.is_ready());
}
#[test]
fn line_accumulates_across_ticks() {
let mut adv = AdVolumeLine::new();
assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0));
assert_eq!(adv.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(100.0));
assert_eq!(adv.update(tick(&[(1.0, 30.0)])), Some(130.0));
}
#[test]
fn unchanged_volume_is_ignored() {
let mut adv = AdVolumeLine::new();
// Unchanged symbols (zero change) contribute to neither bucket.
assert_eq!(adv.update(tick(&[(0.0, 1000.0), (1.0, 10.0)])), Some(10.0));
}
#[test]
fn reset_clears_state() {
let mut adv = AdVolumeLine::new();
adv.update(tick(&[(1.0, 100.0)]));
assert!(adv.is_ready());
adv.reset();
assert!(!adv.is_ready());
assert_eq!(adv.update(tick(&[(1.0, 20.0)])), Some(20.0));
}
#[test]
fn batch_equals_streaming() {
let sections = vec![
tick(&[(1.0, 150.0), (-1.0, 50.0)]),
tick(&[(1.0, 60.0), (-1.0, 60.0)]),
tick(&[(1.0, 30.0)]),
];
let mut a = AdVolumeLine::new();
let mut b = AdVolumeLine::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,151 @@
//! Advance/Decline Ratio — advancing issues divided by declining issues.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Advance/Decline Ratio (ADR) — the number of advancing symbols divided by the
/// number of declining symbols across a universe.
///
/// On each [`CrossSection`] tick the ratio is `advancers / decliners`: a reading
/// above one means advancing issues outnumber declining ones (broad strength),
/// while a reading below one signals broad weakness. Because it is a ratio rather
/// than a difference, the ADR is comparable across universes of different sizes.
///
/// When a tick has no declining symbols the denominator is floored to one, so the
/// ratio degrades gracefully to the advancer count instead of dividing by zero.
///
/// `Input = CrossSection`, `Output = f64`. The ratio is defined from the first
/// tick, so `warmup_period == 1` and the indicator is ready after one update.
///
/// # Example
///
/// ```
/// use wickra_core::{AdvanceDeclineRatio, CrossSection, Indicator, Member};
///
/// let mut adr = AdvanceDeclineRatio::new();
/// // 3 advancers, 1 decliner -> ratio 3.0.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 10.0, false, false),
/// Member::new(0.5, 10.0, false, false),
/// Member::new(2.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(adr.update(tick), Some(3.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct AdvanceDeclineRatio {
has_emitted: bool,
}
impl AdvanceDeclineRatio {
/// Construct a new Advance/Decline Ratio indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for AdvanceDeclineRatio {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let advancers = section.advancers() as f64;
let decliners = section.decliners().max(1) as f64;
self.has_emitted = true;
Some(advancers / decliners)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AdvanceDeclineRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn section(up: usize, down: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..up {
members.push(Member::new(1.0, 10.0, false, false));
}
for _ in 0..down {
members.push(Member::new(-1.0, 10.0, false, false));
}
// A non-empty unchanged member guarantees a valid universe when both
// counts are zero.
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let adr = AdvanceDeclineRatio::new();
assert_eq!(adr.name(), "AdvanceDeclineRatio");
assert_eq!(adr.warmup_period(), 1);
assert!(!adr.is_ready());
}
#[test]
fn first_tick_emits_ratio() {
let mut adr = AdvanceDeclineRatio::new();
assert_eq!(adr.update(section(3, 1)), Some(3.0));
assert!(adr.is_ready());
}
#[test]
fn zero_decliners_floors_denominator() {
let mut adr = AdvanceDeclineRatio::new();
// 4 advancers, 0 decliners -> 4 / max(0, 1) = 4.0.
assert_eq!(adr.update(section(4, 0)), Some(4.0));
}
#[test]
fn no_advancers_yields_zero() {
let mut adr = AdvanceDeclineRatio::new();
assert_eq!(adr.update(section(0, 5)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut adr = AdvanceDeclineRatio::new();
adr.update(section(3, 1));
assert!(adr.is_ready());
adr.reset();
assert!(!adr.is_ready());
assert_eq!(adr.update(section(2, 1)), Some(2.0));
}
#[test]
fn batch_equals_streaming() {
let sections = vec![section(3, 1), section(4, 0), section(0, 5), section(2, 2)];
let mut a = AdvanceDeclineRatio::new();
let mut b = AdvanceDeclineRatio::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,165 @@
//! Breadth Thrust (Zweig) — a moving average of the advancing-issues share.
use crate::cross_section::CrossSection;
use crate::error::Result;
use crate::traits::Indicator;
use crate::Sma;
/// Breadth Thrust (Zweig) — a simple moving average of the advancing-issues
/// share, `advancers / (advancers + decliners)`.
///
/// Martin Zweig's breadth thrust smooths the fraction of participating issues
/// that are advancing over a short window (the classic period is 10). A "thrust"
/// fires when this average climbs from below ~0.40 (oversold, washed-out breadth)
/// to above ~0.615 within about ten sessions — historically a rare, reliable
/// signal that a powerful new advance has begun with broad participation.
///
/// Each tick's share floors the participating count to one, so a tick with no
/// advancing or declining issues contributes a defined `0.0` instead of dividing
/// by zero. The reading is `None` until `period` ticks have been seen.
///
/// `Input = CrossSection`, `Output = f64` (a share in `0..=1`),
/// `warmup_period == period`.
///
/// # Example
///
/// ```
/// use wickra_core::{BreadthThrust, CrossSection, Indicator, Member};
///
/// let mut bt = BreadthThrust::new(2).unwrap();
/// let up = CrossSection::new(vec![Member::new(1.0, 1.0, false, false)], 0).unwrap();
/// assert_eq!(bt.update(up.clone()), None); // warming up
/// assert_eq!(bt.update(up), Some(1.0)); // both ticks 100% advancing
/// ```
#[derive(Debug, Clone)]
pub struct BreadthThrust {
sma: Sma,
}
impl BreadthThrust {
/// Construct a new Breadth Thrust over the given window length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
sma: Sma::new(period)?,
})
}
/// Configured window length.
#[must_use]
pub const fn period(&self) -> usize {
self.sma.period()
}
}
impl Indicator for BreadthThrust {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let advancers = section.advancers();
let decliners = section.decliners();
let participating = (advancers + decliners).max(1) as f64;
let share = advancers as f64 / participating;
self.sma.update(share)
}
fn reset(&mut self) {
self.sma.reset();
}
fn warmup_period(&self) -> usize {
self.sma.period()
}
fn is_ready(&self) -> bool {
self.sma.value().is_some()
}
fn name(&self) -> &'static str {
"BreadthThrust"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::error::Error;
use crate::traits::BatchExt;
fn section(up: usize, down: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..up {
members.push(Member::new(1.0, 10.0, false, false));
}
for _ in 0..down {
members.push(Member::new(-1.0, 10.0, false, false));
}
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let bt = BreadthThrust::new(10).unwrap();
assert_eq!(bt.name(), "BreadthThrust");
assert_eq!(bt.warmup_period(), 10);
assert_eq!(bt.period(), 10);
assert!(!bt.is_ready());
}
#[test]
fn rejects_zero_period() {
assert!(matches!(BreadthThrust::new(0), Err(Error::PeriodZero)));
}
#[test]
fn averages_the_advancing_share() {
let mut bt = BreadthThrust::new(2).unwrap();
// share = 8 / 10 = 0.8 ; window not full yet.
assert_eq!(bt.update(section(8, 2)), None);
// share = 6 / 10 = 0.6 ; SMA(2) = (0.8 + 0.6) / 2 = 0.7.
let value = bt.update(section(6, 4)).unwrap();
assert!((value - 0.7).abs() < 1e-9);
assert!(bt.is_ready());
// share = 5 / 10 = 0.5 ; SMA(2) = (0.6 + 0.5) / 2 = 0.55.
let value = bt.update(section(5, 5)).unwrap();
assert!((value - 0.55).abs() < 1e-9);
}
#[test]
fn empty_participation_floors_to_zero_share() {
let mut bt = BreadthThrust::new(1).unwrap();
// No advancers or decliners -> 0 / max(0, 1) = 0.0.
assert_eq!(bt.update(section(0, 0)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut bt = BreadthThrust::new(2).unwrap();
bt.update(section(8, 2));
bt.update(section(6, 4));
assert!(bt.is_ready());
bt.reset();
assert!(!bt.is_ready());
assert_eq!(bt.update(section(8, 2)), None);
}
#[test]
fn batch_equals_streaming() {
let sections = vec![section(8, 2), section(6, 4), section(5, 5), section(0, 0)];
let mut a = BreadthThrust::new(2).unwrap();
let mut b = BreadthThrust::new(2).unwrap();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,147 @@
//! Bullish Percent Index — share of a universe on a point-and-figure buy signal.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Bullish Percent Index (BPI) — the percentage of symbols in a universe that are
/// currently on a point-and-figure buy signal.
///
/// On each [`CrossSection`] tick the value is `100 * on_buy_signal_count /
/// universe size`, read from the per-symbol `on_buy_signal` flag (the caller
/// evaluates each symbol's point-and-figure chart when it builds the tick). It is
/// a bounded `0..=100` gauge of how many issues are in a confirmed uptrend.
/// Readings above 70 are considered overbought (broad strength, but a crowded
/// market) and below 30 oversold; reversals from those zones are classic BPI
/// buy/sell triggers.
///
/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`),
/// `warmup_period == 1`. The universe is non-empty by construction, so the share
/// is always defined.
///
/// # Example
///
/// ```
/// use wickra_core::{BullishPercentIndex, CrossSection, Indicator, Member};
///
/// let mut bpi = BullishPercentIndex::new();
/// // 2 of 4 symbols on a buy signal -> 50%.
/// let tick = CrossSection::new(
/// vec![
/// Member::with_signals(1.0, 10.0, false, false, false, true),
/// Member::with_signals(1.0, 10.0, false, false, false, true),
/// Member::with_signals(-1.0, 10.0, false, false, false, false),
/// Member::with_signals(-1.0, 10.0, false, false, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(bpi.update(tick), Some(50.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct BullishPercentIndex {
has_emitted: bool,
}
impl BullishPercentIndex {
/// Construct a new Bullish Percent Index indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for BullishPercentIndex {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let bullish = section.on_buy_signal_count() as f64;
let total = section.members.len() as f64;
self.has_emitted = true;
Some(100.0 * bullish / total)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"BullishPercentIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn tick(bullish: usize, bearish: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..bullish {
members.push(Member::with_signals(1.0, 10.0, false, false, false, true));
}
for _ in 0..bearish {
members.push(Member::with_signals(-1.0, 10.0, false, false, false, false));
}
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let bpi = BullishPercentIndex::new();
assert_eq!(bpi.name(), "BullishPercentIndex");
assert_eq!(bpi.warmup_period(), 1);
assert!(!bpi.is_ready());
}
#[test]
fn first_tick_emits_percentage() {
let mut bpi = BullishPercentIndex::new();
assert_eq!(bpi.update(tick(2, 2)), Some(50.0));
assert!(bpi.is_ready());
}
#[test]
fn all_bullish_is_one_hundred() {
let mut bpi = BullishPercentIndex::new();
assert_eq!(bpi.update(tick(5, 0)), Some(100.0));
}
#[test]
fn none_bullish_is_zero() {
let mut bpi = BullishPercentIndex::new();
assert_eq!(bpi.update(tick(0, 4)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut bpi = BullishPercentIndex::new();
bpi.update(tick(2, 2));
assert!(bpi.is_ready());
bpi.reset();
assert!(!bpi.is_ready());
}
#[test]
fn batch_equals_streaming() {
let sections = vec![tick(2, 2), tick(5, 0), tick(0, 4)];
let mut a = BullishPercentIndex::new();
let mut b = BullishPercentIndex::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,163 @@
//! Cumulative Volume Index — running total of volume-normalised net advancing volume.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Cumulative Volume Index (CVI) — the running total of *volume-normalised* net
/// advancing volume across a universe.
///
/// On each [`CrossSection`] tick the increment is `(advancing volume - declining
/// volume) / total volume`: the share of the tick's total volume that flowed,
/// net, into advancing issues. The index accumulates this share over time. Where
/// the raw [`AdVolumeLine`](crate::AdVolumeLine) sums *absolute* net volume — and
/// so drifts with secular growth in trading activity — the CVI normalises each
/// tick by its own total volume, so a one-share-net day in a thin market counts
/// the same as in a heavy one. This keeps the index comparable across regimes of
/// very different volume.
///
/// When a tick has zero total volume the net is necessarily zero too, so the
/// increment is zero and the index is unchanged (the divisor is floored to the
/// smallest positive `f64` purely to keep the division defined).
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, CumulativeVolumeIndex, Indicator, Member};
///
/// let mut cvi = CumulativeVolumeIndex::new();
/// // adv vol 150, dec vol 50, total 200 -> (150 - 50) / 200 = 0.5.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 150.0, false, false),
/// Member::new(-1.0, 50.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(cvi.update(tick), Some(0.5));
/// ```
#[derive(Debug, Clone, Default)]
pub struct CumulativeVolumeIndex {
index: f64,
has_emitted: bool,
}
impl CumulativeVolumeIndex {
/// Construct a new Cumulative Volume Index indicator.
#[must_use]
pub const fn new() -> Self {
Self {
index: 0.0,
has_emitted: false,
}
}
}
impl Indicator for CumulativeVolumeIndex {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let net = section.advancing_volume() - section.declining_volume();
let total = section.total_volume().max(f64::MIN_POSITIVE);
self.index += net / total;
self.has_emitted = true;
Some(self.index)
}
fn reset(&mut self) {
self.index = 0.0;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"CumulativeVolumeIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn tick(items: &[(f64, f64)]) -> CrossSection {
CrossSection::new(
items
.iter()
.map(|&(change, volume)| Member::new(change, volume, false, false))
.collect(),
0,
)
.unwrap()
}
#[test]
fn accessors_and_metadata() {
let cvi = CumulativeVolumeIndex::new();
assert_eq!(cvi.name(), "CumulativeVolumeIndex");
assert_eq!(cvi.warmup_period(), 1);
assert!(!cvi.is_ready());
}
#[test]
fn first_tick_emits_normalised_net() {
let mut cvi = CumulativeVolumeIndex::new();
assert_eq!(cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(0.5));
assert!(cvi.is_ready());
}
#[test]
fn index_accumulates_normalised_shares() {
let mut cvi = CumulativeVolumeIndex::new();
assert_eq!(cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(0.5));
// adv 60, dec 60, total 120 -> net 0 -> index unchanged.
assert_eq!(cvi.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(0.5));
}
#[test]
fn zero_total_volume_leaves_index_unchanged() {
let mut cvi = CumulativeVolumeIndex::new();
cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)]));
// A tick with no volume at all: net 0 / floored divisor -> 0 increment.
assert_eq!(cvi.update(tick(&[(0.0, 0.0)])), Some(0.5));
}
#[test]
fn reset_clears_state() {
let mut cvi = CumulativeVolumeIndex::new();
cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)]));
assert!(cvi.is_ready());
cvi.reset();
assert!(!cvi.is_ready());
assert_eq!(cvi.update(tick(&[(1.0, 100.0)])), Some(1.0));
}
#[test]
fn batch_equals_streaming() {
let sections = vec![
tick(&[(1.0, 150.0), (-1.0, 50.0)]),
tick(&[(1.0, 60.0), (-1.0, 60.0)]),
tick(&[(0.0, 0.0)]),
];
let mut a = CumulativeVolumeIndex::new();
let mut b = CumulativeVolumeIndex::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,162 @@
//! High-Low Index — a moving average of the record-high percentage.
use crate::cross_section::CrossSection;
use crate::error::Result;
use crate::traits::Indicator;
use crate::Sma;
/// High-Low Index — a simple moving average of the *record high percent*,
/// `100 * new_highs / (new_highs + new_lows)`.
///
/// The record high percent is the share of new-extreme issues that are new
/// *highs* rather than new *lows*; smoothing it over a window (the classic period
/// is 10) gives the High-Low Index. Readings above 50 mean new highs dominate
/// (a healthy, broadening trend), readings below 50 mean new lows dominate. The
/// 30 and 70 lines are watched as oversold / overbought breadth thresholds.
///
/// Each tick floors the new-extreme count to one, so a tick with no new highs or
/// lows contributes a defined `0.0` instead of dividing by zero. The reading is
/// `None` until `period` ticks have been seen.
///
/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`),
/// `warmup_period == period`.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, HighLowIndex, Indicator, Member};
///
/// let mut hli = HighLowIndex::new(2).unwrap();
/// let highs = CrossSection::new(vec![Member::new(1.0, 1.0, true, false)], 0).unwrap();
/// assert_eq!(hli.update(highs.clone()), None); // warming up
/// assert_eq!(hli.update(highs), Some(100.0)); // all new highs
/// ```
#[derive(Debug, Clone)]
pub struct HighLowIndex {
sma: Sma,
}
impl HighLowIndex {
/// Construct a new High-Low Index over the given window length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
sma: Sma::new(period)?,
})
}
/// Configured window length.
#[must_use]
pub const fn period(&self) -> usize {
self.sma.period()
}
}
impl Indicator for HighLowIndex {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let new_highs = section.new_highs();
let new_lows = section.new_lows();
let extremes = (new_highs + new_lows).max(1) as f64;
let record_high_percent = 100.0 * new_highs as f64 / extremes;
self.sma.update(record_high_percent)
}
fn reset(&mut self) {
self.sma.reset();
}
fn warmup_period(&self) -> usize {
self.sma.period()
}
fn is_ready(&self) -> bool {
self.sma.value().is_some()
}
fn name(&self) -> &'static str {
"HighLowIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::error::Error;
use crate::traits::BatchExt;
fn flags(highs: usize, lows: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..highs {
members.push(Member::new(1.0, 10.0, true, false));
}
for _ in 0..lows {
members.push(Member::new(-1.0, 10.0, false, true));
}
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let hli = HighLowIndex::new(10).unwrap();
assert_eq!(hli.name(), "HighLowIndex");
assert_eq!(hli.warmup_period(), 10);
assert_eq!(hli.period(), 10);
assert!(!hli.is_ready());
}
#[test]
fn rejects_zero_period() {
assert!(matches!(HighLowIndex::new(0), Err(Error::PeriodZero)));
}
#[test]
fn averages_the_record_high_percent() {
let mut hli = HighLowIndex::new(2).unwrap();
// 8 highs / 10 extremes -> 80% ; window not full.
assert_eq!(hli.update(flags(8, 2)), None);
// 6 highs / 10 extremes -> 60% ; SMA(2) = (80 + 60) / 2 = 70.
let value = hli.update(flags(6, 4)).unwrap();
assert!((value - 70.0).abs() < 1e-9);
assert!(hli.is_ready());
}
#[test]
fn no_extremes_floors_to_zero_percent() {
let mut hli = HighLowIndex::new(1).unwrap();
// No new highs or lows -> 0 / max(0, 1) -> 0%.
assert_eq!(hli.update(flags(0, 0)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut hli = HighLowIndex::new(2).unwrap();
hli.update(flags(8, 2));
hli.update(flags(6, 4));
assert!(hli.is_ready());
hli.reset();
assert!(!hli.is_ready());
assert_eq!(hli.update(flags(8, 2)), None);
}
#[test]
fn batch_equals_streaming() {
let sections = vec![flags(8, 2), flags(6, 4), flags(3, 7), flags(0, 0)];
let mut a = HighLowIndex::new(2).unwrap();
let mut b = HighLowIndex::new(2).unwrap();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,204 @@
//! McClellan Oscillator — the spread between a fast and slow EMA of breadth.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Fast EMA smoothing constant — the classic McClellan 19-period weight
/// `2 / (19 + 1)`.
const ALPHA_FAST: f64 = 0.1;
/// Slow EMA smoothing constant — the classic McClellan 39-period weight
/// `2 / (39 + 1)`.
const ALPHA_SLOW: f64 = 0.05;
/// Scale applied to the ratio-adjusted net advances so readings land on the
/// familiar McClellan amplitude.
const RANA_SCALE: f64 = 1000.0;
/// McClellan Oscillator — the difference between a 19-period and a 39-period
/// exponential moving average of *ratio-adjusted net advances*.
///
/// Each tick's breadth is reduced to ratio-adjusted net advances (RANA),
/// `(advancers - decliners) / (advancers + decliners) * 1000`. Dividing by the
/// number of participating issues makes the reading independent of universe size,
/// so the oscillator stays comparable as the universe grows or shrinks. The
/// oscillator is then the fast EMA minus the slow EMA of that series, using the
/// classic McClellan smoothing constants `0.10` (19-period) and `0.05`
/// (39-period). Both EMAs are seeded from the first tick's RANA, so the
/// oscillator is defined from the first update (`warmup_period == 1`); it starts
/// at `0.0` and crosses zero as breadth momentum shifts.
///
/// A tick with no advancing or declining issues yields a RANA of `0.0` (the
/// participating count is floored to one).
///
/// `Input = CrossSection`, `Output = f64`.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, Indicator, McClellanOscillator, Member};
///
/// let mut osc = McClellanOscillator::new();
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 10.0, false, false),
/// Member::new(1.0, 10.0, false, false),
/// Member::new(1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// // First tick seeds both EMAs to the same value -> oscillator 0.
/// assert_eq!(osc.update(tick), Some(0.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct McClellanOscillator {
ema_fast: f64,
ema_slow: f64,
seeded: bool,
has_emitted: bool,
}
impl McClellanOscillator {
/// Construct a new McClellan Oscillator with the classic 19/39 smoothing.
#[must_use]
pub const fn new() -> Self {
Self {
ema_fast: 0.0,
ema_slow: 0.0,
seeded: false,
has_emitted: false,
}
}
/// Feed a cross-section tick and return the oscillator value, which is defined
/// on every tick. Shared with [`McClellanSummationIndex`] so the summation
/// index can accumulate the oscillator without an `Option` round-trip.
///
/// [`McClellanSummationIndex`]: crate::McClellanSummationIndex
pub(crate) fn step(&mut self, section: &CrossSection) -> f64 {
let advancers = section.advancers();
let decliners = section.decliners();
let net = advancers as f64 - decliners as f64;
let participating = (advancers + decliners).max(1) as f64;
let rana = net / participating * RANA_SCALE;
if self.seeded {
self.ema_fast += ALPHA_FAST * (rana - self.ema_fast);
self.ema_slow += ALPHA_SLOW * (rana - self.ema_slow);
} else {
self.ema_fast = rana;
self.ema_slow = rana;
self.seeded = true;
}
self.has_emitted = true;
self.ema_fast - self.ema_slow
}
}
impl Indicator for McClellanOscillator {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
Some(self.step(&section))
}
fn reset(&mut self) {
self.ema_fast = 0.0;
self.ema_slow = 0.0;
self.seeded = false;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"McClellanOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn section(up: usize, down: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..up {
members.push(Member::new(1.0, 10.0, false, false));
}
for _ in 0..down {
members.push(Member::new(-1.0, 10.0, false, false));
}
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let osc = McClellanOscillator::new();
assert_eq!(osc.name(), "McClellanOscillator");
assert_eq!(osc.warmup_period(), 1);
assert!(!osc.is_ready());
}
#[test]
fn seeds_to_zero_on_first_tick() {
let mut osc = McClellanOscillator::new();
// RANA = (3 - 1) / 4 * 1000 = 500 ; both EMAs seed to 500 -> spread 0.
assert_eq!(osc.update(section(3, 1)), Some(0.0));
assert!(osc.is_ready());
}
#[test]
fn tracks_breadth_momentum_after_seeding() {
let mut osc = McClellanOscillator::new();
osc.update(section(3, 1)); // seed at RANA 500
// RANA = (1 - 3) / 4 * 1000 = -500.
// fast = 500 + 0.1 * (-1000) = 400 ; slow = 500 + 0.05 * (-1000) = 450.
let value = osc.update(section(1, 3)).unwrap();
assert!((value - (-50.0)).abs() < 1e-9);
// RANA = 0. fast = 400 + 0.1 * (-400) = 360 ; slow = 450 + 0.05 * (-450) = 427.5.
let value = osc.update(section(2, 2)).unwrap();
assert!((value - (-67.5)).abs() < 1e-9);
}
#[test]
fn empty_participation_yields_zero_rana() {
let mut osc = McClellanOscillator::new();
// No advancers or decliners -> RANA 0 ; seeds both EMAs to 0 -> spread 0.
assert_eq!(osc.update(section(0, 0)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut osc = McClellanOscillator::new();
osc.update(section(3, 1));
osc.update(section(1, 3));
assert!(osc.is_ready());
osc.reset();
assert!(!osc.is_ready());
// After reset the next tick re-seeds to spread 0.
assert_eq!(osc.update(section(1, 3)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let sections = vec![section(3, 1), section(1, 3), section(2, 2), section(0, 0)];
let mut a = McClellanOscillator::new();
let mut b = McClellanOscillator::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,160 @@
//! McClellan Summation Index — the running total of the McClellan Oscillator.
use crate::cross_section::CrossSection;
use crate::indicators::mcclellan_oscillator::McClellanOscillator;
use crate::traits::Indicator;
/// McClellan Summation Index — the running cumulative sum of the
/// [`McClellanOscillator`].
///
/// Where the oscillator measures the *momentum* of breadth, the summation index
/// integrates it into a longer-term breadth trend: it rises while the oscillator
/// is positive and falls while it is negative, so it behaves like a slow,
/// smoothed advance/decline line. Sustained readings far above or below zero mark
/// strong bull or bear breadth regimes, and crosses of the zero line are read as
/// major trend changes.
///
/// The index embeds a [`McClellanOscillator`] and adds its value on every tick.
/// Because the oscillator seeds to `0.0` on the first tick, the summation index
/// also starts at `0.0` and is defined from the first update
/// (`warmup_period == 1`).
///
/// `Input = CrossSection`, `Output = f64`.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, Indicator, McClellanSummationIndex, Member};
///
/// let mut msi = McClellanSummationIndex::new();
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// // First tick: oscillator seeds to 0, so the summation index is 0.
/// assert_eq!(msi.update(tick), Some(0.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct McClellanSummationIndex {
oscillator: McClellanOscillator,
sum: f64,
has_emitted: bool,
}
impl McClellanSummationIndex {
/// Construct a new McClellan Summation Index.
#[must_use]
pub fn new() -> Self {
Self {
oscillator: McClellanOscillator::new(),
sum: 0.0,
has_emitted: false,
}
}
}
impl Indicator for McClellanSummationIndex {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let oscillator = self.oscillator.step(&section);
self.sum += oscillator;
self.has_emitted = true;
Some(self.sum)
}
fn reset(&mut self) {
self.oscillator.reset();
self.sum = 0.0;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"McClellanSummationIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn section(up: usize, down: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..up {
members.push(Member::new(1.0, 10.0, false, false));
}
for _ in 0..down {
members.push(Member::new(-1.0, 10.0, false, false));
}
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let msi = McClellanSummationIndex::new();
assert_eq!(msi.name(), "McClellanSummationIndex");
assert_eq!(msi.warmup_period(), 1);
assert!(!msi.is_ready());
}
#[test]
fn first_tick_starts_at_zero() {
let mut msi = McClellanSummationIndex::new();
assert_eq!(msi.update(section(3, 1)), Some(0.0));
assert!(msi.is_ready());
}
#[test]
fn accumulates_the_oscillator() {
let mut msi = McClellanSummationIndex::new();
assert_eq!(msi.update(section(3, 1)), Some(0.0)); // osc 0 -> sum 0
// osc -50 -> sum -50.
let value = msi.update(section(1, 3)).unwrap();
assert!((value - (-50.0)).abs() < 1e-9);
// osc -67.5 -> sum -117.5.
let value = msi.update(section(2, 2)).unwrap();
assert!((value - (-117.5)).abs() < 1e-9);
}
#[test]
fn reset_clears_state() {
let mut msi = McClellanSummationIndex::new();
msi.update(section(3, 1));
msi.update(section(1, 3));
assert!(msi.is_ready());
msi.reset();
assert!(!msi.is_ready());
// Oscillator re-seeds, so the summation index restarts at 0.
assert_eq!(msi.update(section(1, 3)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let sections = vec![section(3, 1), section(1, 3), section(2, 2)];
let mut a = McClellanSummationIndex::new();
let mut b = McClellanSummationIndex::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
+49 -2
View File
@@ -5,13 +5,16 @@
//! from the crate root for convenience.
mod abandoned_baby;
mod absolute_breadth_index;
mod acceleration_bands;
mod accelerator_oscillator;
mod ad_oscillator;
mod ad_volume_line;
mod adaptive_cycle;
mod adl;
mod advance_block;
mod advance_decline;
mod advance_decline_ratio;
mod adx;
mod adxr;
mod alligator;
@@ -36,7 +39,9 @@ mod beta;
mod beta_neutral_spread;
mod bollinger;
mod bollinger_bandwidth;
mod breadth_thrust;
mod breakaway;
mod bullish_percent_index;
mod calendar_spread;
mod calmar_ratio;
mod camarilla_pivots;
@@ -59,6 +64,7 @@ mod conditional_value_at_risk;
mod connors_rsi;
mod coppock;
mod counterattack;
mod cumulative_volume_index;
mod cvd;
mod cybernetic_cycle;
mod decycler;
@@ -109,6 +115,7 @@ mod hammer;
mod hanging_man;
mod harami;
mod heikin_ashi;
mod high_low_index;
mod high_wave;
mod hikkake;
mod hikkake_modified;
@@ -166,6 +173,8 @@ mod mass_index;
mod mat_hold;
mod matching_low;
mod max_drawdown;
mod mcclellan_oscillator;
mod mcclellan_summation_index;
mod mcginley_dynamic;
mod median_absolute_deviation;
mod median_price;
@@ -179,6 +188,7 @@ mod mom;
mod morning_doji_star;
mod morning_evening_star;
mod natr;
mod new_highs_new_lows;
mod nvi;
mod ob_imbalance_full;
mod ob_imbalance_top1;
@@ -197,6 +207,7 @@ mod pair_spread_zscore;
mod pairwise_beta;
mod parkinson;
mod pearson_correlation;
mod percent_above_ma;
mod percent_b;
mod percentage_trailing_stop;
mod pgo;
@@ -282,11 +293,13 @@ mod three_outside;
mod three_soldiers_or_crows;
mod three_stars_in_south;
mod thrusting;
mod tick_index;
mod tii;
mod tpo_profile;
mod trade_imbalance;
mod treynor_ratio;
mod trima;
mod trin;
mod trix;
mod true_range;
mod tsf;
@@ -299,6 +312,7 @@ mod typical_price;
mod ulcer_index;
mod ultimate_oscillator;
mod unique_three_river;
mod up_down_volume_ratio;
mod upside_gap_three_methods;
mod upside_gap_two_crows;
mod value_area;
@@ -330,13 +344,16 @@ mod zig_zag;
mod zlema;
pub use abandoned_baby::AbandonedBaby;
pub use absolute_breadth_index::AbsoluteBreadthIndex;
pub use acceleration_bands::{AccelerationBands, AccelerationBandsOutput};
pub use accelerator_oscillator::AcceleratorOscillator;
pub use ad_oscillator::AdOscillator;
pub use ad_volume_line::AdVolumeLine;
pub use adaptive_cycle::AdaptiveCycle;
pub use adl::Adl;
pub use advance_block::AdvanceBlock;
pub use advance_decline::AdvanceDecline;
pub use advance_decline_ratio::AdvanceDeclineRatio;
pub use adx::{Adx, AdxOutput};
pub use adxr::Adxr;
pub use alligator::{Alligator, AlligatorOutput};
@@ -361,7 +378,9 @@ pub use beta::Beta;
pub use beta_neutral_spread::BetaNeutralSpread;
pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
pub use breadth_thrust::BreadthThrust;
pub use breakaway::Breakaway;
pub use bullish_percent_index::BullishPercentIndex;
pub use calendar_spread::CalendarSpread;
pub use calmar_ratio::CalmarRatio;
pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput};
@@ -384,6 +403,7 @@ pub use conditional_value_at_risk::ConditionalValueAtRisk;
pub use connors_rsi::ConnorsRsi;
pub use coppock::Coppock;
pub use counterattack::Counterattack;
pub use cumulative_volume_index::CumulativeVolumeIndex;
pub use cvd::CumulativeVolumeDelta;
pub use cybernetic_cycle::CyberneticCycle;
pub use decycler::Decycler;
@@ -434,6 +454,7 @@ pub use hammer::Hammer;
pub use hanging_man::HangingMan;
pub use harami::Harami;
pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
pub use high_low_index::HighLowIndex;
pub use high_wave::HighWave;
pub use hikkake::Hikkake;
pub use hikkake_modified::HikkakeModified;
@@ -491,6 +512,8 @@ pub use mass_index::MassIndex;
pub use mat_hold::MatHold;
pub use matching_low::MatchingLow;
pub use max_drawdown::MaxDrawdown;
pub use mcclellan_oscillator::McClellanOscillator;
pub use mcclellan_summation_index::McClellanSummationIndex;
pub use mcginley_dynamic::McGinleyDynamic;
pub use median_absolute_deviation::MedianAbsoluteDeviation;
pub use median_price::MedianPrice;
@@ -504,6 +527,7 @@ pub use mom::Mom;
pub use morning_doji_star::MorningDojiStar;
pub use morning_evening_star::MorningEveningStar;
pub use natr::Natr;
pub use new_highs_new_lows::NewHighsNewLows;
pub use nvi::Nvi;
pub use ob_imbalance_full::OrderBookImbalanceFull;
pub use ob_imbalance_top1::OrderBookImbalanceTop1;
@@ -522,6 +546,7 @@ pub use pair_spread_zscore::PairSpreadZScore;
pub use pairwise_beta::PairwiseBeta;
pub use parkinson::ParkinsonVolatility;
pub use pearson_correlation::PearsonCorrelation;
pub use percent_above_ma::PercentAboveMa;
pub use percent_b::PercentB;
pub use percentage_trailing_stop::PercentageTrailingStop;
pub use pgo::Pgo;
@@ -607,11 +632,13 @@ pub use three_outside::ThreeOutside;
pub use three_soldiers_or_crows::ThreeSoldiersOrCrows;
pub use three_stars_in_south::ThreeStarsInSouth;
pub use thrusting::Thrusting;
pub use tick_index::TickIndex;
pub use tii::Tii;
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
pub use trade_imbalance::TradeImbalance;
pub use treynor_ratio::TreynorRatio;
pub use trima::Trima;
pub use trin::Trin;
pub use trix::Trix;
pub use true_range::TrueRange;
pub use tsf::Tsf;
@@ -624,6 +651,7 @@ pub use typical_price::TypicalPrice;
pub use ulcer_index::UlcerIndex;
pub use ultimate_oscillator::UltimateOscillator;
pub use unique_three_river::UniqueThreeRiver;
pub use up_down_volume_ratio::UpDownVolumeRatio;
pub use upside_gap_three_methods::UpsideGapThreeMethods;
pub use upside_gap_two_crows::UpsideGapTwoCrows;
pub use value_area::{ValueArea, ValueAreaOutput};
@@ -1070,7 +1098,26 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"Alt-Chart Bars",
&["RenkoBars", "KagiBars", "PointAndFigureBars"],
),
("Market Breadth", &["AdvanceDecline"]),
(
"Market Breadth",
&[
"AdvanceDecline",
"AdvanceDeclineRatio",
"AdVolumeLine",
"McClellanOscillator",
"McClellanSummationIndex",
"Trin",
"BreadthThrust",
"NewHighsNewLows",
"HighLowIndex",
"PercentAboveMa",
"UpDownVolumeRatio",
"BullishPercentIndex",
"CumulativeVolumeIndex",
"AbsoluteBreadthIndex",
"TickIndex",
],
),
];
#[cfg(test)]
@@ -1099,6 +1146,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, 325, "FAMILIES total drifted from indicator count");
assert_eq!(total, 339, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,142 @@
//! New Highs New Lows — net count of fresh period extremes across a universe.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// New Highs New Lows — the number of symbols printing a new period high minus
/// the number printing a new period low across a universe.
///
/// On each [`CrossSection`] tick the value is `new_highs - new_lows`, read from the
/// per-symbol `new_high` / `new_low` flags. A persistently positive reading means
/// fresh leadership is broad (many names making new highs); a negative reading
/// during an index advance is a classic breadth divergence warning that the rally
/// is narrowing.
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, Indicator, Member, NewHighsNewLows};
///
/// let mut nhnl = NewHighsNewLows::new();
/// // 2 new highs, 1 new low -> net +1.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 10.0, true, false),
/// Member::new(1.0, 10.0, true, false),
/// Member::new(-1.0, 10.0, false, true),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(nhnl.update(tick), Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct NewHighsNewLows {
has_emitted: bool,
}
impl NewHighsNewLows {
/// Construct a new New Highs New Lows indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for NewHighsNewLows {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let net = section.new_highs() as f64 - section.new_lows() as f64;
self.has_emitted = true;
Some(net)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"NewHighsNewLows"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn flags(highs: usize, lows: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..highs {
members.push(Member::new(1.0, 10.0, true, false));
}
for _ in 0..lows {
members.push(Member::new(-1.0, 10.0, false, true));
}
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let nhnl = NewHighsNewLows::new();
assert_eq!(nhnl.name(), "NewHighsNewLows");
assert_eq!(nhnl.warmup_period(), 1);
assert!(!nhnl.is_ready());
}
#[test]
fn first_tick_emits_net_extremes() {
let mut nhnl = NewHighsNewLows::new();
assert_eq!(nhnl.update(flags(5, 2)), Some(3.0));
assert!(nhnl.is_ready());
}
#[test]
fn more_lows_than_highs_is_negative() {
let mut nhnl = NewHighsNewLows::new();
assert_eq!(nhnl.update(flags(1, 4)), Some(-3.0));
}
#[test]
fn no_extremes_yields_zero() {
let mut nhnl = NewHighsNewLows::new();
assert_eq!(nhnl.update(flags(0, 0)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut nhnl = NewHighsNewLows::new();
nhnl.update(flags(3, 1));
assert!(nhnl.is_ready());
nhnl.reset();
assert!(!nhnl.is_ready());
}
#[test]
fn batch_equals_streaming() {
let sections = vec![flags(5, 2), flags(1, 4), flags(0, 0)];
let mut a = NewHighsNewLows::new();
let mut b = NewHighsNewLows::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,146 @@
//! Percent Above Moving Average — share of a universe trading above its MA.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Percent Above Moving Average — the percentage of symbols in a universe that
/// are trading above their reference moving average.
///
/// On each [`CrossSection`] tick the value is `100 * above_ma_count / universe
/// size`, read from the per-symbol `above_ma` flag (the caller decides which MA —
/// 50-day, 200-day — when it builds the tick). It is a bounded `0..=100` breadth
/// gauge: readings near 100 mean almost the whole universe is in an uptrend
/// (broad participation, but also a potential overbought extreme), readings near
/// zero mark washouts. Crosses of the 50 line are read as bull/bear regime flips.
///
/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`),
/// `warmup_period == 1`. The universe is non-empty by construction, so the share
/// is always defined.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, Indicator, Member, PercentAboveMa};
///
/// let mut pct = PercentAboveMa::new();
/// // 3 of 4 symbols above their MA -> 75%.
/// let tick = CrossSection::new(
/// vec![
/// Member::with_signals(1.0, 10.0, false, false, true, false),
/// Member::with_signals(1.0, 10.0, false, false, true, false),
/// Member::with_signals(-1.0, 10.0, false, false, true, false),
/// Member::with_signals(-1.0, 10.0, false, false, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(pct.update(tick), Some(75.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct PercentAboveMa {
has_emitted: bool,
}
impl PercentAboveMa {
/// Construct a new Percent Above Moving Average indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for PercentAboveMa {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let above = section.above_ma_count() as f64;
let total = section.members.len() as f64;
self.has_emitted = true;
Some(100.0 * above / total)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"PercentAboveMa"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn tick(above: usize, below: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..above {
members.push(Member::with_signals(1.0, 10.0, false, false, true, false));
}
for _ in 0..below {
members.push(Member::with_signals(-1.0, 10.0, false, false, false, false));
}
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let pct = PercentAboveMa::new();
assert_eq!(pct.name(), "PercentAboveMa");
assert_eq!(pct.warmup_period(), 1);
assert!(!pct.is_ready());
}
#[test]
fn first_tick_emits_percentage() {
let mut pct = PercentAboveMa::new();
assert_eq!(pct.update(tick(3, 1)), Some(75.0));
assert!(pct.is_ready());
}
#[test]
fn all_above_is_one_hundred() {
let mut pct = PercentAboveMa::new();
assert_eq!(pct.update(tick(4, 0)), Some(100.0));
}
#[test]
fn none_above_is_zero() {
let mut pct = PercentAboveMa::new();
assert_eq!(pct.update(tick(0, 5)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut pct = PercentAboveMa::new();
pct.update(tick(3, 1));
assert!(pct.is_ready());
pct.reset();
assert!(!pct.is_ready());
}
#[test]
fn batch_equals_streaming() {
let sections = vec![tick(3, 1), tick(4, 0), tick(0, 5)];
let mut a = PercentAboveMa::new();
let mut b = PercentAboveMa::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,148 @@
//! TICK Index — instantaneous net advancing-minus-declining issues.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// TICK Index — the instantaneous net of advancing minus declining issues across
/// a universe, `advancers - decliners`.
///
/// Unlike the cumulative [`AdvanceDecline`](crate::AdvanceDecline) line, the TICK
/// is *not* accumulated: each tick reports the breadth of that snapshot alone. It
/// oscillates around zero — strongly positive readings mean a broad surge of
/// upticks (often an intraday overbought extreme), strongly negative readings a
/// broad flush. Traders fade extremes and watch the zero line for intraday bias.
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, Indicator, Member, TickIndex};
///
/// let mut tick = TickIndex::new();
/// // 2 advancers, 5 decliners -> net -3.
/// let snapshot = CrossSection::new(
/// vec![
/// Member::new(1.0, 10.0, false, false),
/// Member::new(1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(tick.update(snapshot), Some(-3.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct TickIndex {
has_emitted: bool,
}
impl TickIndex {
/// Construct a new TICK Index indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for TickIndex {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let net = section.advancers() as f64 - section.decliners() as f64;
self.has_emitted = true;
Some(net)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"TickIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn section(up: usize, down: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..up {
members.push(Member::new(1.0, 10.0, false, false));
}
for _ in 0..down {
members.push(Member::new(-1.0, 10.0, false, false));
}
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let tick = TickIndex::new();
assert_eq!(tick.name(), "TickIndex");
assert_eq!(tick.warmup_period(), 1);
assert!(!tick.is_ready());
}
#[test]
fn positive_when_advancers_lead() {
let mut tick = TickIndex::new();
assert_eq!(tick.update(section(5, 2)), Some(3.0));
assert!(tick.is_ready());
}
#[test]
fn negative_when_decliners_lead() {
let mut tick = TickIndex::new();
assert_eq!(tick.update(section(2, 5)), Some(-3.0));
}
#[test]
fn does_not_accumulate() {
let mut tick = TickIndex::new();
// Each tick is independent — the second reading does not carry the first.
assert_eq!(tick.update(section(3, 0)), Some(3.0));
assert_eq!(tick.update(section(0, 1)), Some(-1.0));
}
#[test]
fn reset_clears_state() {
let mut tick = TickIndex::new();
tick.update(section(3, 0));
assert!(tick.is_ready());
tick.reset();
assert!(!tick.is_ready());
}
#[test]
fn batch_equals_streaming() {
let sections = vec![section(5, 2), section(2, 5), section(3, 0), section(0, 1)];
let mut a = TickIndex::new();
let mut b = TickIndex::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
+171
View File
@@ -0,0 +1,171 @@
//! TRIN / Arms Index — the advance-decline ratio over the up-down volume ratio.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// TRIN (Arms Index) — `(advancers / decliners) / (advancing volume / declining
/// volume)`.
///
/// The TRIN compares the breadth of a move in *issues* to the breadth of the move
/// in *volume*. A value near `1.0` means advancing issues and advancing volume are
/// in balance; a value below `1.0` is bullish (volume is concentrated in advancing
/// issues relative to their count); a value above `1.0` is bearish (declining
/// issues are absorbing disproportionate volume).
///
/// To stay finite on degenerate ticks the decliner count is floored to one and
/// both volume sums are floored to `1.0`, so a tick with no declining issues or no
/// volume on one side still yields a defined reading instead of a division by
/// zero.
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, Indicator, Member, Trin};
///
/// let mut trin = Trin::new();
/// // 3 advancers / 1 decliner = 3; adv vol 150 / dec vol 50 = 3; TRIN = 1.0.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 50.0, false, false),
/// Member::new(1.0, 50.0, false, false),
/// Member::new(1.0, 50.0, false, false),
/// Member::new(-1.0, 50.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(trin.update(tick), Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct Trin {
has_emitted: bool,
}
impl Trin {
/// Construct a new TRIN / Arms Index indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for Trin {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let advancers = section.advancers() as f64;
let decliners = section.decliners().max(1) as f64;
let advancing_volume = section.advancing_volume().max(1.0);
let declining_volume = section.declining_volume().max(1.0);
let ad_ratio = advancers / decliners;
let volume_ratio = advancing_volume / declining_volume;
self.has_emitted = true;
Some(ad_ratio / volume_ratio)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Trin"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn tick(items: &[(f64, f64)]) -> CrossSection {
CrossSection::new(
items
.iter()
.map(|&(change, volume)| Member::new(change, volume, false, false))
.collect(),
0,
)
.unwrap()
}
#[test]
fn accessors_and_metadata() {
let trin = Trin::new();
assert_eq!(trin.name(), "Trin");
assert_eq!(trin.warmup_period(), 1);
assert!(!trin.is_ready());
}
#[test]
fn balanced_breadth_yields_one() {
let mut trin = Trin::new();
let value = trin
.update(tick(&[(1.0, 50.0), (1.0, 50.0), (1.0, 50.0), (-1.0, 50.0)]))
.unwrap();
assert!((value - 1.0).abs() < 1e-9);
assert!(trin.is_ready());
}
#[test]
fn zero_decliners_and_volume_are_floored() {
let mut trin = Trin::new();
// 2 advancers, 0 decliners, adv vol 100, dec vol 0.
// ad_ratio = 2 / max(0,1) = 2; volume_ratio = 100 / max(0,1) = 100; TRIN = 0.02.
let value = trin.update(tick(&[(1.0, 50.0), (1.0, 50.0)])).unwrap();
assert!((value - 0.02).abs() < 1e-9);
}
#[test]
fn heavy_declining_volume_pushes_above_one() {
let mut trin = Trin::new();
// 2 adv / 2 dec = 1; adv vol 20 / dec vol 80 = 0.25; TRIN = 4.0.
let value = trin
.update(tick(&[
(1.0, 10.0),
(1.0, 10.0),
(-1.0, 40.0),
(-1.0, 40.0),
]))
.unwrap();
assert!((value - 4.0).abs() < 1e-9);
}
#[test]
fn reset_clears_state() {
let mut trin = Trin::new();
trin.update(tick(&[(1.0, 10.0), (-1.0, 10.0)]));
assert!(trin.is_ready());
trin.reset();
assert!(!trin.is_ready());
}
#[test]
fn batch_equals_streaming() {
let sections = vec![
tick(&[(1.0, 50.0), (1.0, 50.0), (1.0, 50.0), (-1.0, 50.0)]),
tick(&[(1.0, 50.0), (1.0, 50.0)]),
tick(&[(1.0, 10.0), (1.0, 10.0), (-1.0, 40.0), (-1.0, 40.0)]),
];
let mut a = Trin::new();
let mut b = Trin::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,143 @@
//! Up/Down Volume Ratio — advancing volume divided by declining volume.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Up/Down Volume Ratio — total advancing volume divided by total declining
/// volume across a universe.
///
/// On each [`CrossSection`] tick the ratio is `advancing volume / declining
/// volume`. A reading above one means more volume is trading in advancing issues
/// than declining ones (accumulation); a reading below one means distribution.
/// Sustained extremes are used to flag breadth thrusts and washout bottoms.
///
/// When a tick has no declining volume the denominator is floored to `1.0`, so the
/// ratio stays finite (it degrades to the advancing-volume total) instead of
/// dividing by zero.
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, Indicator, Member, UpDownVolumeRatio};
///
/// let mut udv = UpDownVolumeRatio::new();
/// // advancing volume 150, declining volume 50 -> ratio 3.0.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 150.0, false, false),
/// Member::new(-1.0, 50.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(udv.update(tick), Some(3.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct UpDownVolumeRatio {
has_emitted: bool,
}
impl UpDownVolumeRatio {
/// Construct a new Up/Down Volume Ratio indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for UpDownVolumeRatio {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let advancing_volume = section.advancing_volume();
let declining_volume = section.declining_volume().max(1.0);
self.has_emitted = true;
Some(advancing_volume / declining_volume)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"UpDownVolumeRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn tick(items: &[(f64, f64)]) -> CrossSection {
CrossSection::new(
items
.iter()
.map(|&(change, volume)| Member::new(change, volume, false, false))
.collect(),
0,
)
.unwrap()
}
#[test]
fn accessors_and_metadata() {
let udv = UpDownVolumeRatio::new();
assert_eq!(udv.name(), "UpDownVolumeRatio");
assert_eq!(udv.warmup_period(), 1);
assert!(!udv.is_ready());
}
#[test]
fn first_tick_emits_ratio() {
let mut udv = UpDownVolumeRatio::new();
assert_eq!(udv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(3.0));
assert!(udv.is_ready());
}
#[test]
fn zero_declining_volume_floors_denominator() {
let mut udv = UpDownVolumeRatio::new();
// advancing volume 100, declining volume 0 -> 100 / max(0, 1) = 100.0.
assert_eq!(udv.update(tick(&[(1.0, 100.0)])), Some(100.0));
}
#[test]
fn reset_clears_state() {
let mut udv = UpDownVolumeRatio::new();
udv.update(tick(&[(1.0, 10.0), (-1.0, 10.0)]));
assert!(udv.is_ready());
udv.reset();
assert!(!udv.is_ready());
}
#[test]
fn batch_equals_streaming() {
let sections = vec![
tick(&[(1.0, 150.0), (-1.0, 50.0)]),
tick(&[(1.0, 100.0)]),
tick(&[(1.0, 20.0), (-1.0, 80.0)]),
];
let mut a = UpDownVolumeRatio::new();
let mut b = UpDownVolumeRatio::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}