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:
@@ -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(§ions),
|
||||
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(§ions),
|
||||
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(§ions),
|
||||
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(§ions),
|
||||
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(§ions),
|
||||
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(§ions),
|
||||
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(§ions),
|
||||
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(§ion))
|
||||
}
|
||||
|
||||
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(§ions),
|
||||
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(§ion);
|
||||
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(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(§ions),
|
||||
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(§ions),
|
||||
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(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(§ions),
|
||||
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(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user