use crate::error::StreamError;
use crate::tick::NormalizedTick;
use rust_decimal::Decimal;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum Timeframe {
Seconds(u64),
Minutes(u64),
Hours(u64),
}
impl Timeframe {
pub fn duration_ms(self) -> u64 {
match self {
Timeframe::Seconds(s) => s * 1_000,
Timeframe::Minutes(m) => m * 60 * 1_000,
Timeframe::Hours(h) => h * 3600 * 1_000,
}
}
pub fn bar_start_ms(self, ts_ms: u64) -> u64 {
let dur = self.duration_ms();
(ts_ms / dur) * dur
}
pub fn from_duration_ms(ms: u64) -> Option<Timeframe> {
if ms == 0 {
return None;
}
if ms % 3_600_000 == 0 {
return Some(Timeframe::Hours(ms / 3_600_000));
}
if ms % 60_000 == 0 {
return Some(Timeframe::Minutes(ms / 60_000));
}
if ms % 1_000 == 0 {
return Some(Timeframe::Seconds(ms / 1_000));
}
None
}
}
impl PartialOrd for Timeframe {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Timeframe {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.duration_ms().cmp(&other.duration_ms())
}
}
impl std::fmt::Display for Timeframe {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Timeframe::Seconds(s) => write!(f, "{s}s"),
Timeframe::Minutes(m) => write!(f, "{m}m"),
Timeframe::Hours(h) => write!(f, "{h}h"),
}
}
}
impl std::str::FromStr for Timeframe {
type Err = crate::error::StreamError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim();
if s.is_empty() {
return Err(crate::error::StreamError::ConfigError {
reason: "timeframe string is empty".into(),
});
}
let (digits, suffix) = s.split_at(s.len() - 1);
let n: u64 = digits.parse().map_err(|_| crate::error::StreamError::ConfigError {
reason: format!("invalid timeframe numeric part '{digits}' in '{s}'"),
})?;
if n == 0 {
return Err(crate::error::StreamError::ConfigError {
reason: format!("timeframe value must be > 0, got '{s}'"),
});
}
match suffix {
"s" => Ok(Timeframe::Seconds(n)),
"m" => Ok(Timeframe::Minutes(n)),
"h" => Ok(Timeframe::Hours(n)),
other => Err(crate::error::StreamError::ConfigError {
reason: format!(
"unknown timeframe suffix '{other}' in '{s}'; expected s, m, or h"
),
}),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BarDirection {
Bullish,
Bearish,
Neutral,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct OhlcvBar {
pub symbol: String,
pub timeframe: Timeframe,
pub bar_start_ms: u64,
pub open: Decimal,
pub high: Decimal,
pub low: Decimal,
pub close: Decimal,
pub volume: Decimal,
pub trade_count: u64,
pub is_complete: bool,
pub is_gap_fill: bool,
pub vwap: Option<Decimal>,
}
impl OhlcvBar {
pub fn range(&self) -> Decimal {
self.high - self.low
}
pub fn body(&self) -> Decimal {
(self.close - self.open).abs()
}
pub fn body_high(&self) -> Decimal {
self.open.max(self.close)
}
pub fn body_low(&self) -> Decimal {
self.open.min(self.close)
}
pub fn is_bullish(&self) -> bool {
self.close > self.open
}
pub fn is_bearish(&self) -> bool {
self.close < self.open
}
pub fn has_upper_wick(&self) -> bool {
self.wick_upper() > Decimal::ZERO
}
pub fn has_lower_wick(&self) -> bool {
self.wick_lower() > Decimal::ZERO
}
pub fn body_direction(&self) -> BarDirection {
use std::cmp::Ordering;
match self.close.cmp(&self.open) {
Ordering::Greater => BarDirection::Bullish,
Ordering::Less => BarDirection::Bearish,
Ordering::Equal => BarDirection::Neutral,
}
}
pub fn is_doji(&self, epsilon: Decimal) -> bool {
self.body() <= epsilon
}
pub fn wick_upper(&self) -> Decimal {
self.high - self.body_high()
}
pub fn wick_lower(&self) -> Decimal {
self.body_low() - self.low
}
pub fn price_change(&self) -> Decimal {
self.close - self.open
}
pub fn typical_price(&self) -> Decimal {
(self.high + self.low + self.close) / Decimal::from(3)
}
pub fn close_location_value(&self) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let range = self.range();
if range.is_zero() {
return None;
}
((self.close - self.low - (self.high - self.close)) / range).to_f64()
}
pub fn median_price(&self) -> Decimal {
(self.high + self.low) / Decimal::from(2)
}
pub fn weighted_close(&self) -> Decimal {
(self.high + self.low + self.close + self.close) / Decimal::from(4)
}
pub fn price_change_pct(&self) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if self.open.is_zero() {
return None;
}
let pct = self.price_change() / self.open * Decimal::from(100);
pct.to_f64()
}
pub fn body_ratio(&self) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let range = self.range();
if range.is_zero() {
return None;
}
(self.body() / range).to_f64()
}
pub fn true_range(&self, prev_close: Decimal) -> Decimal {
let hl = self.range();
let hpc = (self.high - prev_close).abs();
let lpc = (self.low - prev_close).abs();
hl.max(hpc).max(lpc)
}
#[deprecated(since = "2.2.0", note = "Use `is_inside_bar` instead")]
pub fn inside_bar(&self, prev: &OhlcvBar) -> bool {
self.is_inside_bar(prev)
}
pub fn outside_bar(&self, prev: &OhlcvBar) -> bool {
self.high > prev.high && self.low < prev.low
}
pub fn wick_ratio(&self) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let range = self.range();
if range.is_zero() {
return None;
}
((self.wick_upper() + self.wick_lower()) / range).to_f64()
}
pub fn is_hammer(&self) -> bool {
let range = self.range();
if range.is_zero() {
return false;
}
let body = self.body();
let wick_lo = self.wick_lower();
let wick_hi = self.wick_upper();
let three = Decimal::from(3);
let six = Decimal::from(6);
let ten = Decimal::from(10);
body * ten <= range * three
&& wick_lo * ten >= range * six
&& wick_hi * ten <= range
}
pub fn is_shooting_star(&self) -> bool {
let range = self.range();
if range.is_zero() {
return false;
}
let body = self.body();
let wick_lo = self.wick_lower();
let wick_hi = self.wick_upper();
let three = Decimal::from(3);
let six = Decimal::from(6);
let ten = Decimal::from(10);
body * ten <= range * three
&& wick_hi * ten >= range * six
&& wick_lo * ten <= range
}
pub fn gap_from(&self, prev: &OhlcvBar) -> Decimal {
self.open - prev.close
}
pub fn is_gap_up(&self, prev: &OhlcvBar) -> bool {
self.open > prev.close
}
pub fn is_gap_down(&self, prev: &OhlcvBar) -> bool {
self.open < prev.close
}
pub fn bar_midpoint(&self) -> Decimal {
(self.open + self.close) / Decimal::from(2)
}
pub fn body_to_range_ratio(&self) -> Option<Decimal> {
let r = self.range();
if r.is_zero() {
return None;
}
Some(self.body() / r)
}
pub fn is_long_upper_wick(&self) -> bool {
self.wick_upper() > self.body()
}
pub fn is_long_lower_wick(&self) -> bool {
self.wick_lower() > self.body()
}
#[deprecated(since = "2.2.0", note = "Use `body()` instead")]
pub fn price_change_abs(&self) -> Decimal {
self.body()
}
pub fn upper_shadow(&self) -> Decimal {
self.wick_upper()
}
pub fn lower_shadow(&self) -> Decimal {
self.wick_lower()
}
pub fn is_spinning_top(&self, body_pct: Decimal) -> bool {
let range = self.range();
if range.is_zero() {
return false;
}
let body = self.body();
let max_body = range * body_pct;
body <= max_body && self.wick_upper() > body && self.wick_lower() > body
}
pub fn hlc3(&self) -> Decimal {
self.typical_price()
}
pub fn ohlc4(&self) -> Decimal {
(self.open + self.high + self.low + self.close) / Decimal::from(4)
}
pub fn is_marubozu(&self) -> bool {
self.wick_upper().is_zero() && self.wick_lower().is_zero()
}
pub fn is_engulfing(&self, prev: &OhlcvBar) -> bool {
self.body_low() < prev.body_low() && self.body_high() > prev.body_high()
}
pub fn is_harami(&self, prev: &OhlcvBar) -> bool {
self.body_low() > prev.body_low() && self.body_high() < prev.body_high()
}
pub fn tail_length(&self) -> Decimal {
self.wick_upper().max(self.wick_lower())
}
pub fn is_inside_bar(&self, prev: &OhlcvBar) -> bool {
self.high < prev.high && self.low > prev.low
}
pub fn gap_up(&self, prev: &OhlcvBar) -> bool {
self.open > prev.high
}
pub fn gap_down(&self, prev: &OhlcvBar) -> bool {
self.open < prev.low
}
#[deprecated(since = "2.2.0", note = "Use `body()` instead")]
pub fn body_size(&self) -> Decimal {
self.body()
}
pub fn volume_delta(&self, prev: &OhlcvBar) -> Decimal {
self.volume - prev.volume
}
pub fn is_consolidating(&self, prev: &OhlcvBar) -> bool {
let prev_range = prev.range();
if prev_range.is_zero() {
return false;
}
self.range() < prev_range / Decimal::TWO
}
pub fn mean_volume(bars: &[OhlcvBar]) -> Option<Decimal> {
if bars.is_empty() {
return None;
}
Some(Self::sum_volume(bars) / Decimal::from(bars.len() as u64))
}
pub fn vwap_deviation(&self) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vwap = self.vwap?;
if vwap.is_zero() {
return None;
}
((self.close - vwap).abs() / vwap).to_f64()
}
pub fn relative_volume(&self, avg_volume: Decimal) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if avg_volume.is_zero() {
return None;
}
(self.volume / avg_volume).to_f64()
}
pub fn intraday_reversal(&self, prev: &OhlcvBar) -> bool {
let prev_bullish = prev.close > prev.open;
let this_bearish = self.close < self.open;
let prev_bearish = prev.close < prev.open;
let this_bullish = self.close > self.open;
(prev_bullish && this_bearish && self.open >= prev.close)
|| (prev_bearish && this_bullish && self.open <= prev.close)
}
pub fn range_pct(&self) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if self.open.is_zero() {
return None;
}
let range = self.range() / self.open;
range.to_f64().map(|v| v * 100.0)
}
#[deprecated(since = "2.2.0", note = "Use `outside_bar()` instead")]
pub fn is_outside_bar(&self, prev: &OhlcvBar) -> bool {
self.outside_bar(prev)
}
pub fn high_low_midpoint(&self) -> Decimal {
self.median_price()
}
pub fn high_close_ratio(&self) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if self.high.is_zero() {
return None;
}
(self.close / self.high).to_f64()
}
pub fn lower_shadow_pct(&self) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let range = self.range();
if range.is_zero() {
return None;
}
(self.lower_shadow() / range).to_f64()
}
pub fn open_close_ratio(&self) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if self.open.is_zero() {
return None;
}
(self.close / self.open).to_f64()
}
pub fn is_wide_range_bar(&self, threshold: Decimal) -> bool {
self.range() > threshold
}
pub fn close_to_low_ratio(&self) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let range = self.range();
if range.is_zero() {
return None;
}
((self.close - self.low) / range).to_f64()
}
pub fn volume_per_trade(&self) -> Option<Decimal> {
if self.trade_count == 0 {
return None;
}
Some(self.volume / Decimal::from(self.trade_count as u64))
}
pub fn price_range_overlap(&self, other: &OhlcvBar) -> bool {
self.high >= other.low && other.high >= self.low
}
pub fn bar_height_pct(&self) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if self.open.is_zero() {
return None;
}
(self.range() / self.open).to_f64()
}
pub fn bar_type(&self) -> &'static str {
if self.close == self.open {
"doji"
} else if self.close > self.open {
"bullish"
} else {
"bearish"
}
}
pub fn body_pct(&self) -> Option<Decimal> {
let range = self.range();
if range.is_zero() {
return None;
}
Some(self.body() / range * Decimal::ONE_HUNDRED)
}
pub fn is_bullish_hammer(&self) -> bool {
let body = self.body();
if body.is_zero() {
return false;
}
let lower = self.wick_lower();
let upper = self.wick_upper();
lower >= body * Decimal::TWO && upper <= body
}
pub fn upper_wick_pct(&self) -> Option<Decimal> {
let range = self.range();
if range.is_zero() {
return None;
}
Some(self.wick_upper() / range * Decimal::ONE_HUNDRED)
}
pub fn lower_wick_pct(&self) -> Option<Decimal> {
let range = self.range();
if range.is_zero() {
return None;
}
Some(self.wick_lower() / range * Decimal::ONE_HUNDRED)
}
pub fn is_bearish_engulfing(&self, prev: &OhlcvBar) -> bool {
self.is_bearish() && self.is_engulfing(prev)
}
pub fn is_bullish_engulfing(&self, prev: &OhlcvBar) -> bool {
self.is_bullish() && self.is_engulfing(prev)
}
#[deprecated(since = "2.2.0", note = "Use `gap_from()` instead")]
pub fn close_gap(&self, prev: &OhlcvBar) -> Decimal {
self.gap_from(prev)
}
pub fn close_above_midpoint(&self) -> bool {
self.close > self.high_low_midpoint()
}
pub fn close_momentum(&self, prev: &OhlcvBar) -> Decimal {
self.close - prev.close
}
#[deprecated(since = "2.2.0", note = "Use `range()` instead")]
pub fn bar_range(&self) -> Decimal {
self.range()
}
pub fn bar_duration_ms(&self) -> u64 {
self.timeframe.duration_ms()
}
pub fn is_gravestone_doji(&self, epsilon: Decimal) -> bool {
self.body() <= epsilon && (self.close - self.low).abs() <= epsilon
}
pub fn is_dragonfly_doji(&self, epsilon: Decimal) -> bool {
self.body() <= epsilon && (self.high - self.close).abs() <= epsilon
}
pub fn is_flat(&self) -> bool {
self.range().is_zero()
}
#[deprecated(since = "2.2.0", note = "Use `true_range()` instead")]
pub fn true_range_with_prev(&self, prev_close: Decimal) -> Decimal {
self.true_range(prev_close)
}
#[deprecated(since = "2.2.0", note = "Use `high_close_ratio()` instead")]
pub fn close_to_high_ratio(&self) -> Option<f64> {
self.high_close_ratio()
}
#[deprecated(since = "2.2.0", note = "Use `open_close_ratio()` instead")]
pub fn close_open_ratio(&self) -> Option<f64> {
self.open_close_ratio()
}
pub fn price_at_pct(&self, pct: f64) -> Decimal {
use rust_decimal::prelude::FromPrimitive;
let pct_clamped = pct.clamp(0.0, 1.0);
let factor = Decimal::from_f64(pct_clamped).unwrap_or(Decimal::ZERO);
self.low + self.range() * factor
}
pub fn average_true_range(bars: &[OhlcvBar]) -> Option<Decimal> {
if bars.len() < 2 {
return None;
}
let sum: Decimal = (1..bars.len())
.map(|i| bars[i].true_range(bars[i - 1].close))
.sum();
Some(sum / Decimal::from((bars.len() - 1) as u64))
}
pub fn average_body(bars: &[OhlcvBar]) -> Option<Decimal> {
if bars.is_empty() {
return None;
}
let sum: Decimal = bars.iter().map(|b| b.body()).sum();
Some(sum / Decimal::from(bars.len() as u64))
}
pub fn highest_high(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| b.high).reduce(Decimal::max)
}
pub fn lowest_low(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| b.low).reduce(Decimal::min)
}
pub fn highest_close(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| b.close).reduce(Decimal::max)
}
pub fn lowest_close(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| b.close).reduce(Decimal::min)
}
pub fn close_range(bars: &[OhlcvBar]) -> Option<Decimal> {
let hi = Self::highest_close(bars)?;
let lo = Self::lowest_close(bars)?;
Some(hi - lo)
}
pub fn momentum(bars: &[OhlcvBar], n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let len = bars.len();
if len <= n {
return None;
}
let current = bars[len - 1].close;
let prior = bars[len - 1 - n].close;
if prior.is_zero() {
return None;
}
((current - prior) / prior).to_f64()
}
pub fn sum_volume(bars: &[OhlcvBar]) -> Decimal {
bars.iter().map(|b| b.volume).sum()
}
pub fn bullish_count(bars: &[OhlcvBar]) -> usize {
bars.iter().filter(|b| b.is_bullish()).count()
}
pub fn bearish_count(bars: &[OhlcvBar]) -> usize {
bars.iter().filter(|b| b.is_bearish()).count()
}
pub fn bullish_streak(bars: &[OhlcvBar]) -> usize {
bars.iter().rev().take_while(|b| b.is_bullish()).count()
}
pub fn bearish_streak(bars: &[OhlcvBar]) -> usize {
bars.iter().rev().take_while(|b| b.is_bearish()).count()
}
pub fn win_rate(bars: &[OhlcvBar]) -> Option<f64> {
if bars.is_empty() {
return None;
}
Some(Self::bullish_count(bars) as f64 / bars.len() as f64)
}
pub fn max_drawdown(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.len() < 2 {
return None;
}
let mut peak = bars[0].close;
let mut max_dd = 0.0_f64;
for bar in &bars[1..] {
if bar.close > peak {
peak = bar.close;
} else if !peak.is_zero() {
let dd = ((peak - bar.close) / peak).to_f64().unwrap_or(0.0);
if dd > max_dd {
max_dd = dd;
}
}
}
Some(max_dd)
}
pub fn linear_regression_slope(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let ys: Vec<f64> = bars.iter().filter_map(|b| b.close.to_f64()).collect();
Self::ols_slope_indexed(&ys, bars.len())
}
pub fn volume_slope(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let ys: Vec<f64> = bars.iter().filter_map(|b| b.volume.to_f64()).collect();
Self::ols_slope_indexed(&ys, bars.len())
}
fn ols_slope_indexed(ys: &[f64], expected_n: usize) -> Option<f64> {
if ys.len() < expected_n || expected_n < 2 {
return None;
}
let n_f = expected_n as f64;
let x_mean = (n_f - 1.0) / 2.0;
let y_mean = ys.iter().sum::<f64>() / n_f;
let numerator: f64 = ys.iter().enumerate().map(|(i, y)| (i as f64 - x_mean) * (y - y_mean)).sum();
let denominator: f64 = ys.iter().enumerate().map(|(i, _)| (i as f64 - x_mean).powi(2)).sum();
if denominator == 0.0 {
return None;
}
Some(numerator / denominator)
}
pub fn mean_close(bars: &[OhlcvBar]) -> Option<Decimal> {
if bars.is_empty() {
return None;
}
let sum: Decimal = bars.iter().map(|b| b.close).sum();
Some(sum / Decimal::from(bars.len() as u64))
}
pub fn close_std_dev(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let n = bars.len();
if n < 2 {
return None;
}
let mean = Self::mean_close(bars)?.to_f64()?;
let variance: f64 = bars.iter()
.filter_map(|b| b.close.to_f64())
.map(|c| (c - mean).powi(2))
.sum::<f64>() / n as f64;
Some(variance.sqrt())
}
pub fn price_efficiency_ratio(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let n = bars.len();
if n < 2 {
return None;
}
let net_move = (bars[n - 1].close - bars[0].close).abs();
let total_path: Decimal = bars.iter().map(|b| b.range()).sum();
if total_path.is_zero() {
return None;
}
(net_move / total_path).to_f64()
}
pub fn mean_clv(bars: &[OhlcvBar]) -> Option<f64> {
if bars.is_empty() {
return None;
}
let clvs: Vec<f64> = bars.iter().filter_map(|b| b.close_location_value()).collect();
if clvs.is_empty() {
return None;
}
Some(clvs.iter().sum::<f64>() / clvs.len() as f64)
}
pub fn mean_range(bars: &[OhlcvBar]) -> Option<Decimal> {
if bars.is_empty() {
return None;
}
let total: Decimal = bars.iter().map(|b| b.range()).sum();
Some(total / Decimal::from(bars.len() as u64))
}
pub fn close_z_score(bars: &[OhlcvBar], value: Decimal) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let mean = Self::mean_close(bars)?;
let std_dev = Self::close_std_dev(bars)?;
if std_dev == 0.0 {
return None;
}
((value - mean) / Decimal::try_from(std_dev).ok()?).to_f64()
}
pub fn bollinger_band_width(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let mean = Self::mean_close(bars)?;
if mean.is_zero() {
return None;
}
let std_dev = Self::close_std_dev(bars)?;
let width = 2.0 * std_dev / mean.to_f64()?;
Some(width)
}
pub fn up_down_ratio(bars: &[OhlcvBar]) -> Option<f64> {
let down = Self::bearish_count(bars);
if down == 0 {
return None;
}
Some(Self::bullish_count(bars) as f64 / down as f64)
}
pub fn volume_weighted_close(bars: &[OhlcvBar]) -> Option<Decimal> {
let total_volume = Self::sum_volume(bars);
if total_volume.is_zero() {
return None;
}
let weighted_sum: Decimal = bars.iter().map(|b| b.close * b.volume).sum();
Some(weighted_sum / total_volume)
}
pub fn rolling_return(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let n = bars.len();
if n < 2 {
return None;
}
let first = bars[0].close;
let last = bars[n - 1].close;
if first.is_zero() {
return None;
}
((last - first) / first).to_f64()
}
pub fn average_high(bars: &[OhlcvBar]) -> Option<Decimal> {
if bars.is_empty() {
return None;
}
let total: Decimal = bars.iter().map(|b| b.high).sum();
Some(total / Decimal::from(bars.len() as u64))
}
pub fn average_low(bars: &[OhlcvBar]) -> Option<Decimal> {
if bars.is_empty() {
return None;
}
let total: Decimal = bars.iter().map(|b| b.low).sum();
Some(total / Decimal::from(bars.len() as u64))
}
pub fn min_body(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| b.body()).reduce(Decimal::min)
}
pub fn max_body(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| b.body()).reduce(Decimal::max)
}
pub fn atr_pct(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let atr = Self::average_true_range(bars)?;
let mean = Self::mean_close(bars)?;
if mean.is_zero() {
return None;
}
(atr / mean).to_f64()
}
pub fn breakout_count(bars: &[OhlcvBar]) -> usize {
if bars.len() < 2 {
return 0;
}
bars.windows(2)
.filter(|w| w[1].close > w[0].high)
.count()
}
pub fn doji_count(bars: &[OhlcvBar], epsilon: Decimal) -> usize {
bars.iter().filter(|b| b.is_doji(epsilon)).count()
}
pub fn channel_width(bars: &[OhlcvBar]) -> Option<Decimal> {
let hi = Self::highest_high(bars)?;
let lo = Self::lowest_low(bars)?;
Some(hi - lo)
}
pub fn sma(bars: &[OhlcvBar], n: usize) -> Option<Decimal> {
if n == 0 || bars.len() < n {
return None;
}
let window = &bars[bars.len() - n..];
let sum: Decimal = window.iter().map(|b| b.close).sum();
Some(sum / Decimal::from(n as u64))
}
pub fn mean_wick_ratio(bars: &[OhlcvBar]) -> Option<f64> {
let ratios: Vec<f64> = bars.iter().filter_map(|b| b.wick_ratio()).collect();
if ratios.is_empty() {
return None;
}
Some(ratios.iter().sum::<f64>() / ratios.len() as f64)
}
pub fn bullish_volume(bars: &[OhlcvBar]) -> Decimal {
bars.iter().filter(|b| b.is_bullish()).map(|b| b.volume).sum()
}
pub fn bearish_volume(bars: &[OhlcvBar]) -> Decimal {
bars.iter().filter(|b| b.is_bearish()).map(|b| b.volume).sum()
}
pub fn close_above_mid_count(bars: &[OhlcvBar]) -> usize {
bars.iter().filter(|b| b.close > b.high_low_midpoint()).count()
}
pub fn ema(bars: &[OhlcvBar], alpha: f64) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let alpha = alpha.clamp(1e-9, 1.0);
let mut iter = bars.iter();
let first = iter.next()?.close.to_f64()?;
let result = iter.fold(first, |acc, b| {
let c = b.close.to_f64().unwrap_or(acc);
alpha * c + (1.0 - alpha) * acc
});
Some(result)
}
pub fn highest_open(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| b.open).reduce(Decimal::max)
}
pub fn lowest_open(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| b.open).reduce(Decimal::min)
}
pub fn rising_close_count(bars: &[OhlcvBar]) -> usize {
if bars.len() < 2 {
return 0;
}
bars.windows(2).filter(|w| w[1].close > w[0].close).count()
}
pub fn mean_body_ratio(bars: &[OhlcvBar]) -> Option<f64> {
let ratios: Vec<f64> = bars.iter().filter_map(|b| b.body_ratio()).collect();
if ratios.is_empty() {
return None;
}
Some(ratios.iter().sum::<f64>() / ratios.len() as f64)
}
pub fn volume_std_dev(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let n = bars.len();
if n < 2 {
return None;
}
let vols: Vec<f64> = bars.iter().filter_map(|b| b.volume.to_f64()).collect();
if vols.len() < 2 {
return None;
}
let mean = vols.iter().sum::<f64>() / vols.len() as f64;
let variance = vols.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (vols.len() - 1) as f64;
Some(variance.sqrt())
}
pub fn max_volume_bar(bars: &[OhlcvBar]) -> Option<&OhlcvBar> {
bars.iter().max_by(|a, b| a.volume.cmp(&b.volume))
}
pub fn min_volume_bar(bars: &[OhlcvBar]) -> Option<&OhlcvBar> {
bars.iter().min_by(|a, b| a.volume.cmp(&b.volume))
}
pub fn gap_sum(bars: &[OhlcvBar]) -> Decimal {
if bars.len() < 2 {
return Decimal::ZERO;
}
bars.windows(2).map(|w| w[1].open - w[0].close).sum()
}
pub fn three_white_soldiers(bars: &[OhlcvBar]) -> bool {
if bars.len() < 3 {
return false;
}
let last3 = &bars[bars.len() - 3..];
last3[0].close > last3[0].open
&& last3[1].close > last3[1].open
&& last3[2].close > last3[2].open
&& last3[1].close > last3[0].close
&& last3[2].close > last3[1].close
}
pub fn three_black_crows(bars: &[OhlcvBar]) -> bool {
if bars.len() < 3 {
return false;
}
let last3 = &bars[bars.len() - 3..];
last3[0].close < last3[0].open
&& last3[1].close < last3[1].open
&& last3[2].close < last3[2].open
&& last3[1].close < last3[0].close
&& last3[2].close < last3[1].close
}
pub fn is_gap_bar(bar: &OhlcvBar, prev_close: Decimal) -> bool {
bar.open != prev_close
}
pub fn gap_bars_count(bars: &[OhlcvBar]) -> usize {
if bars.len() < 2 {
return 0;
}
bars.windows(2).filter(|w| w[1].open != w[0].close).count()
}
pub fn bar_efficiency(bar: &OhlcvBar) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let range = bar.range();
if range.is_zero() {
return None;
}
(bar.body() / range).to_f64()
}
pub fn wicks_sum(bars: &[OhlcvBar]) -> Decimal {
bars.iter().map(|b| b.wick_upper() + b.wick_lower()).sum()
}
pub fn avg_close_to_high(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.is_empty() {
return None;
}
let sum: Decimal = bars.iter().map(|b| b.high - b.close).sum();
(sum / Decimal::from(bars.len() as u32)).to_f64()
}
pub fn avg_range(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.is_empty() {
return None;
}
let sum: Decimal = bars.iter().map(|b| b.range()).sum();
(sum / Decimal::from(bars.len() as u32)).to_f64()
}
pub fn max_close(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| b.close).reduce(Decimal::max)
}
pub fn min_close(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| b.close).reduce(Decimal::min)
}
pub fn trend_strength(bars: &[OhlcvBar]) -> Option<f64> {
if bars.len() < 2 {
return None;
}
let moves = bars.len() - 1;
let up = bars.windows(2).filter(|w| w[1].close > w[0].close).count();
Some(up as f64 / moves as f64)
}
pub fn net_change(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.last().map(|b| b.price_change())
}
pub fn open_to_close_pct(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let bar = bars.last()?;
if bar.open.is_zero() {
return None;
}
(bar.price_change() / bar.open * Decimal::ONE_HUNDRED).to_f64()
}
pub fn high_to_low_pct(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let bar = bars.last()?;
if bar.high.is_zero() {
return None;
}
(bar.range() / bar.high * Decimal::ONE_HUNDRED).to_f64()
}
pub fn consecutive_highs(bars: &[OhlcvBar]) -> usize {
if bars.len() < 2 {
return 0;
}
let mut count = 0;
for w in bars.windows(2).rev() {
if w[1].high > w[0].high {
count += 1;
} else {
break;
}
}
count
}
pub fn consecutive_lows(bars: &[OhlcvBar]) -> usize {
if bars.len() < 2 {
return 0;
}
let mut count = 0;
for w in bars.windows(2).rev() {
if w[1].low < w[0].low {
count += 1;
} else {
break;
}
}
count
}
pub fn volume_change_pct(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.len() < 2 {
return None;
}
let prior = bars[bars.len() - 2].volume;
if prior.is_zero() {
return None;
}
let current = bars[bars.len() - 1].volume;
((current - prior) / prior * Decimal::ONE_HUNDRED).to_f64()
}
pub fn open_gap_pct(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.len() < 2 {
return None;
}
let prev_close = bars[bars.len() - 2].close;
if prev_close.is_zero() {
return None;
}
let current_open = bars[bars.len() - 1].open;
((current_open - prev_close) / prev_close * Decimal::ONE_HUNDRED).to_f64()
}
pub fn volume_cumulative(bars: &[OhlcvBar]) -> Decimal {
bars.iter().map(|b| b.volume).sum()
}
pub fn price_position(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let hi = Self::highest_high(bars)?;
let lo = Self::lowest_low(bars)?;
let range = hi - lo;
if range.is_zero() {
return None;
}
let last_close = bars.last()?.close;
((last_close - lo) / range).to_f64()
}
pub fn is_trending_up(bars: &[OhlcvBar], n: usize) -> bool {
if n < 2 || bars.len() < n {
return false;
}
bars[bars.len() - n..].windows(2).all(|w| w[1].close > w[0].close)
}
pub fn is_trending_down(bars: &[OhlcvBar], n: usize) -> bool {
if n < 2 || bars.len() < n {
return false;
}
bars[bars.len() - n..].windows(2).all(|w| w[1].close < w[0].close)
}
pub fn volume_acceleration(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.len() < 2 {
return None;
}
let prev = bars[bars.len() - 2].volume;
if prev.is_zero() {
return None;
}
let curr = bars[bars.len() - 1].volume;
((curr - prev) / prev * Decimal::ONE_HUNDRED).to_f64()
}
pub fn wick_body_ratio(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let valid: Vec<f64> = bars.iter().filter_map(|b| {
let body = b.body();
if body.is_zero() {
return None;
}
let wicks = b.wick_upper() + b.wick_lower();
(wicks / body).to_f64()
}).collect();
if valid.is_empty() {
return None;
}
Some(valid.iter().sum::<f64>() / valid.len() as f64)
}
pub fn close_above_open_count(bars: &[OhlcvBar]) -> usize {
bars.iter().filter(|b| b.close > b.open).count()
}
pub fn volume_price_correlation(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let n = bars.len();
if n < 2 {
return None;
}
let vols: Vec<f64> = bars.iter().filter_map(|b| b.volume.to_f64()).collect();
let closes: Vec<f64> = bars.iter().filter_map(|b| b.close.to_f64()).collect();
if vols.len() != n || closes.len() != n {
return None;
}
let nf = n as f64;
let mean_v = vols.iter().sum::<f64>() / nf;
let mean_c = closes.iter().sum::<f64>() / nf;
let cov: f64 = vols.iter().zip(closes.iter()).map(|(v, c)| (v - mean_v) * (c - mean_c)).sum::<f64>() / nf;
let std_v = (vols.iter().map(|v| (v - mean_v).powi(2)).sum::<f64>() / nf).sqrt();
let std_c = (closes.iter().map(|c| (c - mean_c).powi(2)).sum::<f64>() / nf).sqrt();
if std_v == 0.0 || std_c == 0.0 {
return None;
}
Some(cov / (std_v * std_c))
}
pub fn body_consistency(bars: &[OhlcvBar]) -> Option<f64> {
if bars.is_empty() {
return None;
}
let valid: Vec<_> = bars.iter().filter(|b| !b.range().is_zero()).collect();
if valid.is_empty() {
return None;
}
let consistent = valid.iter().filter(|b| {
b.body() * Decimal::TWO > b.range()
}).count();
Some(consistent as f64 / valid.len() as f64)
}
pub fn close_volatility_ratio(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let mean = Self::mean_close(bars)?;
if mean.is_zero() {
return None;
}
let std = Self::close_std_dev(bars)?;
let mean_f = mean.to_f64()?;
Some(std / mean_f.abs())
}
pub fn close_momentum_score(bars: &[OhlcvBar]) -> Option<f64> {
let mean = Self::mean_close(bars)?;
let above = bars.iter().filter(|b| b.close > mean).count();
Some(above as f64 / bars.len() as f64)
}
pub fn range_expansion_count(bars: &[OhlcvBar]) -> usize {
if bars.len() < 2 {
return 0;
}
bars.windows(2).filter(|w| w[1].range() > w[0].range()).count()
}
pub fn gap_count(bars: &[OhlcvBar]) -> usize {
if bars.len() < 2 {
return 0;
}
bars.windows(2).filter(|w| w[1].open != w[0].close).count()
}
pub fn avg_wick_size(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.is_empty() {
return None;
}
let total: f64 = bars.iter()
.filter_map(|b| (b.wick_upper() + b.wick_lower()).to_f64())
.sum();
Some(total / bars.len() as f64)
}
pub fn mean_volume_ratio(bars: &[OhlcvBar]) -> Vec<Option<f64>> {
use rust_decimal::prelude::ToPrimitive;
if bars.is_empty() {
return vec![];
}
let mean = match Self::mean_volume(bars) {
Some(m) if !m.is_zero() => m,
_ => return bars.iter().map(|_| None).collect(),
};
bars.iter().map(|b| (b.volume / mean).to_f64()).collect()
}
pub fn close_above_high_ma(bars: &[OhlcvBar], n: usize) -> usize {
if n < 1 || bars.len() < n {
return 0;
}
let high_ma: Decimal = bars.iter().take(n).map(|b| b.high).sum::<Decimal>()
/ Decimal::from(n as u32);
bars[n - 1..].iter().filter(|b| b.close > high_ma).count()
}
pub fn price_compression_ratio(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let mean_body = Self::average_body(bars)?;
let mean_range = Self::mean_range(bars)?;
if mean_range.is_zero() {
return None;
}
(mean_body / mean_range).to_f64()
}
pub fn open_close_spread(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.is_empty() {
return None;
}
let total: f64 = bars.iter()
.filter_map(|b| (b.close - b.open).abs().to_f64())
.sum();
Some(total / bars.len() as f64)
}
pub fn max_consecutive_gains(bars: &[OhlcvBar]) -> usize {
let mut max_run = 0usize;
let mut current = 0usize;
for w in bars.windows(2) {
if w[1].close > w[0].close {
current += 1;
if current > max_run {
max_run = current;
}
} else {
current = 0;
}
}
max_run
}
pub fn max_consecutive_losses(bars: &[OhlcvBar]) -> usize {
let mut max_run = 0usize;
let mut current = 0usize;
for w in bars.windows(2) {
if w[1].close < w[0].close {
current += 1;
if current > max_run {
max_run = current;
}
} else {
current = 0;
}
}
max_run
}
pub fn price_path_length(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.len() < 2 {
return None;
}
let total: f64 = bars.windows(2)
.filter_map(|w| (w[1].close - w[0].close).abs().to_f64())
.sum();
Some(total)
}
pub fn close_reversion_count(bars: &[OhlcvBar]) -> usize {
let mean = match Self::mean_close(bars) {
Some(m) => m,
None => return 0,
};
if bars.len() < 2 {
return 0;
}
bars.windows(2).filter(|w| {
let prev = w[0].close;
let curr = w[1].close;
if prev < mean {
curr > prev && curr <= mean
} else {
curr < prev && curr >= mean
}
}).count()
}
pub fn atr_ratio(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let atr = Self::average_true_range(bars)?;
let mean = Self::mean_close(bars)?;
if mean.is_zero() {
return None;
}
(atr / mean * Decimal::ONE_HUNDRED).to_f64()
}
pub fn volume_trend_strength(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let n = bars.len();
if n < 2 {
return None;
}
let nf = n as f64;
let indices: Vec<f64> = (0..n).map(|i| i as f64).collect();
let vols: Vec<f64> = bars.iter().filter_map(|b| b.volume.to_f64()).collect();
if vols.len() != n {
return None;
}
let mean_i = indices.iter().sum::<f64>() / nf;
let mean_v = vols.iter().sum::<f64>() / nf;
let cov: f64 = indices.iter().zip(vols.iter()).map(|(i, v)| (i - mean_i) * (v - mean_v)).sum::<f64>() / nf;
let std_i = (indices.iter().map(|i| (i - mean_i).powi(2)).sum::<f64>() / nf).sqrt();
let std_v = (vols.iter().map(|v| (v - mean_v).powi(2)).sum::<f64>() / nf).sqrt();
if std_i == 0.0 || std_v == 0.0 {
return None;
}
Some(cov / (std_i * std_v))
}
pub fn high_close_spread(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.is_empty() {
return None;
}
let total: f64 = bars.iter().filter_map(|b| (b.high - b.close).to_f64()).sum();
Some(total / bars.len() as f64)
}
pub fn open_range(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.is_empty() {
return None;
}
let total: f64 = bars.iter().filter_map(|b| (b.close - b.open).abs().to_f64()).sum();
Some(total / bars.len() as f64)
}
pub fn normalized_close(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let min = Self::min_close(bars)?;
let max = Self::max_close(bars)?;
let range = max - min;
if range.is_zero() {
return None;
}
let last = bars.last()?.close;
((last - min) / range).to_f64()
}
pub fn price_channel_position(bars: &[OhlcvBar]) -> Option<f64> {
Self::price_position(bars)
}
pub fn candle_score(bars: &[OhlcvBar]) -> Option<f64> {
if bars.is_empty() {
return None;
}
let strong = bars.iter().filter(|b| {
b.is_bullish()
&& !b.range().is_zero()
&& b.body() * Decimal::TWO > b.range()
&& b.close_above_midpoint()
}).count();
Some(strong as f64 / bars.len() as f64)
}
pub fn bar_speed(bars: &[OhlcvBar]) -> Option<f64> {
if bars.is_empty() {
return None;
}
let total_ticks: u64 = bars.iter().map(|b| b.trade_count).sum();
let total_ms: u64 = bars.iter().map(|b| b.bar_duration_ms()).sum();
if total_ms == 0 {
return None;
}
Some(total_ticks as f64 / total_ms as f64)
}
pub fn higher_highs_count(bars: &[OhlcvBar]) -> usize {
if bars.len() < 2 {
return 0;
}
bars.windows(2).filter(|w| w[1].high > w[0].high).count()
}
pub fn lower_lows_count(bars: &[OhlcvBar]) -> usize {
if bars.len() < 2 {
return 0;
}
bars.windows(2).filter(|w| w[1].low < w[0].low).count()
}
pub fn close_minus_open_pct(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let values: Vec<f64> = bars.iter().filter_map(|b| {
if b.open.is_zero() { return None; }
((b.close - b.open) / b.open * Decimal::ONE_HUNDRED).to_f64()
}).collect();
if values.is_empty() {
return None;
}
Some(values.iter().sum::<f64>() / values.len() as f64)
}
pub fn volume_per_range(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let values: Vec<f64> = bars.iter().filter_map(|b| {
let r = b.range();
if r.is_zero() { return None; }
(b.volume / r).to_f64()
}).collect();
if values.is_empty() {
return None;
}
Some(values.iter().sum::<f64>() / values.len() as f64)
}
pub fn body_fraction(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let fracs: Vec<f64> = bars.iter().filter_map(|b| {
let range = b.range();
if range.is_zero() { return None; }
let body = (b.close - b.open).abs();
(body / range).to_f64()
}).collect();
if fracs.is_empty() {
return None;
}
Some(fracs.iter().sum::<f64>() / fracs.len() as f64)
}
pub fn bullish_ratio(bars: &[OhlcvBar]) -> Option<f64> {
if bars.is_empty() {
return None;
}
let bullish = bars.iter().filter(|b| b.close > b.open).count();
Some(bullish as f64 / bars.len() as f64)
}
pub fn peak_close(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| b.close).reduce(Decimal::max)
}
pub fn trough_close(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| b.close).reduce(Decimal::min)
}
pub fn up_volume_fraction(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.is_empty() {
return None;
}
let total: Decimal = bars.iter().map(|b| b.volume).sum();
if total.is_zero() {
return None;
}
let up_vol: Decimal = bars.iter()
.filter(|b| b.close > b.open)
.map(|b| b.volume)
.sum();
(up_vol / total).to_f64()
}
pub fn tail_upper_fraction(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let fracs: Vec<f64> = bars.iter().filter_map(|b| {
let range = b.range();
if range.is_zero() { return None; }
let body_top = b.open.max(b.close);
let upper_wick = b.high - body_top;
(upper_wick / range).to_f64()
}).collect();
if fracs.is_empty() {
return None;
}
Some(fracs.iter().sum::<f64>() / fracs.len() as f64)
}
pub fn tail_lower_fraction(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let fracs: Vec<f64> = bars.iter().filter_map(|b| {
let range = b.range();
if range.is_zero() { return None; }
let body_bot = b.open.min(b.close);
let lower_wick = body_bot - b.low;
(lower_wick / range).to_f64()
}).collect();
if fracs.is_empty() {
return None;
}
Some(fracs.iter().sum::<f64>() / fracs.len() as f64)
}
pub fn range_std_dev(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.len() < 2 {
return None;
}
let vals: Vec<f64> = bars.iter().filter_map(|b| b.range().to_f64()).collect();
if vals.len() < 2 {
return None;
}
let n = vals.len() as f64;
let mean = vals.iter().sum::<f64>() / n;
let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
Some(variance.sqrt())
}
pub fn close_to_range_position(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter_map(|b| {
let r = b.range();
if r.is_zero() {
return None;
}
((b.close - b.low) / r).to_f64()
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn volume_oscillator(bars: &[OhlcvBar], short_n: usize, long_n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if short_n == 0 || long_n == 0 || short_n >= long_n || bars.len() < long_n {
return None;
}
let recent = &bars[bars.len() - short_n..];
let long_slice = &bars[bars.len() - long_n..];
let short_avg: f64 =
recent.iter().filter_map(|b| b.volume.to_f64()).sum::<f64>() / short_n as f64;
let long_sum: Vec<f64> = long_slice.iter().filter_map(|b| b.volume.to_f64()).collect();
if long_sum.is_empty() {
return None;
}
let long_avg = long_sum.iter().sum::<f64>() / long_sum.len() as f64;
if long_avg == 0.0 {
return None;
}
Some((short_avg - long_avg) / long_avg)
}
pub fn direction_reversal_count(bars: &[OhlcvBar]) -> usize {
if bars.len() < 2 {
return 0;
}
let mut count = 0usize;
let mut prev_bullish: Option<bool> = None;
for b in bars {
let bullish = b.close > b.open;
if let Some(pb) = prev_bullish {
if bullish != pb {
count += 1;
}
}
prev_bullish = Some(bullish);
}
count
}
pub fn upper_wick_dominance_fraction(bars: &[OhlcvBar]) -> Option<f64> {
if bars.is_empty() {
return None;
}
let count = bars.iter().filter(|b| b.wick_upper() > b.wick_lower()).count();
Some(count as f64 / bars.len() as f64)
}
pub fn avg_open_to_high_ratio(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter_map(|b| {
let r = b.range();
if r.is_zero() {
return None;
}
((b.high - b.open) / r).to_f64()
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn volume_weighted_range(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.is_empty() {
return None;
}
let mut numerator = 0f64;
let mut denom = 0f64;
for b in bars {
let r = b.range().to_f64()?;
let v = b.volume.to_f64()?;
numerator += r * v;
denom += v;
}
if denom == 0.0 {
return None;
}
Some(numerator / denom)
}
pub fn bar_strength_index(bars: &[OhlcvBar]) -> Option<f64> {
let vals: Vec<f64> =
bars.iter().filter_map(|b| b.close_location_value()).collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn shadow_to_body_ratio(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.is_empty() {
return None;
}
let total_wick: Decimal = bars.iter().map(|b| b.wick_upper() + b.wick_lower()).sum();
let total_body: Decimal = bars.iter().map(|b| b.body()).sum();
if total_body.is_zero() {
return None;
}
(total_wick / total_body).to_f64()
}
pub fn first_last_close_pct(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let first = bars.first()?;
let last = bars.last()?;
if first.close.is_zero() {
return None;
}
((last.close - first.close) / first.close * Decimal::ONE_HUNDRED).to_f64()
}
pub fn open_to_close_volatility(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.len() < 2 {
return None;
}
let returns: Vec<f64> = bars
.iter()
.filter_map(|b| {
if b.open.is_zero() {
return None;
}
((b.close - b.open) / b.open).to_f64()
})
.collect();
if returns.len() < 2 {
return None;
}
let n = returns.len() as f64;
let mean = returns.iter().sum::<f64>() / n;
let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
Some(variance.sqrt())
}
pub fn close_recovery_ratio(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter_map(|b| {
let r = b.range();
if r.is_zero() {
return None;
}
((b.close - b.low) / r).to_f64()
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn median_range(bars: &[OhlcvBar]) -> Option<Decimal> {
if bars.is_empty() {
return None;
}
let mut ranges: Vec<Decimal> = bars.iter().map(|b| b.range()).collect();
ranges.sort();
let n = ranges.len();
if n % 2 == 1 {
Some(ranges[n / 2])
} else {
Some((ranges[n / 2 - 1] + ranges[n / 2]) / Decimal::from(2u64))
}
}
pub fn mean_typical_price(bars: &[OhlcvBar]) -> Option<Decimal> {
if bars.is_empty() {
return None;
}
let sum: Decimal = bars.iter().map(|b| b.typical_price()).sum();
Some(sum / Decimal::from(bars.len() as u64))
}
pub fn directional_volume_ratio(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let bull = Self::bullish_volume(bars);
let bear = Self::bearish_volume(bars);
let total = bull + bear;
if total.is_zero() {
return None;
}
(bull / total).to_f64()
}
pub fn inside_bar_fraction(bars: &[OhlcvBar]) -> Option<f64> {
if bars.len() < 2 {
return None;
}
let inside = bars.windows(2).filter(|w| w[1].is_inside_bar(&w[0])).count();
Some(inside as f64 / (bars.len() - 1) as f64)
}
pub fn body_momentum(bars: &[OhlcvBar]) -> Decimal {
bars.iter()
.map(|b| b.close - b.open)
.sum()
}
pub fn avg_trade_count(bars: &[OhlcvBar]) -> Option<f64> {
if bars.is_empty() {
return None;
}
let total: u64 = bars.iter().map(|b| b.trade_count).sum();
Some(total as f64 / bars.len() as f64)
}
pub fn max_trade_count(bars: &[OhlcvBar]) -> Option<u64> {
bars.iter().map(|b| b.trade_count).max()
}
pub fn close_to_high_std(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.len() < 2 {
return None;
}
let vals: Vec<f64> = bars.iter().filter_map(|b| (b.high - b.close).to_f64()).collect();
if vals.len() < 2 {
return None;
}
let n = vals.len() as f64;
let mean = vals.iter().sum::<f64>() / n;
let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
Some(variance.sqrt())
}
pub fn avg_open_volume_ratio(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter_map(|b| {
if b.open.is_zero() {
return None;
}
(b.volume / b.open).to_f64()
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn typical_price_std(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.len() < 2 {
return None;
}
let vals: Vec<f64> = bars.iter().filter_map(|b| b.typical_price().to_f64()).collect();
if vals.len() < 2 {
return None;
}
let n = vals.len() as f64;
let mean = vals.iter().sum::<f64>() / n;
let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
Some(variance.sqrt())
}
pub fn vwap_deviation_avg(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vwaps: Vec<f64> = bars
.iter()
.filter_map(|b| b.vwap?.to_f64())
.collect();
if vwaps.is_empty() {
return None;
}
let mean = vwaps.iter().sum::<f64>() / vwaps.len() as f64;
let mad = vwaps.iter().map(|v| (v - mean).abs()).sum::<f64>() / vwaps.len() as f64;
Some(mad)
}
pub fn avg_high_low_ratio(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter_map(|b| {
if b.low.is_zero() {
return None;
}
(b.high / b.low).to_f64()
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn gap_fill_fraction(bars: &[OhlcvBar]) -> Option<f64> {
if bars.is_empty() {
return None;
}
let gap_bars = bars.iter().filter(|b| b.is_gap_fill).count();
Some(gap_bars as f64 / bars.len() as f64)
}
pub fn complete_bar_count(bars: &[OhlcvBar]) -> usize {
bars.iter().filter(|b| b.is_complete).count()
}
pub fn min_trade_count(bars: &[OhlcvBar]) -> Option<u64> {
bars.iter().map(|b| b.trade_count).min()
}
pub fn avg_bar_range(bars: &[OhlcvBar]) -> Option<Decimal> {
if bars.is_empty() {
return None;
}
let sum: Decimal = bars.iter().map(|b| b.high - b.low).sum();
Some(sum / Decimal::from(bars.len()))
}
pub fn max_up_move(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| (b.close - b.open).max(Decimal::ZERO)).max()
}
pub fn max_down_move(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| (b.open - b.close).max(Decimal::ZERO)).max()
}
pub fn avg_close_position(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter_map(|b| {
let range = b.high - b.low;
if range.is_zero() {
return None;
}
let pos = (b.close - b.low).to_f64()? / range.to_f64()?;
Some(pos)
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn volume_std(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.len() < 2 {
return None;
}
let vols: Vec<f64> = bars.iter().filter_map(|b| b.volume.to_f64()).collect();
let n = vols.len() as f64;
if n < 2.0 {
return None;
}
let mean = vols.iter().sum::<f64>() / n;
let var = vols.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
Some(var.sqrt())
}
pub fn avg_wick_ratio(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter_map(|b| {
let range = b.high - b.low;
if range.is_zero() {
return None;
}
let upper = b.high - b.close.max(b.open);
let lower = b.close.min(b.open) - b.low;
let wick = upper + lower;
let ratio = wick.to_f64()? / range.to_f64()?;
Some(ratio)
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn open_gap_mean(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.len() < 2 {
return None;
}
let vals: Vec<f64> = bars
.windows(2)
.filter_map(|w| {
let prev_close = w[0].close;
if prev_close.is_zero() {
return None;
}
let gap = (w[1].open - prev_close).abs().to_f64()? / prev_close.to_f64()?;
Some(gap)
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn net_directional_move(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.is_empty() {
return None;
}
let first_open = bars.first()?.open;
let last_close = bars.last()?.close;
if first_open.is_zero() {
return None;
}
let pct = (last_close - first_open).to_f64()? / first_open.to_f64()?;
Some(pct)
}
pub fn close_above_median_fraction(bars: &[OhlcvBar]) -> Option<f64> {
if bars.is_empty() {
return None;
}
let above = bars.iter().filter(|b| b.close > b.high_low_midpoint()).count();
Some(above as f64 / bars.len() as f64)
}
pub fn avg_range_to_open(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter_map(|b| {
if b.open.is_zero() { return None; }
((b.high - b.low) / b.open).to_f64()
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn close_sum(bars: &[OhlcvBar]) -> Decimal {
bars.iter().map(|b| b.close).sum()
}
pub fn above_avg_volume_count(bars: &[OhlcvBar]) -> usize {
let avg = Self::mean_volume(bars).unwrap_or(Decimal::ZERO);
if avg.is_zero() {
return 0;
}
bars.iter().filter(|b| b.volume > avg).count()
}
pub fn median_close(bars: &[OhlcvBar]) -> Option<Decimal> {
if bars.is_empty() {
return None;
}
let mut closes: Vec<Decimal> = bars.iter().map(|b| b.close).collect();
closes.sort();
let n = closes.len();
if n % 2 == 1 {
Some(closes[n / 2])
} else {
Some((closes[n / 2 - 1] + closes[n / 2]) / Decimal::from(2u64))
}
}
pub fn flat_bar_fraction(bars: &[OhlcvBar]) -> Option<f64> {
if bars.is_empty() {
return None;
}
let flat = bars.iter().filter(|b| b.open == b.close).count();
Some(flat as f64 / bars.len() as f64)
}
pub fn avg_body_to_range(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter_map(|b| {
let r = b.range();
if r.is_zero() { return None; }
(b.body() / r).to_f64()
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn max_open_gap(bars: &[OhlcvBar]) -> Option<Decimal> {
if bars.len() < 2 {
return None;
}
bars.windows(2)
.map(|w| (w[1].open - w[0].close).abs())
.max()
}
pub fn volume_trend_slope(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let n = bars.len();
if n < 2 {
return None;
}
let n_f = n as f64;
let x_mean = (n_f - 1.0) / 2.0;
let y: Vec<f64> = bars.iter().filter_map(|b| b.volume.to_f64()).collect();
if y.len() < 2 {
return None;
}
let y_mean = y.iter().sum::<f64>() / y.len() as f64;
let num: f64 = y.iter().enumerate().map(|(i, &v)| (i as f64 - x_mean) * (v - y_mean)).sum();
let den: f64 = (0..n).map(|i| (i as f64 - x_mean).powi(2)).sum();
if den == 0.0 { None } else { Some(num / den) }
}
pub fn up_close_fraction(bars: &[OhlcvBar]) -> Option<f64> {
if bars.len() < 2 {
return None;
}
let up = bars.windows(2).filter(|w| w[1].close > w[0].close).count();
Some(up as f64 / (bars.len() - 1) as f64)
}
pub fn avg_upper_shadow_ratio(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter_map(|b| {
let r = b.range();
if r.is_zero() { return None; }
(b.upper_shadow() / r).to_f64()
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn avg_lower_shadow_ratio(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter_map(|b| {
let r = b.range();
if r.is_zero() { return None; }
(b.lower_shadow() / r).to_f64()
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn close_to_open_range_ratio(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter_map(|b| {
let r = b.range();
if r.is_zero() { return None; }
((b.close - b.open) / r).to_f64()
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn max_high(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| b.high).max()
}
pub fn min_low(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| b.low).min()
}
pub fn avg_bar_efficiency(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter_map(|b| {
let r = b.range();
if r.is_zero() { return None; }
((b.close - b.open).abs() / r).to_f64()
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn open_range_fraction(bars: &[OhlcvBar]) -> Option<f64> {
if bars.is_empty() {
return None;
}
let count = bars
.iter()
.filter(|b| {
let mid = (b.high + b.low) / Decimal::from(2);
b.open >= mid
})
.count();
Some(count as f64 / bars.len() as f64)
}
pub fn close_skewness(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.len() < 3 {
return None;
}
let vals: Vec<f64> = bars.iter().filter_map(|b| b.close.to_f64()).collect();
let n = vals.len() as f64;
let mean = vals.iter().sum::<f64>() / n;
let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
let std = var.sqrt();
if std < 1e-12 {
return None;
}
let skew = vals.iter().map(|v| ((v - mean) / std).powi(3)).sum::<f64>() / n;
Some(skew)
}
pub fn volume_above_median_fraction(bars: &[OhlcvBar]) -> Option<f64> {
if bars.is_empty() {
return None;
}
let mut vols: Vec<Decimal> = bars.iter().map(|b| b.volume).collect();
vols.sort();
let mid = vols.len() / 2;
let median = if vols.len() % 2 == 0 {
(vols[mid - 1] + vols[mid]) / Decimal::from(2)
} else {
vols[mid]
};
let count = bars.iter().filter(|b| b.volume > median).count();
Some(count as f64 / bars.len() as f64)
}
pub fn typical_price_sum(bars: &[OhlcvBar]) -> Decimal {
bars.iter()
.map(|b| (b.high + b.low + b.close) / Decimal::from(3))
.sum()
}
pub fn max_body_size(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| (b.close - b.open).abs()).max()
}
pub fn min_body_size(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| (b.close - b.open).abs()).min()
}
pub fn avg_lower_wick_to_range(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter_map(|b| {
let range = b.high - b.low;
if range.is_zero() {
return None;
}
let lower_wick = b.open.min(b.close) - b.low;
(lower_wick / range).to_f64()
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn total_range(bars: &[OhlcvBar]) -> Decimal {
bars.iter().map(|b| b.high - b.low).sum()
}
pub fn close_at_high_fraction(bars: &[OhlcvBar]) -> Option<f64> {
if bars.is_empty() {
return None;
}
let count = bars.iter().filter(|b| b.close == b.high).count();
Some(count as f64 / bars.len() as f64)
}
pub fn close_at_low_fraction(bars: &[OhlcvBar]) -> Option<f64> {
if bars.is_empty() {
return None;
}
let count = bars.iter().filter(|b| b.close == b.low).count();
Some(count as f64 / bars.len() as f64)
}
pub fn avg_high_above_open_ratio(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter_map(|b| {
let range = b.high - b.low;
if range.is_zero() {
return None;
}
((b.high - b.open) / range).to_f64()
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn continuation_bar_count(bars: &[OhlcvBar]) -> usize {
if bars.len() < 2 {
return 0;
}
bars.windows(2)
.filter(|w| w[1].open == w[0].close)
.count()
}
pub fn down_close_volume(bars: &[OhlcvBar]) -> Decimal {
bars.iter()
.filter(|b| b.close < b.open)
.map(|b| b.volume)
.sum()
}
pub fn up_close_volume(bars: &[OhlcvBar]) -> Decimal {
bars.iter()
.filter(|b| b.close > b.open)
.map(|b| b.volume)
.sum()
}
pub fn mean_open(bars: &[OhlcvBar]) -> Option<Decimal> {
if bars.is_empty() {
return None;
}
let sum: Decimal = bars.iter().map(|b| b.open).sum();
Some(sum / Decimal::from(bars.len() as i64))
}
pub fn new_high_count(bars: &[OhlcvBar]) -> usize {
if bars.is_empty() {
return 0;
}
let mut running_max = bars[0].high;
let mut count = 0usize;
for b in bars.iter().skip(1) {
if b.high > running_max {
count += 1;
running_max = b.high;
}
}
count
}
pub fn new_low_count(bars: &[OhlcvBar]) -> usize {
if bars.is_empty() {
return 0;
}
let mut running_min = bars[0].low;
let mut count = 0usize;
for b in bars.iter().skip(1) {
if b.low < running_min {
count += 1;
running_min = b.low;
}
}
count
}
pub fn close_std(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.len() < 2 {
return None;
}
let vals: Vec<f64> = bars.iter().filter_map(|b| b.close.to_f64()).collect();
let n = vals.len() as f64;
let mean = vals.iter().sum::<f64>() / n;
let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
Some(var.sqrt())
}
pub fn zero_volume_fraction(bars: &[OhlcvBar]) -> Option<f64> {
if bars.is_empty() {
return None;
}
let count = bars.iter().filter(|b| b.volume.is_zero()).count();
Some(count as f64 / bars.len() as f64)
}
pub fn avg_open_to_close(bars: &[OhlcvBar]) -> Option<Decimal> {
if bars.is_empty() {
return None;
}
let sum: Decimal = bars.iter().map(|b| b.close - b.open).sum();
Some(sum / Decimal::from(bars.len() as i64))
}
pub fn max_bar_volume(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| b.volume).max()
}
pub fn min_bar_volume(bars: &[OhlcvBar]) -> Option<Decimal> {
bars.iter().map(|b| b.volume).min()
}
pub fn body_to_range_std(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let ratios: Vec<f64> = bars
.iter()
.filter(|b| b.high > b.low)
.filter_map(|b| {
let body = (b.close - b.open).abs();
let range = b.high - b.low;
(body / range).to_f64()
})
.collect();
if ratios.len() < 2 {
return None;
}
let n = ratios.len() as f64;
let mean = ratios.iter().sum::<f64>() / n;
let var = ratios.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
Some(var.sqrt())
}
pub fn avg_wick_symmetry(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let ratios: Vec<f64> = bars
.iter()
.filter_map(|b| {
let upper = b.high - b.close.max(b.open);
let lower = b.close.min(b.open) - b.low;
if upper.is_zero() && lower.is_zero() {
return None;
}
let lo = upper.min(lower);
let hi = upper.max(lower);
if hi.is_zero() {
return None;
}
(lo / hi).to_f64()
})
.collect();
if ratios.is_empty() {
return None;
}
Some(ratios.iter().sum::<f64>() / ratios.len() as f64)
}
pub fn avg_range_pct_of_open(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter_map(|b| {
if b.open.is_zero() { return None; }
((b.high - b.low) / b.open).to_f64()
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn high_volume_fraction(bars: &[OhlcvBar]) -> Option<f64> {
if bars.is_empty() {
return None;
}
let max_vol = bars.iter().map(|b| b.volume).max()?;
let min_vol = bars.iter().map(|b| b.volume).min()?;
let mid = (max_vol + min_vol) / Decimal::from(2);
if max_vol == min_vol {
return None;
}
let count = bars.iter().filter(|b| b.volume > mid).count();
Some(count as f64 / bars.len() as f64)
}
pub fn close_cluster_count(bars: &[OhlcvBar]) -> usize {
use rust_decimal::prelude::ToPrimitive;
if bars.len() < 2 {
return 0;
}
bars.windows(2)
.filter(|w| {
if w[0].close.is_zero() {
return false;
}
let pct_diff = ((w[1].close - w[0].close) / w[0].close).abs();
pct_diff <= rust_decimal::Decimal::new(1, 2)
})
.count()
}
pub fn mean_vwap(bars: &[OhlcvBar]) -> Option<Decimal> {
let vals: Vec<Decimal> = bars.iter().filter_map(|b| b.vwap).collect();
if vals.is_empty() {
return None;
}
let sum: Decimal = vals.iter().copied().sum();
Some(sum / Decimal::from(vals.len() as i64))
}
pub fn complete_fraction(bars: &[OhlcvBar]) -> Option<f64> {
if bars.is_empty() {
return None;
}
let count = bars.iter().filter(|b| b.is_complete).count();
Some(count as f64 / bars.len() as f64)
}
pub fn total_body_movement(bars: &[OhlcvBar]) -> Decimal {
bars.iter().map(|b| (b.close - b.open).abs()).sum()
}
pub fn open_std(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if bars.len() < 2 {
return None;
}
let vals: Vec<f64> = bars.iter().filter_map(|b| b.open.to_f64()).collect();
let n = vals.len() as f64;
let mean = vals.iter().sum::<f64>() / n;
let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
Some(var.sqrt())
}
pub fn mean_high_low_ratio(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter(|b| !b.low.is_zero())
.filter_map(|b| (b.high / b.low).to_f64())
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn max_consecutive_up_bars(bars: &[OhlcvBar]) -> usize {
let mut max_run = 0usize;
let mut run = 0usize;
for b in bars {
if b.close > b.open {
run += 1;
if run > max_run {
max_run = run;
}
} else {
run = 0;
}
}
max_run
}
pub fn avg_upper_shadow_fraction(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter(|b| b.high > b.low)
.filter_map(|b| {
let range = b.high - b.low;
let upper = b.high - b.close.max(b.open);
(upper / range).to_f64()
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn up_down_bar_ratio(bars: &[OhlcvBar]) -> Option<f64> {
let ups = bars.iter().filter(|b| b.close > b.open).count();
let downs = bars.iter().filter(|b| b.close < b.open).count();
if downs == 0 {
return None;
}
Some(ups as f64 / downs as f64)
}
pub fn close_range_fraction(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let half = Decimal::new(5, 1);
let vals: Vec<f64> = bars
.iter()
.filter(|b| b.high > b.low)
.filter_map(|b| {
let r = (b.close - b.low) / (b.high - b.low);
r.to_f64()
})
.collect();
if vals.is_empty() {
return None;
}
let _ = half;
let count = vals.iter().filter(|&&v| v > 0.5).count();
Some(count as f64 / vals.len() as f64)
}
pub fn tail_symmetry(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter(|b| b.high > b.low)
.filter_map(|b| {
let range = (b.high - b.low).to_f64()?;
let upper = (b.high - b.close.max(b.open)).to_f64()?;
let lower = (b.close.min(b.open) - b.low).to_f64()?;
Some(1.0 - (upper - lower).abs() / range)
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn bar_trend_strength(bars: &[OhlcvBar]) -> Option<f64> {
if bars.len() < 2 {
return None;
}
let up_count = bars.windows(2).filter(|w| w[1].close > w[0].close).count();
Some(up_count as f64 / (bars.len() - 1) as f64)
}
pub fn gap_up_count(bars: &[OhlcvBar]) -> usize {
if bars.len() < 2 {
return 0;
}
bars.windows(2).filter(|w| w[1].open > w[0].close).count()
}
pub fn gap_down_count(bars: &[OhlcvBar]) -> usize {
if bars.len() < 2 {
return 0;
}
bars.windows(2).filter(|w| w[1].open < w[0].close).count()
}
pub fn mean_bar_efficiency(bars: &[OhlcvBar]) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let vals: Vec<f64> = bars
.iter()
.filter(|b| b.high > b.low)
.filter_map(|b| {
let body = (b.close - b.open).abs();
let range = b.high - b.low;
(body / range).to_f64()
})
.collect();
if vals.is_empty() {
return None;
}
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
}
impl std::fmt::Display for OhlcvBar {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} {} [{}/{}/{}/{} v={}]",
self.symbol, self.timeframe, self.open, self.high, self.low, self.close, self.volume
)
}
}
pub struct OhlcvAggregator {
symbol: String,
timeframe: Timeframe,
current_bar: Option<OhlcvBar>,
last_bar: Option<OhlcvBar>,
emit_empty_bars: bool,
bars_emitted: u64,
price_volume_sum: Decimal,
total_volume: Decimal,
peak_volume: Option<Decimal>,
min_volume: Option<Decimal>,
}
impl OhlcvAggregator {
pub fn new(symbol: impl Into<String>, timeframe: Timeframe) -> Result<Self, StreamError> {
let tf_dur = timeframe.duration_ms();
if tf_dur == 0 {
return Err(StreamError::ConfigError {
reason: "OhlcvAggregator timeframe duration must be > 0".into(),
});
}
Ok(Self {
symbol: symbol.into(),
timeframe,
current_bar: None,
last_bar: None,
emit_empty_bars: false,
bars_emitted: 0,
price_volume_sum: Decimal::ZERO,
total_volume: Decimal::ZERO,
peak_volume: None,
min_volume: None,
})
}
pub fn with_emit_empty_bars(mut self, enabled: bool) -> Self {
self.emit_empty_bars = enabled;
self
}
#[must_use = "completed bars are returned; ignoring them loses bar data"]
#[inline]
pub fn feed(&mut self, tick: &NormalizedTick) -> Result<Vec<OhlcvBar>, StreamError> {
if tick.symbol != self.symbol {
return Err(StreamError::AggregationError {
reason: format!(
"tick symbol '{}' does not match aggregator '{}'",
tick.symbol, self.symbol
),
});
}
let tick_ts = tick.exchange_ts_ms.unwrap_or(tick.received_at_ms);
let bar_start = self.timeframe.bar_start_ms(tick_ts);
let mut emitted: Vec<OhlcvBar> = Vec::new();
let bar_window_changed = self
.current_bar
.as_ref()
.map_or(false, |b| b.bar_start_ms != bar_start);
if bar_window_changed {
let mut completed = self.current_bar.take().unwrap_or_else(|| unreachable!());
completed.is_complete = true;
let prev_close = completed.close;
let prev_start = completed.bar_start_ms;
emitted.push(completed);
if self.emit_empty_bars {
let dur = self.timeframe.duration_ms();
let mut gap_start = prev_start + dur;
while gap_start < bar_start {
emitted.push(OhlcvBar {
symbol: self.symbol.clone(),
timeframe: self.timeframe,
bar_start_ms: gap_start,
open: prev_close,
high: prev_close,
low: prev_close,
close: prev_close,
volume: Decimal::ZERO,
trade_count: 0,
is_complete: true,
is_gap_fill: true,
vwap: None,
});
gap_start += dur;
}
}
}
let tick_value = tick.value();
if self.current_bar.is_some() {
self.price_volume_sum += tick_value;
} else {
self.price_volume_sum = tick_value;
}
match &mut self.current_bar {
Some(bar) => {
if tick.price > bar.high {
bar.high = tick.price;
}
if tick.price < bar.low {
bar.low = tick.price;
}
bar.close = tick.price;
bar.volume += tick.quantity;
bar.trade_count += 1;
bar.vwap = if bar.volume.is_zero() {
None
} else {
Some(self.price_volume_sum / bar.volume)
};
}
None => {
self.current_bar = Some(OhlcvBar {
symbol: self.symbol.clone(),
timeframe: self.timeframe,
bar_start_ms: bar_start,
open: tick.price,
high: tick.price,
low: tick.price,
close: tick.price,
volume: tick.quantity,
trade_count: 1,
is_complete: false,
is_gap_fill: false,
vwap: Some(tick.price), });
}
}
self.bars_emitted += emitted.len() as u64;
for b in &emitted {
self.total_volume += b.volume;
self.peak_volume = Some(match self.peak_volume {
Some(prev) => prev.max(b.volume),
None => b.volume,
});
self.min_volume = Some(match self.min_volume {
Some(prev) => prev.min(b.volume),
None => b.volume,
});
}
if let Some(b) = emitted.last() {
self.last_bar = Some(b.clone());
}
Ok(emitted)
}
pub fn current_bar(&self) -> Option<&OhlcvBar> {
self.current_bar.as_ref()
}
#[must_use = "the flushed bar is returned; ignoring it loses the partial bar"]
pub fn flush(&mut self) -> Option<OhlcvBar> {
let mut bar = self.current_bar.take()?;
bar.is_complete = true;
self.bars_emitted += 1;
self.total_volume += bar.volume;
self.peak_volume = Some(match self.peak_volume {
Some(prev) => prev.max(bar.volume),
None => bar.volume,
});
self.min_volume = Some(match self.min_volume {
Some(prev) => prev.min(bar.volume),
None => bar.volume,
});
self.last_bar = Some(bar.clone());
Some(bar)
}
pub fn last_bar(&self) -> Option<&OhlcvBar> {
self.last_bar.as_ref()
}
pub fn bar_count(&self) -> u64 {
self.bars_emitted
}
pub fn reset(&mut self) {
self.current_bar = None;
self.last_bar = None;
self.bars_emitted = 0;
self.price_volume_sum = Decimal::ZERO;
self.total_volume = Decimal::ZERO;
self.peak_volume = None;
self.min_volume = None;
}
pub fn total_volume(&self) -> Decimal {
self.total_volume
}
pub fn peak_volume(&self) -> Option<Decimal> {
self.peak_volume
}
pub fn min_volume(&self) -> Option<Decimal> {
self.min_volume
}
pub fn volume_range(&self) -> Option<(Decimal, Decimal)> {
Some((self.min_volume?, self.peak_volume?))
}
pub fn average_volume(&self) -> Option<Decimal> {
if self.bars_emitted == 0 {
return None;
}
Some(self.total_volume / Decimal::from(self.bars_emitted))
}
pub fn symbol(&self) -> &str {
&self.symbol
}
pub fn timeframe(&self) -> Timeframe {
self.timeframe
}
pub fn window_progress(&self, now_ms: u64) -> Option<f64> {
let bar = self.current_bar.as_ref()?;
let elapsed = now_ms.saturating_sub(bar.bar_start_ms);
let duration = self.timeframe.duration_ms();
let progress = elapsed as f64 / duration as f64;
Some(progress.clamp(0.0, 1.0))
}
pub fn is_active(&self) -> bool {
self.current_bar.is_some()
}
pub fn vwap_current(&self) -> Option<Decimal> {
let bar = self.current_bar.as_ref()?;
if bar.volume.is_zero() {
return None;
}
Some(self.price_volume_sum / bar.volume)
}
}
#[cfg(test)]
#[allow(deprecated)]
mod tests {
use super::*;
use crate::tick::{Exchange, NormalizedTick, TradeSide};
use rust_decimal_macros::dec;
fn make_tick(symbol: &str, price: Decimal, qty: Decimal, ts_ms: u64) -> NormalizedTick {
NormalizedTick {
exchange: Exchange::Binance,
symbol: symbol.to_string(),
price,
quantity: qty,
side: Some(TradeSide::Buy),
trade_id: None,
exchange_ts_ms: None,
received_at_ms: ts_ms,
}
}
fn make_tick_with_exchange_ts(
symbol: &str,
price: Decimal,
qty: Decimal,
exchange_ts_ms: u64,
received_at_ms: u64,
) -> NormalizedTick {
NormalizedTick {
exchange: Exchange::Binance,
symbol: symbol.to_string(),
price,
quantity: qty,
side: Some(TradeSide::Buy),
trade_id: None,
exchange_ts_ms: Some(exchange_ts_ms),
received_at_ms,
}
}
fn agg(symbol: &str, tf: Timeframe) -> OhlcvAggregator {
OhlcvAggregator::new(symbol, tf).unwrap()
}
#[test]
fn test_timeframe_seconds_duration_ms() {
assert_eq!(Timeframe::Seconds(30).duration_ms(), 30_000);
}
#[test]
fn test_timeframe_minutes_duration_ms() {
assert_eq!(Timeframe::Minutes(5).duration_ms(), 300_000);
}
#[test]
fn test_timeframe_hours_duration_ms() {
assert_eq!(Timeframe::Hours(1).duration_ms(), 3_600_000);
}
#[test]
fn test_timeframe_bar_start_ms_aligns() {
let tf = Timeframe::Minutes(1);
let ts = 61_500; assert_eq!(tf.bar_start_ms(ts), 60_000);
}
#[test]
fn test_timeframe_display() {
assert_eq!(Timeframe::Seconds(30).to_string(), "30s");
assert_eq!(Timeframe::Minutes(5).to_string(), "5m");
assert_eq!(Timeframe::Hours(4).to_string(), "4h");
}
#[test]
fn test_timeframe_ord_seconds_lt_minutes() {
assert!(Timeframe::Seconds(30) < Timeframe::Minutes(1));
}
#[test]
fn test_timeframe_ord_minutes_lt_hours() {
assert!(Timeframe::Minutes(59) < Timeframe::Hours(1));
}
#[test]
fn test_timeframe_ord_same_duration_equal() {
assert_eq!(Timeframe::Seconds(60), Timeframe::Seconds(60));
assert_eq!(
Timeframe::Seconds(3600).cmp(&Timeframe::Hours(1)),
std::cmp::Ordering::Equal
);
}
#[test]
fn test_timeframe_ord_sort() {
let mut tfs = vec![
Timeframe::Hours(1),
Timeframe::Seconds(30),
Timeframe::Minutes(5),
];
tfs.sort();
assert_eq!(tfs[0], Timeframe::Seconds(30));
assert_eq!(tfs[1], Timeframe::Minutes(5));
assert_eq!(tfs[2], Timeframe::Hours(1));
}
#[test]
fn test_ohlcv_aggregator_first_tick_sets_ohlcv() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
let tick = make_tick("BTC-USD", dec!(50000), dec!(1), 60_000);
let result = agg.feed(&tick).unwrap();
assert!(result.is_empty()); let bar = agg.current_bar().unwrap();
assert_eq!(bar.open, dec!(50000));
assert_eq!(bar.high, dec!(50000));
assert_eq!(bar.low, dec!(50000));
assert_eq!(bar.close, dec!(50000));
assert_eq!(bar.volume, dec!(1));
assert_eq!(bar.trade_count, 1);
}
#[test]
fn test_ohlcv_aggregator_high_low_tracking() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
.unwrap();
agg.feed(&make_tick("BTC-USD", dec!(49500), dec!(1), 60_200))
.unwrap();
let bar = agg.current_bar().unwrap();
assert_eq!(bar.high, dec!(51000));
assert_eq!(bar.low, dec!(49500));
assert_eq!(bar.close, dec!(49500));
assert_eq!(bar.trade_count, 3);
}
#[test]
fn test_ohlcv_aggregator_bar_completes_on_new_window() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(2), 60_500))
.unwrap();
let mut bars = agg
.feed(&make_tick("BTC-USD", dec!(50200), dec!(1), 120_000))
.unwrap();
assert_eq!(bars.len(), 1);
let bar = bars.remove(0);
assert!(bar.is_complete);
assert_eq!(bar.open, dec!(50000));
assert_eq!(bar.close, dec!(50100));
assert_eq!(bar.volume, dec!(3));
assert_eq!(bar.bar_start_ms, 60_000);
}
#[test]
fn test_ohlcv_aggregator_new_bar_started_after_completion() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
agg.feed(&make_tick("BTC-USD", dec!(50200), dec!(1), 120_000))
.unwrap();
let bar = agg.current_bar().unwrap();
assert_eq!(bar.open, dec!(50200));
assert_eq!(bar.bar_start_ms, 120_000);
}
#[test]
fn test_ohlcv_aggregator_flush_marks_complete() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
let flushed = agg.flush().unwrap();
assert!(flushed.is_complete);
assert!(agg.current_bar().is_none());
}
#[test]
fn test_ohlcv_aggregator_flush_empty_returns_none() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
assert!(agg.flush().is_none());
}
#[test]
fn test_ohlcv_aggregator_wrong_symbol_returns_error() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
let tick = make_tick("ETH-USD", dec!(3000), dec!(1), 60_000);
let result = agg.feed(&tick);
assert!(matches!(result, Err(StreamError::AggregationError { .. })));
}
#[test]
fn test_ohlcv_aggregator_volume_accumulates() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1.5), 60_000))
.unwrap();
agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(2.5), 60_100))
.unwrap();
let bar = agg.current_bar().unwrap();
assert_eq!(bar.volume, dec!(4));
}
#[test]
fn test_ohlcv_bar_symbol_and_timeframe() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(5));
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 300_000))
.unwrap();
let bar = agg.current_bar().unwrap();
assert_eq!(bar.symbol, "BTC-USD");
assert_eq!(bar.timeframe, Timeframe::Minutes(5));
}
#[test]
fn test_ohlcv_aggregator_symbol_accessor() {
let agg = agg("ETH-USD", Timeframe::Hours(1));
assert_eq!(agg.symbol(), "ETH-USD");
assert_eq!(agg.timeframe(), Timeframe::Hours(1));
}
#[test]
fn test_bar_aligned_by_exchange_ts_not_received_ts() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
let tick = make_tick_with_exchange_ts("BTC-USD", dec!(50000), dec!(1), 60_500, 120_100);
agg.feed(&tick).unwrap();
let bar = agg.current_bar().unwrap();
assert_eq!(bar.bar_start_ms, 60_000, "bar should use exchange_ts_ms");
}
#[test]
fn test_bar_falls_back_to_received_ts_when_no_exchange_ts() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
let tick = make_tick("BTC-USD", dec!(50000), dec!(1), 75_000);
agg.feed(&tick).unwrap();
let bar = agg.current_bar().unwrap();
assert_eq!(bar.bar_start_ms, 60_000);
}
#[test]
fn test_emit_empty_bars_no_gap_no_empties() {
let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
.unwrap()
.with_emit_empty_bars(true);
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
let bars = agg
.feed(&make_tick("BTC-USD", dec!(50100), dec!(1), 120_000))
.unwrap();
assert_eq!(bars.len(), 1);
assert_eq!(bars[0].bar_start_ms, 60_000);
assert_eq!(bars[0].volume, dec!(1));
}
#[test]
fn test_emit_empty_bars_two_skipped_windows() {
let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
.unwrap()
.with_emit_empty_bars(true);
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
let bars = agg
.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
.unwrap();
assert_eq!(bars.len(), 3);
assert_eq!(bars[0].bar_start_ms, 60_000);
assert!(!bars[0].volume.is_zero()); assert_eq!(bars[1].bar_start_ms, 120_000);
assert!(bars[1].volume.is_zero()); assert_eq!(bars[1].trade_count, 0);
assert_eq!(bars[1].open, dec!(50000)); assert_eq!(bars[2].bar_start_ms, 180_000);
assert!(bars[2].volume.is_zero()); }
#[test]
fn test_emit_empty_bars_disabled_no_empties_on_gap() {
let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
.unwrap()
.with_emit_empty_bars(false);
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
let bars = agg
.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
.unwrap();
assert_eq!(bars.len(), 1); }
#[test]
fn test_emit_empty_bars_is_complete_true() {
let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
.unwrap()
.with_emit_empty_bars(true);
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
let bars = agg
.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
.unwrap();
for bar in &bars {
assert!(bar.is_complete, "all emitted bars must be marked complete");
}
}
#[test]
fn test_ohlcv_bar_display() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
let bar = agg.current_bar().unwrap();
let s = bar.to_string();
assert!(s.contains("BTC-USD"));
assert!(s.contains("1m"));
assert!(s.contains("50000"));
}
#[test]
fn test_bar_count_increments_on_feed() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
assert_eq!(agg.bar_count(), 0);
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(1), 120_000))
.unwrap();
assert_eq!(agg.bar_count(), 1);
}
#[test]
fn test_bar_count_increments_on_flush() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
agg.flush().unwrap();
assert_eq!(agg.bar_count(), 1);
}
#[test]
fn test_ohlcv_bar_range() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
.unwrap();
agg.feed(&make_tick("BTC-USD", dec!(49500), dec!(1), 60_200))
.unwrap();
let bar = agg.current_bar().unwrap();
assert_eq!(bar.range(), dec!(1500)); }
#[test]
fn test_ohlcv_bar_body_bullish() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
agg.feed(&make_tick("BTC-USD", dec!(50500), dec!(1), 60_100))
.unwrap();
let bar = agg.current_bar().unwrap();
assert_eq!(bar.body(), dec!(500));
}
#[test]
fn test_ohlcv_bar_body_bearish() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(50500), dec!(1), 60_000))
.unwrap();
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_100))
.unwrap();
let bar = agg.current_bar().unwrap();
assert_eq!(bar.body(), dec!(500));
}
#[test]
fn test_aggregator_reset_clears_bar_and_count() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
agg.feed(&make_tick("BTC-USD", dec!(50100), dec!(1), 120_000))
.unwrap();
assert_eq!(agg.bar_count(), 1);
assert!(agg.current_bar().is_some());
agg.reset();
assert_eq!(agg.bar_count(), 0);
assert!(agg.current_bar().is_none());
}
#[test]
fn test_ohlcv_bar_is_bullish_when_close_gt_open() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
.unwrap();
let bar = agg.current_bar().unwrap();
assert!(bar.is_bullish());
assert!(!bar.is_bearish());
}
#[test]
fn test_ohlcv_bar_is_bearish_when_close_lt_open() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_000))
.unwrap();
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_100))
.unwrap();
let bar = agg.current_bar().unwrap();
assert!(bar.is_bearish());
assert!(!bar.is_bullish());
}
#[test]
fn test_ohlcv_bar_neither_bullish_nor_bearish_on_equal_open_close() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
let bar = agg.current_bar().unwrap();
assert!(!bar.is_bullish());
assert!(!bar.is_bearish());
}
#[test]
fn test_ohlcv_bar_vwap_single_tick_equals_price() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(2), 60_000))
.unwrap();
let bar = agg.current_bar().unwrap();
assert_eq!(bar.vwap, Some(dec!(50000)));
}
#[test]
fn test_ohlcv_bar_vwap_two_equal_price_ticks() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(3), 60_100))
.unwrap();
let bar = agg.current_bar().unwrap();
assert_eq!(bar.vwap, Some(dec!(50000)));
}
#[test]
fn test_ohlcv_bar_vwap_two_different_price_ticks() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
agg.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 60_100))
.unwrap();
let bar = agg.current_bar().unwrap();
assert_eq!(bar.vwap, Some(dec!(50500)));
}
#[test]
fn test_ohlcv_bar_vwap_gap_fill_is_none() {
let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1))
.unwrap()
.with_emit_empty_bars(true);
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
let bars = agg
.feed(&make_tick("BTC-USD", dec!(51000), dec!(1), 240_000))
.unwrap();
assert!(bars[0].vwap.is_some());
assert!(bars[1].vwap.is_none());
assert!(bars[2].vwap.is_none());
}
#[test]
fn test_aggregator_reset_allows_fresh_start() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(50000), dec!(1), 60_000))
.unwrap();
agg.reset();
agg.feed(&make_tick("BTC-USD", dec!(99999), dec!(2), 60_000))
.unwrap();
let bar = agg.current_bar().unwrap();
assert_eq!(bar.open, dec!(99999));
}
#[test]
fn test_from_duration_ms_hours() {
assert_eq!(Timeframe::from_duration_ms(3_600_000), Some(Timeframe::Hours(1)));
assert_eq!(Timeframe::from_duration_ms(7_200_000), Some(Timeframe::Hours(2)));
}
#[test]
fn test_from_duration_ms_minutes() {
assert_eq!(Timeframe::from_duration_ms(300_000), Some(Timeframe::Minutes(5)));
assert_eq!(Timeframe::from_duration_ms(60_000), Some(Timeframe::Minutes(1)));
}
#[test]
fn test_from_duration_ms_seconds() {
assert_eq!(Timeframe::from_duration_ms(15_000), Some(Timeframe::Seconds(15)));
assert_eq!(Timeframe::from_duration_ms(1_000), Some(Timeframe::Seconds(1)));
}
#[test]
fn test_from_duration_ms_zero_returns_none() {
assert_eq!(Timeframe::from_duration_ms(0), None);
}
#[test]
fn test_from_duration_ms_non_whole_second_returns_none() {
assert_eq!(Timeframe::from_duration_ms(1_500), None);
}
#[test]
fn test_from_duration_ms_roundtrip() {
for tf in [Timeframe::Seconds(30), Timeframe::Minutes(5), Timeframe::Hours(4)] {
assert_eq!(Timeframe::from_duration_ms(tf.duration_ms()), Some(tf));
}
}
#[test]
fn test_is_doji_exact_zero_body() {
let bar = OhlcvBar {
symbol: "X".into(), timeframe: Timeframe::Minutes(1),
bar_start_ms: 0, open: dec!(100), high: dec!(105),
low: dec!(95), close: dec!(100),
volume: dec!(1), trade_count: 1, is_complete: true,
is_gap_fill: false, vwap: None,
};
assert!(bar.is_doji(Decimal::ZERO));
}
#[test]
fn test_is_doji_small_epsilon() {
let bar = OhlcvBar {
symbol: "X".into(), timeframe: Timeframe::Minutes(1),
bar_start_ms: 0, open: dec!(100), high: dec!(105),
low: dec!(95), close: dec!(100.005),
volume: dec!(1), trade_count: 1, is_complete: true,
is_gap_fill: false, vwap: None,
};
assert!(bar.is_doji(dec!(0.01)));
assert!(!bar.is_doji(Decimal::ZERO));
}
#[test]
fn test_wick_upper_bullish() {
let bar = OhlcvBar {
symbol: "X".into(), timeframe: Timeframe::Minutes(1),
bar_start_ms: 0, open: dec!(100), high: dec!(107),
low: dec!(98), close: dec!(104),
volume: dec!(1), trade_count: 1, is_complete: true,
is_gap_fill: false, vwap: None,
};
assert_eq!(bar.wick_upper(), dec!(3));
}
#[test]
fn test_wick_lower_bearish() {
let bar = OhlcvBar {
symbol: "X".into(), timeframe: Timeframe::Minutes(1),
bar_start_ms: 0, open: dec!(104), high: dec!(107),
low: dec!(97), close: dec!(100),
volume: dec!(1), trade_count: 1, is_complete: true,
is_gap_fill: false, vwap: None,
};
assert_eq!(bar.wick_lower(), dec!(3));
}
#[test]
fn test_window_progress_none_when_no_bar() {
let agg = agg("BTC-USD", Timeframe::Minutes(1));
assert!(agg.window_progress(60_000).is_none());
}
#[test]
fn test_window_progress_at_start_is_zero() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
assert_eq!(agg.window_progress(60_000), Some(0.0));
}
#[test]
fn test_window_progress_midpoint() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
let progress = agg.window_progress(90_000).unwrap();
assert!((progress - 0.5).abs() < 1e-9);
}
#[test]
fn test_window_progress_clamps_at_one() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
assert_eq!(agg.window_progress(150_000), Some(1.0));
}
#[test]
fn test_price_change_bullish_is_positive() {
let bar = make_bar(dec!(100), dec!(110), dec!(98), dec!(105));
assert_eq!(bar.price_change(), dec!(5));
}
#[test]
fn test_price_change_bearish_is_negative() {
let bar = make_bar(dec!(105), dec!(110), dec!(98), dec!(100));
assert_eq!(bar.price_change(), dec!(-5));
}
#[test]
fn test_price_change_doji_is_zero() {
let bar = make_bar(dec!(100), dec!(102), dec!(98), dec!(100));
assert_eq!(bar.price_change(), dec!(0));
}
#[test]
fn test_total_volume_zero_before_completion() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(2), 60_000)).unwrap();
assert_eq!(agg.total_volume(), dec!(0));
}
#[test]
fn test_total_volume_accumulates_across_bars() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(2), 60_000)).unwrap();
agg.feed(&make_tick("BTC-USD", dec!(101), dec!(3), 120_000)).unwrap();
assert_eq!(agg.total_volume(), dec!(2));
agg.feed(&make_tick("BTC-USD", dec!(102), dec!(5), 180_000)).unwrap();
assert_eq!(agg.total_volume(), dec!(5)); }
#[test]
fn test_total_volume_reset_clears() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(2), 60_000)).unwrap();
agg.feed(&make_tick("BTC-USD", dec!(101), dec!(3), 120_000)).unwrap();
agg.reset();
assert_eq!(agg.total_volume(), dec!(0));
}
fn make_bar(open: Decimal, high: Decimal, low: Decimal, close: Decimal) -> OhlcvBar {
OhlcvBar {
symbol: "X".into(),
timeframe: Timeframe::Minutes(1),
bar_start_ms: 0,
open,
high,
low,
close,
volume: dec!(1),
trade_count: 1,
is_complete: true,
is_gap_fill: false,
vwap: None,
}
}
#[test]
fn test_typical_price() {
let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(10));
assert_eq!(bar.typical_price(), dec!(10));
}
#[test]
fn test_median_price() {
let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(10));
assert_eq!(bar.median_price(), dec!(10));
}
#[test]
fn test_typical_price_differs_from_median() {
let bar = make_bar(dec!(8), dec!(10), dec!(6), dec!(10));
assert_eq!(bar.median_price(), dec!(8));
assert!(bar.typical_price() > bar.median_price());
}
#[test]
fn test_close_location_value_at_high() {
let bar = make_bar(dec!(100), dec!(110), dec!(90), dec!(110));
let clv = bar.close_location_value().unwrap();
assert!((clv - 1.0).abs() < 1e-9, "expected 1.0 got {clv}");
}
#[test]
fn test_close_location_value_at_low() {
let bar = make_bar(dec!(100), dec!(110), dec!(90), dec!(90));
let clv = bar.close_location_value().unwrap();
assert!((clv + 1.0).abs() < 1e-9, "expected -1.0 got {clv}");
}
#[test]
fn test_close_location_value_midpoint_is_zero() {
let bar = make_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let clv = bar.close_location_value().unwrap();
assert!(clv.abs() < 1e-9, "expected 0.0 got {clv}");
}
#[test]
fn test_close_location_value_zero_range_returns_none() {
let bar = make_bar(dec!(100), dec!(100), dec!(100), dec!(100));
assert!(bar.close_location_value().is_none());
}
#[test]
fn test_body_direction_bullish() {
let bar = make_bar(dec!(90), dec!(110), dec!(85), dec!(105));
assert_eq!(bar.body_direction(), BarDirection::Bullish);
}
#[test]
fn test_body_direction_bearish() {
let bar = make_bar(dec!(105), dec!(110), dec!(85), dec!(90));
assert_eq!(bar.body_direction(), BarDirection::Bearish);
}
#[test]
fn test_body_direction_neutral() {
let bar = make_bar(dec!(100), dec!(110), dec!(85), dec!(100));
assert_eq!(bar.body_direction(), BarDirection::Neutral);
}
#[test]
fn test_last_bar_none_before_completion() {
let agg = agg("BTC-USD", Timeframe::Minutes(1));
assert!(agg.last_bar().is_none());
}
#[test]
fn test_last_bar_set_after_bar_completion() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
agg.feed(&make_tick("BTC-USD", dec!(200), dec!(1), 120_000)).unwrap();
let last = agg.last_bar().unwrap();
assert!(last.is_complete);
assert_eq!(last.close, dec!(100));
}
#[test]
fn test_last_bar_set_after_flush() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(50), dec!(1), 60_000)).unwrap();
let flushed = agg.flush().unwrap();
assert_eq!(agg.last_bar().unwrap().close, flushed.close);
}
#[test]
fn test_last_bar_cleared_on_reset() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 60_000)).unwrap();
agg.feed(&make_tick("BTC-USD", dec!(200), dec!(1), 120_000)).unwrap();
assert!(agg.last_bar().is_some());
agg.reset();
assert!(agg.last_bar().is_none());
}
#[test]
fn test_weighted_close_basic() {
let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(10));
assert_eq!(bar.weighted_close(), dec!(10));
}
#[test]
fn test_weighted_close_weights_close_more_than_typical() {
let bar = make_bar(dec!(50), dec!(100), dec!(0), dec!(80));
assert_eq!(bar.weighted_close(), dec!(65));
}
#[test]
fn test_price_change_pct_bullish() {
let bar = make_bar(dec!(100), dec!(115), dec!(95), dec!(110));
let pct = bar.price_change_pct().unwrap();
assert!((pct - 10.0).abs() < 1e-9, "expected 10.0 got {pct}");
}
#[test]
fn test_price_change_pct_bearish() {
let bar = make_bar(dec!(200), dec!(210), dec!(175), dec!(180));
let pct = bar.price_change_pct().unwrap();
assert!((pct - (-10.0)).abs() < 1e-9, "expected -10.0 got {pct}");
}
#[test]
fn test_price_change_pct_zero_open_returns_none() {
let bar = make_bar(dec!(0), dec!(5), dec!(0), dec!(3));
assert!(bar.price_change_pct().is_none());
}
#[test]
fn test_wick_ratio_all_wicks() {
let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
let r = bar.wick_ratio().unwrap();
assert!((r - 1.0).abs() < 1e-9, "expected 1.0 got {r}");
}
#[test]
fn test_wick_ratio_no_wicks() {
let bar = make_bar(dec!(0), dec!(10), dec!(0), dec!(10));
let r = bar.wick_ratio().unwrap();
assert!((r - 0.0).abs() < 1e-9, "expected 0.0 got {r}");
}
#[test]
fn test_wick_ratio_zero_range_returns_none() {
let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
assert!(bar.wick_ratio().is_none());
}
#[test]
fn test_body_ratio_no_wicks_is_one() {
let bar = make_bar(dec!(0), dec!(10), dec!(0), dec!(10));
let r = bar.body_ratio().unwrap();
assert!((r - 1.0).abs() < 1e-9);
}
#[test]
fn test_body_ratio_all_wicks_is_zero() {
let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
let r = bar.body_ratio().unwrap();
assert!((r - 0.0).abs() < 1e-9);
}
#[test]
fn test_body_ratio_zero_range_returns_none() {
let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
assert!(bar.body_ratio().is_none());
}
#[test]
fn test_body_ratio_plus_wick_ratio_equals_one() {
let bar = make_bar(dec!(4), dec!(10), dec!(0), dec!(8));
let body = bar.body_ratio().unwrap();
let wick = bar.wick_ratio().unwrap();
assert!((body + wick - 1.0).abs() < 1e-9);
}
#[test]
fn test_average_volume_none_before_bars() {
let agg = agg("BTC-USD", Timeframe::Minutes(1));
assert!(agg.average_volume().is_none());
}
#[test]
fn test_average_volume_one_bar() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(4), 60_000)).unwrap();
agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
assert_eq!(agg.average_volume(), Some(dec!(4)));
}
#[test]
fn test_average_volume_two_bars() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(4), 60_000)).unwrap();
agg.feed(&make_tick("BTC-USD", dec!(101), dec!(6), 120_000)).unwrap();
agg.feed(&make_tick("BTC-USD", dec!(102), dec!(1), 180_000)).unwrap();
assert_eq!(agg.average_volume(), Some(dec!(5)));
}
#[test]
fn test_true_range_no_gap() {
let bar = make_bar(dec!(9), dec!(12), dec!(8), dec!(11));
assert_eq!(bar.true_range(dec!(10)), dec!(4));
}
#[test]
fn test_true_range_gap_up() {
let bar = make_bar(dec!(12), dec!(15), dec!(12), dec!(13));
assert_eq!(bar.true_range(dec!(10)), dec!(5));
}
#[test]
fn test_true_range_gap_down() {
let bar = make_bar(dec!(7), dec!(8), dec!(5), dec!(6));
assert_eq!(bar.true_range(dec!(12)), dec!(7));
}
#[test]
fn test_inside_bar_true_when_contained() {
let prev = make_bar(dec!(9), dec!(15), dec!(5), dec!(12));
let curr = make_bar(dec!(10), dec!(14), dec!(6), dec!(11));
assert!(curr.is_inside_bar(&prev));
}
#[test]
fn test_inside_bar_false_when_not_contained() {
let prev = make_bar(dec!(9), dec!(15), dec!(5), dec!(12));
let curr = make_bar(dec!(10), dec!(16), dec!(6), dec!(11));
assert!(!curr.is_inside_bar(&prev));
}
#[test]
fn test_outside_bar_true_when_engulfing() {
let prev = make_bar(dec!(9), dec!(12), dec!(8), dec!(11));
let curr = make_bar(dec!(10), dec!(14), dec!(6), dec!(11));
assert!(curr.outside_bar(&prev));
}
#[test]
fn test_outside_bar_false_when_not_engulfing() {
let prev = make_bar(dec!(9), dec!(12), dec!(8), dec!(11));
let curr = make_bar(dec!(10), dec!(11), dec!(9), dec!(10));
assert!(!curr.outside_bar(&prev));
}
#[test]
fn test_is_hammer_classic() {
let bar = make_bar(dec!(9), dec!(10), dec!(0), dec!(9));
assert!(bar.is_hammer());
}
#[test]
fn test_is_hammer_false_large_upper_wick() {
let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
assert!(!bar.is_hammer());
}
#[test]
fn test_is_hammer_false_zero_range() {
let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
assert!(!bar.is_hammer());
}
#[test]
fn test_peak_volume_none_before_completion() {
let agg = agg("BTC-USD", Timeframe::Minutes(1));
assert!(agg.peak_volume().is_none());
}
#[test]
fn test_peak_volume_tracks_maximum() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(3), 60_000)).unwrap();
agg.feed(&make_tick("BTC-USD", dec!(101), dec!(10), 120_000)).unwrap();
assert_eq!(agg.peak_volume(), Some(dec!(3)));
agg.feed(&make_tick("BTC-USD", dec!(102), dec!(1), 180_000)).unwrap();
assert_eq!(agg.peak_volume(), Some(dec!(10)));
}
#[test]
fn test_peak_volume_reset_clears() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(5), 60_000)).unwrap();
agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
agg.reset();
assert!(agg.peak_volume().is_none());
}
#[test]
fn test_peak_volume_via_flush() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(7), 60_000)).unwrap();
agg.flush();
assert_eq!(agg.peak_volume(), Some(dec!(7)));
}
#[test]
fn test_is_shooting_star_classic() {
let bar = make_bar(dec!(1), dec!(10), dec!(0), dec!(1));
assert!(bar.is_shooting_star());
}
#[test]
fn test_is_shooting_star_false_large_lower_wick() {
let bar = make_bar(dec!(5), dec!(10), dec!(0), dec!(5));
assert!(!bar.is_shooting_star());
}
#[test]
fn test_is_shooting_star_false_zero_range() {
let bar = make_bar(dec!(5), dec!(5), dec!(5), dec!(5));
assert!(!bar.is_shooting_star());
}
#[test]
fn test_hammer_and_shooting_star_are_mutually_exclusive_for_typical_bars() {
let hammer = make_bar(dec!(9), dec!(10), dec!(0), dec!(9));
let star = make_bar(dec!(1), dec!(10), dec!(0), dec!(1));
assert!(hammer.is_hammer() && !hammer.is_shooting_star());
assert!(star.is_shooting_star() && !star.is_hammer());
}
#[test]
fn test_min_volume_none_before_completion() {
let agg = agg("BTC-USD", Timeframe::Minutes(1));
assert!(agg.min_volume().is_none());
}
#[test]
fn test_min_volume_tracks_minimum() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(10), 60_000)).unwrap();
agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
assert_eq!(agg.min_volume(), Some(dec!(10)));
agg.feed(&make_tick("BTC-USD", dec!(102), dec!(5), 180_000)).unwrap();
assert_eq!(agg.min_volume(), Some(dec!(1)));
}
#[test]
fn test_min_volume_reset_clears() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(5), 60_000)).unwrap();
agg.feed(&make_tick("BTC-USD", dec!(101), dec!(1), 120_000)).unwrap();
agg.reset();
assert!(agg.min_volume().is_none());
}
#[test]
fn test_is_gap_up_true() {
let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
let curr = make_bar(dec!(9), dec!(12), dec!(8), dec!(11)); assert!(curr.is_gap_up(&prev));
}
#[test]
fn test_is_gap_up_false_when_equal() {
let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
let curr = make_bar(dec!(8), dec!(12), dec!(7), dec!(11)); assert!(!curr.is_gap_up(&prev));
}
#[test]
fn test_is_gap_down_true() {
let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
let curr = make_bar(dec!(7), dec!(8), dec!(6), dec!(7)); assert!(curr.is_gap_down(&prev));
}
#[test]
fn test_is_gap_down_false_when_equal() {
let prev = make_bar(dec!(5), dec!(10), dec!(4), dec!(8));
let curr = make_bar(dec!(8), dec!(9), dec!(7), dec!(8)); assert!(!curr.is_gap_down(&prev));
}
#[test]
fn test_volume_range_none_before_completion() {
let agg = agg("BTC-USD", Timeframe::Minutes(1));
assert!(agg.volume_range().is_none());
}
#[test]
fn test_volume_range_after_two_bars() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(3), 60_000)).unwrap();
agg.feed(&make_tick("BTC-USD", dec!(101), dec!(10), 120_000)).unwrap();
agg.feed(&make_tick("BTC-USD", dec!(102), dec!(1), 180_000)).unwrap();
assert_eq!(agg.volume_range(), Some((dec!(3), dec!(10))));
}
fn make_ohlcv_bar(open: Decimal, high: Decimal, low: Decimal, close: Decimal) -> OhlcvBar {
OhlcvBar {
symbol: "X".into(),
timeframe: Timeframe::Minutes(1),
open,
high,
low,
close,
volume: dec!(1),
bar_start_ms: 0,
trade_count: 1,
is_complete: false,
is_gap_fill: false,
vwap: None,
}
}
#[test]
fn test_body_to_range_ratio_bullish_full_body() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
assert_eq!(bar.body_to_range_ratio(), Some(dec!(1)));
}
#[test]
fn test_body_to_range_ratio_doji_like() {
let bar = make_ohlcv_bar(dec!(100), dec!(102), dec!(98), dec!(100));
assert_eq!(bar.body_to_range_ratio(), Some(dec!(0)));
}
#[test]
fn test_body_to_range_ratio_none_when_range_zero() {
let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
assert!(bar.body_to_range_ratio().is_none());
}
#[test]
fn test_is_active_false_before_any_ticks() {
let agg = agg("BTC-USD", Timeframe::Minutes(1));
assert!(!agg.is_active());
}
#[test]
fn test_is_active_true_after_first_tick() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 1_000)).unwrap();
assert!(agg.is_active());
}
#[test]
fn test_is_active_false_after_flush() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 1_000)).unwrap();
agg.flush();
assert!(!agg.is_active());
}
#[test]
fn test_is_long_upper_wick_true_when_upper_wick_dominates() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(101));
assert!(bar.is_long_upper_wick());
}
#[test]
fn test_is_long_upper_wick_false_for_full_body() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
assert!(!bar.is_long_upper_wick());
}
#[test]
fn test_is_long_upper_wick_false_when_equal() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(105));
assert!(!bar.is_long_upper_wick());
}
#[test]
fn test_price_change_abs_bullish_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(108));
assert_eq!(bar.price_change_abs(), dec!(8));
}
#[test]
fn test_price_change_abs_bearish_bar() {
let bar = make_ohlcv_bar(dec!(110), dec!(110), dec!(100), dec!(102));
assert_eq!(bar.price_change_abs(), dec!(8));
}
#[test]
fn test_price_change_abs_doji_zero() {
let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
assert_eq!(bar.price_change_abs(), dec!(0));
}
#[test]
fn test_vwap_current_none_before_any_ticks() {
let agg = agg("BTC-USD", Timeframe::Minutes(1));
assert!(agg.vwap_current().is_none());
}
#[test]
fn test_vwap_current_equals_price_for_single_tick() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(200), dec!(5), 1_000)).unwrap();
assert_eq!(agg.vwap_current(), Some(dec!(200)));
}
#[test]
fn test_vwap_current_weighted_average() {
let mut agg = agg("BTC-USD", Timeframe::Minutes(1));
agg.feed(&make_tick("BTC-USD", dec!(100), dec!(1), 1_000)).unwrap();
agg.feed(&make_tick("BTC-USD", dec!(200), dec!(3), 2_000)).unwrap();
assert_eq!(agg.vwap_current(), Some(dec!(175)));
}
fn bar(o: i64, h: i64, l: i64, c: i64) -> OhlcvBar {
OhlcvBar {
symbol: "X".into(),
timeframe: Timeframe::Minutes(1),
open: Decimal::from(o),
high: Decimal::from(h),
low: Decimal::from(l),
close: Decimal::from(c),
volume: Decimal::ZERO,
bar_start_ms: 0,
trade_count: 0,
is_complete: false,
is_gap_fill: false,
vwap: None,
}
}
#[test]
fn test_upper_shadow_equals_wick_upper() {
let b = bar(100, 120, 90, 110);
assert_eq!(b.upper_shadow(), b.wick_upper());
assert_eq!(b.upper_shadow(), Decimal::from(10)); }
#[test]
fn test_lower_shadow_equals_wick_lower() {
let b = bar(100, 120, 90, 110);
assert_eq!(b.lower_shadow(), b.wick_lower());
assert_eq!(b.lower_shadow(), Decimal::from(10)); }
#[test]
fn test_is_spinning_top_true_when_small_body_large_wicks() {
let b = bar(100, 130, 80, 110);
assert!(b.is_spinning_top(dec!(0.3)));
}
#[test]
fn test_is_spinning_top_false_when_body_too_large() {
let b = bar(80, 130, 80, 120);
assert!(!b.is_spinning_top(dec!(0.3)));
}
#[test]
fn test_is_spinning_top_false_when_zero_range() {
let b = bar(100, 100, 100, 100);
assert!(!b.is_spinning_top(dec!(0.3)));
}
#[test]
fn test_hlc3_equals_typical_price() {
let b = bar(100, 120, 80, 110);
assert_eq!(b.hlc3(), b.typical_price());
assert_eq!(b.hlc3(), (Decimal::from(120) + Decimal::from(80) + Decimal::from(110)) / Decimal::from(3));
}
#[test]
fn test_is_bearish_true_when_close_below_open() {
let bar = make_ohlcv_bar(dec!(110), dec!(115), dec!(100), dec!(105));
assert!(bar.is_bearish());
}
#[test]
fn test_is_bearish_false_when_close_above_open() {
let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
assert!(!bar.is_bearish());
}
#[test]
fn test_is_bearish_false_when_doji() {
let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
assert!(!bar.is_bearish());
}
#[test]
fn test_wick_ratio_zero_for_full_body_no_wicks() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
let ratio = bar.wick_ratio().unwrap();
assert!(ratio.abs() < 1e-10);
}
#[test]
fn test_wick_ratio_one_for_pure_wick_doji() {
let bar = make_ohlcv_bar(dec!(105), dec!(110), dec!(100), dec!(105));
let ratio = bar.wick_ratio().unwrap();
assert!((ratio - 1.0).abs() < 1e-10);
}
#[test]
fn test_wick_ratio_none_for_zero_range_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
assert!(bar.wick_ratio().is_none());
}
#[test]
fn test_is_bullish_true_when_close_above_open() {
let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
assert!(bar.is_bullish());
}
#[test]
fn test_is_bullish_false_when_close_below_open() {
let bar = make_ohlcv_bar(dec!(110), dec!(115), dec!(100), dec!(105));
assert!(!bar.is_bullish());
}
#[test]
fn test_is_bullish_false_when_doji() {
let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
assert!(!bar.is_bullish());
}
#[test]
fn test_bar_duration_ms_one_minute() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(bar.bar_duration_ms(), 60_000);
}
#[test]
fn test_bar_duration_ms_consistent_with_timeframe() {
let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
bar.timeframe = Timeframe::Hours(1);
assert_eq!(bar.bar_duration_ms(), 3_600_000);
}
#[test]
fn test_bar_duration_ms_seconds_timeframe() {
let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
bar.timeframe = Timeframe::Seconds(30);
assert_eq!(bar.bar_duration_ms(), 30_000);
}
#[test]
fn test_ohlc4_equals_average_of_all_four_prices() {
let b = bar(100, 120, 80, 110);
let expected = (Decimal::from(100) + Decimal::from(120) + Decimal::from(80) + Decimal::from(110))
/ Decimal::from(4);
assert_eq!(b.ohlc4(), expected);
}
#[test]
fn test_is_marubozu_true_when_no_wicks() {
let b = bar(100, 110, 100, 110);
assert!(b.is_marubozu());
}
#[test]
fn test_is_marubozu_false_when_has_upper_wick() {
let b = bar(100, 115, 100, 110);
assert!(!b.is_marubozu());
}
#[test]
fn test_is_marubozu_false_when_has_lower_wick() {
let b = bar(100, 110, 95, 110);
assert!(!b.is_marubozu());
}
#[test]
fn test_is_harami_true_when_body_inside_prev_body() {
let prev = bar(98, 115, 90, 108); let curr = bar(100, 110, 95, 105); assert!(curr.is_harami(&prev));
}
#[test]
fn test_is_harami_false_when_body_engulfs_prev() {
let prev = bar(100, 110, 95, 105); let curr = bar(98, 115, 90, 108); assert!(!curr.is_harami(&prev));
}
#[test]
fn test_is_harami_false_when_bodies_equal() {
let prev = bar(100, 110, 90, 105);
let curr = bar(100, 110, 90, 105); assert!(!curr.is_harami(&prev));
}
#[test]
fn test_tail_length_upper_wick_longer() {
let b = bar(100, 120, 95, 105);
assert_eq!(b.tail_length(), Decimal::from(15));
}
#[test]
fn test_tail_length_lower_wick_longer() {
let b = bar(105, 110, 80, 100);
assert_eq!(b.tail_length(), Decimal::from(20));
}
#[test]
fn test_tail_length_zero_for_marubozu() {
let b = bar(100, 110, 100, 110);
assert!(b.tail_length().is_zero());
}
#[test]
fn test_is_inside_bar_true_when_range_within_prev() {
let prev = bar(90, 120, 80, 110); let curr = bar(95, 115, 85, 100); assert!(curr.is_inside_bar(&prev));
}
#[test]
fn test_is_inside_bar_false_when_high_exceeds_prev_high() {
let prev = bar(90, 110, 80, 100); let curr = bar(95, 112, 85, 100); assert!(!curr.is_inside_bar(&prev));
}
#[test]
fn test_is_inside_bar_false_when_equal_range() {
let prev = bar(90, 110, 80, 100);
let curr = bar(90, 110, 80, 100); assert!(!curr.is_inside_bar(&prev));
}
#[test]
fn test_bar_type_bullish() {
let b = bar(100, 110, 90, 105); assert_eq!(b.bar_type(), "bullish");
}
#[test]
fn test_bar_type_bearish() {
let b = bar(105, 110, 90, 100); assert_eq!(b.bar_type(), "bearish");
}
#[test]
fn test_bar_type_doji() {
let b = bar(100, 110, 90, 100); assert_eq!(b.bar_type(), "doji");
}
#[test]
fn test_body_pct_none_for_zero_range() {
let b = bar(100, 100, 100, 100);
assert!(b.body_pct().is_none());
}
#[test]
fn test_body_pct_100_for_marubozu() {
let b = bar(100, 110, 100, 110);
assert_eq!(b.body_pct().unwrap(), Decimal::ONE_HUNDRED);
}
#[test]
fn test_body_pct_50_for_half_body() {
let b = bar(100, 110, 100, 105);
assert_eq!(b.body_pct().unwrap(), Decimal::from(50));
}
#[test]
fn test_is_bullish_hammer_true_for_classic_hammer() {
let b = bar(108, 110, 100, 109);
assert!(b.is_bullish_hammer());
}
#[test]
fn test_is_bullish_hammer_false_when_lower_wick_not_long_enough() {
let b = bar(100, 110, 98, 108);
assert!(!b.is_bullish_hammer());
}
#[test]
fn test_is_bullish_hammer_false_for_doji() {
let b = bar(100, 110, 90, 100); assert!(!b.is_bullish_hammer());
}
#[test]
fn test_is_marubozu_true_when_full_body() {
let b = bar(100, 110, 100, 110);
assert!(b.is_marubozu());
}
#[test]
fn test_is_marubozu_false_when_large_wicks() {
let b = bar(100, 120, 80, 110);
assert!(!b.is_marubozu());
}
#[test]
fn test_is_marubozu_true_for_zero_range_flat_bar() {
let b = bar(100, 100, 100, 100);
assert!(b.is_marubozu());
}
#[test]
fn test_upper_wick_pct_zero_when_no_upper_wick() {
let b = bar(100, 110, 90, 110);
let pct = b.upper_wick_pct().unwrap();
assert!(pct.is_zero(), "expected 0, got {pct}");
}
#[test]
fn test_upper_wick_pct_50_when_half_range() {
let b = bar(100, 120, 100, 110);
let pct = b.upper_wick_pct().unwrap();
assert_eq!(pct, dec!(50));
}
#[test]
fn test_upper_wick_pct_none_for_zero_range() {
let b = bar(100, 100, 100, 100);
assert!(b.upper_wick_pct().is_none());
}
#[test]
fn test_lower_wick_pct_zero_when_no_lower_wick() {
let b = bar(100, 110, 100, 105);
let pct = b.lower_wick_pct().unwrap();
assert!(pct.is_zero(), "expected 0, got {pct}");
}
#[test]
fn test_lower_wick_pct_50_when_half_range() {
let b = bar(110, 120, 100, 115);
let pct = b.lower_wick_pct().unwrap();
assert_eq!(pct, dec!(50));
}
#[test]
fn test_lower_wick_pct_none_for_zero_range() {
let b = bar(100, 100, 100, 100);
assert!(b.lower_wick_pct().is_none());
}
#[test]
fn test_is_bearish_engulfing_true_for_bearish_engulf() {
let prev = bar(100, 115, 95, 110); let curr = bar(112, 115, 88, 90); assert!(curr.is_bearish_engulfing(&prev));
}
#[test]
fn test_is_bearish_engulfing_false_for_bullish_engulf() {
let prev = bar(110, 115, 95, 100); let curr = bar(98, 120, 95, 115); assert!(!curr.is_bearish_engulfing(&prev));
}
#[test]
fn test_is_engulfing_true_when_body_contains_prev_body() {
let prev = bar(100, 110, 95, 105); let curr = bar(98, 115, 95, 108); assert!(curr.is_engulfing(&prev));
}
#[test]
fn test_is_engulfing_false_when_only_partial_overlap() {
let prev = bar(100, 115, 90, 112); let curr = bar(101, 115, 90, 113); assert!(!curr.is_engulfing(&prev));
}
#[test]
fn test_is_engulfing_false_for_equal_bodies() {
let prev = bar(100, 110, 90, 108);
let curr = bar(100, 110, 90, 108); assert!(!curr.is_engulfing(&prev));
}
#[test]
fn test_has_upper_wick_true_when_high_above_max_oc() {
let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(100), dec!(110));
assert!(bar.has_upper_wick());
}
#[test]
fn test_has_upper_wick_false_for_full_body() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
assert!(!bar.has_upper_wick());
}
#[test]
fn test_has_lower_wick_true_when_low_below_min_oc() {
let bar = make_ohlcv_bar(dec!(105), dec!(110), dec!(100), dec!(110));
assert!(bar.has_lower_wick());
}
#[test]
fn test_has_lower_wick_false_for_full_body() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
assert!(!bar.has_lower_wick());
}
#[test]
fn test_is_gravestone_doji_true() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(100));
assert!(bar.is_gravestone_doji(dec!(0)));
}
#[test]
fn test_is_gravestone_doji_false_when_close_above_low() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(99), dec!(105));
assert!(!bar.is_gravestone_doji(dec!(1)));
}
#[test]
fn test_is_dragonfly_doji_true() {
let bar = make_ohlcv_bar(dec!(110), dec!(110), dec!(100), dec!(110));
assert!(bar.is_dragonfly_doji(dec!(0)));
}
#[test]
fn test_is_dragonfly_doji_false_when_close_below_high() {
let bar = make_ohlcv_bar(dec!(105), dec!(110), dec!(100), dec!(105));
assert!(!bar.is_dragonfly_doji(dec!(1)));
}
#[test]
fn test_is_flat_true() {
let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
assert!(bar.is_flat());
}
#[test]
fn test_is_flat_false_when_range_exists() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
assert!(!bar.is_flat());
}
#[test]
fn test_close_to_high_ratio_normal() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
let r = bar.close_to_high_ratio().unwrap();
assert!((r - 1.0).abs() < 1e-9);
}
#[test]
fn test_close_to_high_ratio_none_when_high_zero() {
let bar = make_ohlcv_bar(dec!(0), dec!(0), dec!(0), dec!(0));
assert!(bar.close_to_high_ratio().is_none());
}
#[test]
fn test_close_open_ratio_normal() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
let r = bar.close_open_ratio().unwrap();
assert!((r - 1.1).abs() < 1e-9);
}
#[test]
fn test_close_open_ratio_none_when_open_zero() {
let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
assert!(bar.close_open_ratio().is_none());
}
#[test]
fn test_true_range_simple_hl_dominates() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
assert_eq!(bar.true_range_with_prev(dec!(100)), dec!(20));
}
#[test]
fn test_true_range_gap_up_dominates() {
let bar = make_ohlcv_bar(dec!(91), dec!(100), dec!(90), dec!(95));
assert_eq!(bar.true_range_with_prev(dec!(80)), dec!(20));
}
#[test]
fn test_true_range_gap_down_dominates() {
let bar = make_ohlcv_bar(dec!(98), dec!(100), dec!(95), dec!(97));
assert_eq!(bar.true_range_with_prev(dec!(120)), dec!(25));
}
#[test]
fn test_is_outside_bar_true() {
let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(bar.is_outside_bar(&prev));
}
#[test]
fn test_is_outside_bar_false_when_inside() {
let prev = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
assert!(!bar.is_outside_bar(&prev));
}
#[test]
fn test_high_low_midpoint_correct() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
assert_eq!(bar.high_low_midpoint(), dec!(100));
}
#[test]
fn test_high_low_midpoint_uneven() {
let bar = make_ohlcv_bar(dec!(100), dec!(111), dec!(90), dec!(100));
assert_eq!(bar.high_low_midpoint(), dec!(100.5));
}
#[test]
fn test_gap_up_true() {
let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(98));
let bar = make_ohlcv_bar(dec!(102), dec!(110), dec!(101), dec!(108));
assert!(bar.gap_up(&prev));
}
#[test]
fn test_gap_up_false_when_no_gap() {
let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(98));
let bar = make_ohlcv_bar(dec!(99), dec!(105), dec!(98), dec!(104));
assert!(!bar.gap_up(&prev));
}
#[test]
fn test_gap_down_true() {
let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(92));
let bar = make_ohlcv_bar(dec!(88), dec!(89), dec!(85), dec!(86));
assert!(bar.gap_down(&prev));
}
#[test]
fn test_gap_down_false_when_no_gap() {
let prev = make_ohlcv_bar(dec!(95), dec!(100), dec!(90), dec!(92));
let bar = make_ohlcv_bar(dec!(91), dec!(95), dec!(89), dec!(93));
assert!(!bar.gap_down(&prev));
}
#[test]
fn test_range_pct_correct() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let pct = bar.range_pct().unwrap();
assert!((pct - 20.0).abs() < 1e-9);
}
#[test]
fn test_range_pct_none_when_open_zero() {
let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
assert!(bar.range_pct().is_none());
}
#[test]
fn test_range_pct_zero_for_flat_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
let pct = bar.range_pct().unwrap();
assert_eq!(pct, 0.0);
}
#[test]
fn test_body_size_bullish_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
assert_eq!(bar.body_size(), dec!(10));
}
#[test]
fn test_body_size_bearish_bar() {
let bar = make_ohlcv_bar(dec!(110), dec!(115), dec!(95), dec!(100));
assert_eq!(bar.body_size(), dec!(10));
}
#[test]
fn test_body_size_doji() {
let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
assert_eq!(bar.body_size(), dec!(0));
}
#[test]
fn test_volume_delta_positive_when_increasing() {
let mut prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
prev.volume = dec!(1000);
let mut bar = make_ohlcv_bar(dec!(102), dec!(110), dec!(98), dec!(108));
bar.volume = dec!(1500);
assert_eq!(bar.volume_delta(&prev), dec!(500));
}
#[test]
fn test_volume_delta_negative_when_decreasing() {
let mut prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
prev.volume = dec!(1500);
let mut bar = make_ohlcv_bar(dec!(102), dec!(110), dec!(98), dec!(108));
bar.volume = dec!(1000);
assert_eq!(bar.volume_delta(&prev), dec!(-500));
}
#[test]
fn test_is_consolidating_true_when_small_range() {
let prev = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); let bar = make_ohlcv_bar(dec!(102), dec!(106), dec!(100), dec!(104)); assert!(bar.is_consolidating(&prev));
}
#[test]
fn test_is_consolidating_false_when_large_range() {
let prev = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); let bar = make_ohlcv_bar(dec!(102), dec!(115), dec!(95), dec!(110)); assert!(!bar.is_consolidating(&prev));
}
#[test]
fn test_relative_volume_correct() {
let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(103));
let rv = bar.relative_volume(dec!(2)).unwrap();
assert!((rv - 0.5).abs() < 1e-9);
}
#[test]
fn test_relative_volume_none_when_avg_zero() {
let bar = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(103));
assert!(bar.relative_volume(dec!(0)).is_none());
}
#[test]
fn test_intraday_reversal_true_for_bullish_then_bearish() {
let prev = make_ohlcv_bar(dec!(100), dec!(108), dec!(99), dec!(105));
let bar = make_ohlcv_bar(dec!(105), dec!(107), dec!(97), dec!(98));
assert!(bar.intraday_reversal(&prev));
}
#[test]
fn test_intraday_reversal_false_for_continuation() {
let prev = make_ohlcv_bar(dec!(100), dec!(108), dec!(99), dec!(105));
let bar = make_ohlcv_bar(dec!(104), dec!(115), dec!(103), dec!(113));
assert!(!bar.intraday_reversal(&prev));
}
#[test]
fn test_price_at_pct_zero_returns_low() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
assert_eq!(bar.price_at_pct(0.0), dec!(90));
}
#[test]
fn test_price_at_pct_one_returns_high() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
assert_eq!(bar.price_at_pct(1.0), dec!(110));
}
#[test]
fn test_price_at_pct_half_returns_midpoint() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
assert_eq!(bar.price_at_pct(0.5), dec!(100));
}
#[test]
fn test_price_at_pct_clamped_above_one() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
assert_eq!(bar.price_at_pct(2.0), dec!(110));
}
#[test]
fn test_average_true_range_none_when_fewer_than_two_bars() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::average_true_range(&[bar]).is_none());
assert!(OhlcvBar::average_true_range(&[]).is_none());
}
#[test]
fn test_average_true_range_two_bars_no_gap() {
let bar1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let bar2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(95), dec!(110));
let atr = OhlcvBar::average_true_range(&[bar1, bar2]).unwrap();
assert_eq!(atr, dec!(20)); }
#[test]
fn test_average_true_range_three_bars_mean() {
let bar1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let bar2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let bar3 = make_ohlcv_bar(dec!(110), dec!(120), dec!(100), dec!(115));
let atr = OhlcvBar::average_true_range(&[bar1, bar2, bar3]).unwrap();
assert_eq!(atr, dec!(20));
}
#[test]
fn test_average_body_none_when_empty() {
assert!(OhlcvBar::average_body(&[]).is_none());
}
#[test]
fn test_average_body_single_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
assert_eq!(OhlcvBar::average_body(&[bar]), Some(dec!(8)));
}
#[test]
fn test_average_body_multiple_bars() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)); let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(100), dec!(100)); let b3 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(120)); let avg = OhlcvBar::average_body(&[b1, b2, b3]).unwrap();
assert_eq!(avg, dec!(40) / dec!(3));
}
#[test]
fn test_bullish_count_zero_for_empty_slice() {
assert_eq!(OhlcvBar::bullish_count(&[]), 0);
}
#[test]
fn test_bullish_count_all_bullish() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108)); let b2 = make_ohlcv_bar(dec!(108), dec!(120), dec!(105), dec!(115)); assert_eq!(OhlcvBar::bullish_count(&[b1, b2]), 2);
}
#[test]
fn test_bearish_count_correct() {
let bull = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
let bear = make_ohlcv_bar(dec!(108), dec!(110), dec!(90), dec!(95));
let doji = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
assert_eq!(OhlcvBar::bearish_count(&[bull, bear, doji]), 1);
}
#[test]
fn test_win_rate_none_when_empty() {
assert!(OhlcvBar::win_rate(&[]).is_none());
}
#[test]
fn test_win_rate_all_bullish() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
let b2 = make_ohlcv_bar(dec!(108), dec!(115), dec!(105), dec!(112));
let wr = OhlcvBar::win_rate(&[b1, b2]).unwrap();
assert!((wr - 1.0).abs() < 1e-9);
}
#[test]
fn test_win_rate_half_and_half() {
let bull = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
let bear = make_ohlcv_bar(dec!(108), dec!(110), dec!(90), dec!(95));
let wr = OhlcvBar::win_rate(&[bull, bear]).unwrap();
assert!((wr - 0.5).abs() < 1e-9);
}
#[test]
fn test_bullish_streak_zero_for_empty_slice() {
assert_eq!(OhlcvBar::bullish_streak(&[]), 0);
}
#[test]
fn test_bullish_streak_zero_when_last_bar_bearish() {
let bull = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
let bear = make_ohlcv_bar(dec!(108), dec!(110), dec!(90), dec!(95));
assert_eq!(OhlcvBar::bullish_streak(&[bull, bear]), 0);
}
#[test]
fn test_bullish_streak_counts_consecutive_tail() {
let bear = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90)); let bull1 = make_ohlcv_bar(dec!(90), dec!(105), dec!(88), dec!(102)); let bull2 = make_ohlcv_bar(dec!(102), dec!(115), dec!(100), dec!(110)); assert_eq!(OhlcvBar::bullish_streak(&[bear, bull1, bull2]), 2);
}
#[test]
fn test_bearish_streak_counts_consecutive_tail() {
let bull = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108)); let bear1 = make_ohlcv_bar(dec!(108), dec!(109), dec!(90), dec!(95)); let bear2 = make_ohlcv_bar(dec!(95), dec!(96), dec!(80), dec!(85)); assert_eq!(OhlcvBar::bearish_streak(&[bull, bear1, bear2]), 2);
}
#[test]
fn test_max_drawdown_none_when_fewer_than_2_bars() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::max_drawdown(&[bar]).is_none());
assert!(OhlcvBar::max_drawdown(&[]).is_none());
}
#[test]
fn test_max_drawdown_zero_when_monotone_increasing() {
let b1 = make_ohlcv_bar(dec!(100), dec!(105), dec!(98), dec!(100));
let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(99), dec!(105));
let b3 = make_ohlcv_bar(dec!(105), dec!(115), dec!(104), dec!(110));
let dd = OhlcvBar::max_drawdown(&[b1, b2, b3]).unwrap();
assert_eq!(dd, 0.0);
}
#[test]
fn test_max_drawdown_correct_after_peak_then_drop() {
let b1 = make_ohlcv_bar(dec!(100), dec!(102), dec!(98), dec!(100));
let b2 = make_ohlcv_bar(dec!(100), dec!(125), dec!(99), dec!(120));
let b3 = make_ohlcv_bar(dec!(120), dec!(121), dec!(88), dec!(90));
let dd = OhlcvBar::max_drawdown(&[b1, b2, b3]).unwrap();
assert!((dd - 0.25).abs() < 1e-9, "expected 0.25, got {dd}");
}
#[test]
fn test_mean_volume_none_when_empty() {
assert!(OhlcvBar::mean_volume(&[]).is_none());
}
#[test]
fn test_mean_volume_single_bar() {
let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
bar.volume = dec!(200);
assert_eq!(OhlcvBar::mean_volume(&[bar]), Some(dec!(200)));
}
#[test]
fn test_mean_volume_multiple_bars() {
let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
b1.volume = dec!(100);
let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
b2.volume = dec!(200);
let mut b3 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
b3.volume = dec!(300);
assert_eq!(OhlcvBar::mean_volume(&[b1, b2, b3]), Some(dec!(200)));
}
#[test]
fn test_vwap_deviation_none_when_vwap_not_set() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
assert!(bar.vwap_deviation().is_none());
}
#[test]
fn test_vwap_deviation_zero_when_close_equals_vwap() {
let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
bar.vwap = Some(dec!(100));
assert_eq!(bar.vwap_deviation(), Some(0.0));
}
#[test]
fn test_vwap_deviation_correct_value() {
let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
bar.vwap = Some(dec!(100));
let dev = bar.vwap_deviation().unwrap();
assert!((dev - 0.1).abs() < 1e-10);
}
#[test]
fn test_high_close_ratio_none_when_high_zero() {
let bar = OhlcvBar {
symbol: "X".into(),
timeframe: Timeframe::Minutes(1),
open: dec!(0),
high: dec!(0),
low: dec!(0),
close: dec!(0),
volume: dec!(1),
bar_start_ms: 0,
trade_count: 1,
is_complete: false,
is_gap_fill: false,
vwap: None,
};
assert!(bar.high_close_ratio().is_none());
}
#[test]
fn test_high_close_ratio_one_when_close_equals_high() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
let ratio = bar.high_close_ratio().unwrap();
assert!((ratio - 1.0).abs() < 1e-10);
}
#[test]
fn test_high_close_ratio_less_than_one_when_close_below_high() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(99));
let ratio = bar.high_close_ratio().unwrap();
assert!(ratio < 1.0);
}
#[test]
fn test_lower_shadow_pct_none_when_range_zero() {
let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
assert!(bar.lower_shadow_pct().is_none());
}
#[test]
fn test_lower_shadow_pct_zero_when_no_lower_shadow() {
let bar = make_ohlcv_bar(dec!(90), dec!(110), dec!(90), dec!(110));
let pct = bar.lower_shadow_pct().unwrap();
assert!(pct.abs() < 1e-10);
}
#[test]
fn test_lower_shadow_pct_correct_value() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let pct = bar.lower_shadow_pct().unwrap();
assert!((pct - 0.5).abs() < 1e-10);
}
#[test]
fn test_open_close_ratio_none_when_open_zero() {
let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
assert!(bar.open_close_ratio().is_none());
}
#[test]
fn test_open_close_ratio_one_when_flat() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let ratio = bar.open_close_ratio().unwrap();
assert!((ratio - 1.0).abs() < 1e-10);
}
#[test]
fn test_open_close_ratio_above_one_for_bullish_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
let ratio = bar.open_close_ratio().unwrap();
assert!(ratio > 1.0);
}
#[test]
fn test_is_wide_range_bar_true_when_range_exceeds_threshold() {
let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(95), dec!(110)); assert!(bar.is_wide_range_bar(dec!(20)));
}
#[test]
fn test_is_wide_range_bar_false_when_range_equals_threshold() {
let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(100), dec!(110)); assert!(!bar.is_wide_range_bar(dec!(20)));
}
#[test]
fn test_close_to_low_ratio_none_when_range_zero() {
let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
assert!(bar.close_to_low_ratio().is_none());
}
#[test]
fn test_close_to_low_ratio_one_when_closed_at_high() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
let ratio = bar.close_to_low_ratio().unwrap();
assert!((ratio - 1.0).abs() < 1e-10);
}
#[test]
fn test_close_to_low_ratio_zero_when_closed_at_low() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90));
let ratio = bar.close_to_low_ratio().unwrap();
assert!(ratio.abs() < 1e-10);
}
#[test]
fn test_close_to_low_ratio_half_at_midpoint() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let ratio = bar.close_to_low_ratio().unwrap();
assert!((ratio - 0.5).abs() < 1e-10);
}
#[test]
fn test_volume_per_trade_none_when_trade_count_zero() {
let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
bar.trade_count = 0;
assert!(bar.volume_per_trade().is_none());
}
#[test]
fn test_volume_per_trade_correct_value() {
let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
bar.volume = dec!(500);
bar.trade_count = 5;
assert_eq!(bar.volume_per_trade(), Some(dec!(100)));
}
#[test]
fn test_price_range_overlap_true_when_ranges_overlap() {
let a = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let b = make_ohlcv_bar(dec!(105), dec!(120), dec!(95), dec!(110));
assert!(a.price_range_overlap(&b));
}
#[test]
fn test_price_range_overlap_false_when_no_overlap() {
let a = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let b = make_ohlcv_bar(dec!(120), dec!(130), dec!(115), dec!(125));
assert!(!a.price_range_overlap(&b));
}
#[test]
fn test_price_range_overlap_true_at_exact_touch() {
let a = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let b = make_ohlcv_bar(dec!(115), dec!(125), dec!(110), dec!(120));
assert!(a.price_range_overlap(&b));
}
#[test]
fn test_bar_height_pct_none_when_open_zero() {
let bar = make_ohlcv_bar(dec!(0), dec!(10), dec!(0), dec!(5));
assert!(bar.bar_height_pct().is_none());
}
#[test]
fn test_bar_height_pct_correct_value() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); let pct = bar.bar_height_pct().unwrap();
assert!((pct - 0.2).abs() < 1e-10);
}
#[test]
fn test_is_bullish_engulfing_true_for_valid_pattern() {
let prev = make_ohlcv_bar(dec!(110), dec!(115), dec!(95), dec!(100));
let bar = make_ohlcv_bar(dec!(98), dec!(115), dec!(95), dec!(112));
assert!(bar.is_bullish_engulfing(&prev));
}
#[test]
fn test_is_bullish_engulfing_false_when_bearish() {
let prev = make_ohlcv_bar(dec!(110), dec!(115), dec!(95), dec!(100));
let bar = make_ohlcv_bar(dec!(108), dec!(115), dec!(95), dec!(95));
assert!(!bar.is_bullish_engulfing(&prev));
}
#[test]
fn test_close_gap_positive_for_gap_up() {
let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
let bar = make_ohlcv_bar(dec!(106), dec!(110), dec!(103), dec!(108)); assert_eq!(bar.close_gap(&prev), dec!(4));
}
#[test]
fn test_close_gap_negative_for_gap_down() {
let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
let bar = make_ohlcv_bar(dec!(98), dec!(100), dec!(95), dec!(97)); assert_eq!(bar.close_gap(&prev), dec!(-4));
}
#[test]
fn test_close_gap_zero_when_no_gap() {
let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(102));
let bar = make_ohlcv_bar(dec!(102), dec!(110), dec!(100), dec!(108));
assert_eq!(bar.close_gap(&prev), dec!(0));
}
#[test]
fn test_close_above_midpoint_true_when_above_mid() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(bar.close_above_midpoint());
}
#[test]
fn test_close_above_midpoint_false_when_at_mid() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); assert!(!bar.close_above_midpoint());
}
#[test]
fn test_close_momentum_positive_when_rising() {
let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
assert_eq!(bar.close_momentum(&prev), dec!(10));
}
#[test]
fn test_close_momentum_zero_when_unchanged() {
let prev = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(100));
assert_eq!(bar.close_momentum(&prev), dec!(0));
}
#[test]
fn test_bar_range_correct() {
let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(110));
assert_eq!(bar.bar_range(), dec!(30));
}
#[test]
fn test_linear_regression_slope_none_for_single_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::linear_regression_slope(&[bar]).is_none());
}
#[test]
fn test_linear_regression_slope_positive_for_rising_closes() {
let bars = vec![
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110)),
make_ohlcv_bar(dec!(110), dec!(120), dec!(105), dec!(120)),
];
let slope = OhlcvBar::linear_regression_slope(&bars).unwrap();
assert!(slope > 0.0, "slope should be positive for rising closes");
}
#[test]
fn test_linear_regression_slope_negative_for_falling_closes() {
let bars = vec![
make_ohlcv_bar(dec!(120), dec!(125), dec!(115), dec!(120)),
make_ohlcv_bar(dec!(120), dec!(115), dec!(105), dec!(110)),
make_ohlcv_bar(dec!(110), dec!(108), dec!(95), dec!(100)),
];
let slope = OhlcvBar::linear_regression_slope(&bars).unwrap();
assert!(slope < 0.0, "slope should be negative for falling closes");
}
#[test]
fn test_linear_regression_slope_near_zero_for_flat_closes() {
let bars = vec![
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
];
let slope = OhlcvBar::linear_regression_slope(&bars).unwrap();
assert!(slope.abs() < 1e-10, "slope should be ~0 for identical closes");
}
#[test]
fn test_volume_slope_none_for_single_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
assert!(OhlcvBar::volume_slope(&[bar]).is_none());
}
#[test]
fn test_volume_slope_positive_for_rising_volume() {
let make_bar_with_vol = |v: u64| {
let mut b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
b.volume = Decimal::from(v);
b
};
let bars = vec![make_bar_with_vol(100), make_bar_with_vol(200), make_bar_with_vol(300)];
assert!(OhlcvBar::volume_slope(&bars).unwrap() > 0.0);
}
#[test]
fn test_highest_close_none_for_empty_slice() {
assert!(OhlcvBar::highest_close(&[]).is_none());
}
#[test]
fn test_highest_close_returns_max_close() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(95), dec!(115));
let b3 = make_ohlcv_bar(dec!(100), dec!(108), dec!(90), dec!(102));
assert_eq!(OhlcvBar::highest_close(&[b1, b2, b3]), Some(dec!(115)));
}
#[test]
fn test_lowest_close_none_for_empty_slice() {
assert!(OhlcvBar::lowest_close(&[]).is_none());
}
#[test]
fn test_lowest_close_returns_min_close() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(95), dec!(115));
let b3 = make_ohlcv_bar(dec!(100), dec!(108), dec!(90), dec!(102));
assert_eq!(OhlcvBar::lowest_close(&[b1, b2, b3]), Some(dec!(102)));
}
#[test]
fn test_close_range_none_for_empty_slice() {
assert!(OhlcvBar::close_range(&[]).is_none());
}
#[test]
fn test_close_range_correct() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(102));
let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(95), dec!(115));
assert_eq!(OhlcvBar::close_range(&[b1, b2]), Some(dec!(13)));
}
#[test]
fn test_momentum_none_for_insufficient_bars() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
assert!(OhlcvBar::momentum(&[bar], 1).is_none());
}
#[test]
fn test_momentum_positive_for_rising_close() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
let mom = OhlcvBar::momentum(&[b1, b2], 1).unwrap();
assert!((mom - 0.1).abs() < 1e-10);
}
#[test]
fn test_momentum_negative_for_falling_close() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
let b2 = make_ohlcv_bar(dec!(100), dec!(108), dec!(88), dec!(99));
let mom = OhlcvBar::momentum(&[b1, b2], 1).unwrap();
assert!(mom < 0.0);
}
#[test]
fn test_mean_close_none_for_empty_slice() {
assert!(OhlcvBar::mean_close(&[]).is_none());
}
#[test]
fn test_mean_close_single_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(OhlcvBar::mean_close(&[bar]), Some(dec!(105)));
}
#[test]
fn test_mean_close_multiple_bars() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
let b3 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(120));
assert_eq!(OhlcvBar::mean_close(&[b1, b2, b3]), Some(dec!(110)));
}
#[test]
fn test_close_std_dev_none_for_single_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
assert!(OhlcvBar::close_std_dev(&[bar]).is_none());
}
#[test]
fn test_close_std_dev_zero_for_identical_closes() {
let bars = vec![
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
];
let sd = OhlcvBar::close_std_dev(&bars).unwrap();
assert!(sd.abs() < 1e-10, "std_dev should be ~0 for identical closes");
}
#[test]
fn test_close_std_dev_positive_for_varied_closes() {
let bars = vec![
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90)),
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)),
];
assert!(OhlcvBar::close_std_dev(&bars).unwrap() > 0.0);
}
#[test]
fn test_price_efficiency_ratio_none_for_single_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
assert!(OhlcvBar::price_efficiency_ratio(&[bar]).is_none());
}
#[test]
fn test_price_efficiency_ratio_one_for_trending_price() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(100), dec!(110));
let b3 = make_ohlcv_bar(dec!(120), dec!(130), dec!(110), dec!(120));
let ratio = OhlcvBar::price_efficiency_ratio(&[b1, b2, b3]).unwrap();
assert!(ratio > 0.0 && ratio <= 1.0);
}
#[test]
fn test_price_efficiency_ratio_none_for_zero_total_range() {
let bars = vec![
make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100)),
make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100)),
];
assert!(OhlcvBar::price_efficiency_ratio(&bars).is_none());
}
#[test]
fn test_clv_plus_one_when_close_at_high() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
let clv = bar.close_location_value().unwrap();
assert!((clv - 1.0).abs() < 1e-10, "CLV should be 1.0 when close == high, got {clv}");
}
#[test]
fn test_clv_minus_one_when_close_at_low() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90));
let clv = bar.close_location_value().unwrap();
assert!((clv + 1.0).abs() < 1e-10, "CLV should be -1.0 when close == low, got {clv}");
}
#[test]
fn test_clv_zero_when_close_at_midpoint() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let clv = bar.close_location_value().unwrap();
assert!(clv.abs() < 1e-10, "CLV should be 0 at midpoint, got {clv}");
}
#[test]
fn test_clv_none_for_zero_range_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
assert!(bar.close_location_value().is_none());
}
#[test]
fn test_mean_clv_none_for_empty_slice() {
assert!(OhlcvBar::mean_clv(&[]).is_none());
}
#[test]
fn test_mean_clv_positive_for_bullish_closes() {
let bars = vec![
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108)), make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(106)), ];
let clv = OhlcvBar::mean_clv(&bars).unwrap();
assert!(clv > 0.0, "mean CLV should be positive when closes are near highs");
}
#[test]
fn test_mean_range_none_for_empty_slice() {
assert!(OhlcvBar::mean_range(&[]).is_none());
}
#[test]
fn test_mean_range_single_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(OhlcvBar::mean_range(&[bar]), Some(dec!(20)));
}
#[test]
fn test_mean_range_multiple_bars() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(100)); assert_eq!(OhlcvBar::mean_range(&[b1, b2]), Some(dec!(30)));
}
#[test]
fn test_close_z_score_none_for_empty_slice() {
assert!(OhlcvBar::close_z_score(&[], dec!(100)).is_none());
}
#[test]
fn test_close_z_score_of_mean_is_zero() {
let bars = vec![
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)),
];
let mean = (dec!(100) + dec!(100) + dec!(110)) / dec!(3);
let z = OhlcvBar::close_z_score(&bars, mean).unwrap();
assert!(z.abs() < 1e-6);
}
#[test]
fn test_close_z_score_positive_above_mean() {
let bars = vec![
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)),
];
let z = OhlcvBar::close_z_score(&bars, dec!(120)).unwrap();
assert!(z > 0.0);
}
#[test]
fn test_bollinger_band_width_none_for_empty_slice() {
assert!(OhlcvBar::bollinger_band_width(&[]).is_none());
}
#[test]
fn test_bollinger_band_width_zero_for_identical_closes() {
let bars = vec![
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
];
assert_eq!(OhlcvBar::bollinger_band_width(&bars), Some(0.0));
}
#[test]
fn test_bollinger_band_width_positive_for_varying_closes() {
let bars = vec![
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90)),
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)),
];
let bw = OhlcvBar::bollinger_band_width(&bars).unwrap();
assert!(bw > 0.0);
}
#[test]
fn test_up_down_ratio_none_for_no_bearish_bars() {
let bars = vec![
make_ohlcv_bar(dec!(90), dec!(110), dec!(85), dec!(105)), ];
assert!(OhlcvBar::up_down_ratio(&bars).is_none());
}
#[test]
fn test_up_down_ratio_two_to_one() {
let bull = make_ohlcv_bar(dec!(90), dec!(110), dec!(85), dec!(105));
let bear = make_ohlcv_bar(dec!(110), dec!(115), dec!(85), dec!(95));
let bars = vec![bull.clone(), bull, bear];
let ratio = OhlcvBar::up_down_ratio(&bars).unwrap();
assert!((ratio - 2.0).abs() < 1e-9);
}
#[test]
fn test_volume_weighted_close_none_for_empty_slice() {
assert!(OhlcvBar::volume_weighted_close(&[]).is_none());
}
#[test]
fn test_volume_weighted_close_single_bar() {
let mut bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
bar.volume = dec!(10);
assert_eq!(OhlcvBar::volume_weighted_close(&[bar]), Some(dec!(105)));
}
#[test]
fn test_volume_weighted_close_weights_by_volume() {
let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
b1.volume = dec!(1);
let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(200));
b2.volume = dec!(3);
assert_eq!(OhlcvBar::volume_weighted_close(&[b1, b2]), Some(dec!(175)));
}
#[test]
fn test_rolling_return_none_for_empty_slice() {
assert!(OhlcvBar::rolling_return(&[]).is_none());
}
#[test]
fn test_rolling_return_none_for_single_bar() {
assert!(OhlcvBar::rolling_return(&[make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105))]).is_none());
}
#[test]
fn test_rolling_return_positive_when_close_rises() {
let b1 = make_ohlcv_bar(dec!(100), dec!(105), dec!(95), dec!(100));
let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
let ret = OhlcvBar::rolling_return(&[b1, b2]).unwrap();
assert!((ret - 0.1).abs() < 1e-9);
}
#[test]
fn test_average_high_none_for_empty_slice() {
assert!(OhlcvBar::average_high(&[]).is_none());
}
#[test]
fn test_average_high_single_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(105));
assert_eq!(OhlcvBar::average_high(&[bar]), Some(dec!(120)));
}
#[test]
fn test_average_high_multiple_bars() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(100), dec!(130), dec!(90), dec!(105));
assert_eq!(OhlcvBar::average_high(&[b1, b2]), Some(dec!(120)));
}
#[test]
fn test_average_low_none_for_empty_slice() {
assert!(OhlcvBar::average_low(&[]).is_none());
}
#[test]
fn test_average_low_single_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(105));
assert_eq!(OhlcvBar::average_low(&[bar]), Some(dec!(80)));
}
#[test]
fn test_average_low_multiple_bars() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(80), dec!(105));
let b2 = make_ohlcv_bar(dec!(100), dec!(130), dec!(60), dec!(105));
assert_eq!(OhlcvBar::average_low(&[b1, b2]), Some(dec!(70)));
}
#[test]
fn test_min_body_none_for_empty_slice() {
assert!(OhlcvBar::min_body(&[]).is_none());
}
#[test]
fn test_min_body_returns_smallest_body() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(115)); assert_eq!(OhlcvBar::min_body(&[b1, b2]), Some(dec!(5)));
}
#[test]
fn test_max_body_none_for_empty_slice() {
assert!(OhlcvBar::max_body(&[]).is_none());
}
#[test]
fn test_max_body_returns_largest_body() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(115)); assert_eq!(OhlcvBar::max_body(&[b1, b2]), Some(dec!(15)));
}
#[test]
fn test_atr_pct_none_for_single_bar() {
assert!(OhlcvBar::atr_pct(&[make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105))]).is_none());
}
#[test]
fn test_atr_pct_positive_for_normal_bars() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let pct = OhlcvBar::atr_pct(&[b1, b2]).unwrap();
assert!(pct > 0.0);
}
#[test]
fn test_breakout_count_zero_for_empty_slice() {
assert_eq!(OhlcvBar::breakout_count(&[]), 0);
}
#[test]
fn test_breakout_count_zero_for_single_bar() {
assert_eq!(OhlcvBar::breakout_count(&[make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105))]), 0);
}
#[test]
fn test_breakout_count_detects_close_above_prev_high() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
let b2 = make_ohlcv_bar(dec!(108), dec!(120), dec!(105), dec!(115));
assert_eq!(OhlcvBar::breakout_count(&[b1, b2]), 1);
}
#[test]
fn test_breakout_count_zero_when_close_at_prev_high() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
let b2 = make_ohlcv_bar(dec!(108), dec!(115), dec!(105), dec!(110));
assert_eq!(OhlcvBar::breakout_count(&[b1, b2]), 0);
}
#[test]
fn test_doji_count_zero_for_empty_slice() {
assert_eq!(OhlcvBar::doji_count(&[], dec!(0.001)), 0);
}
#[test]
fn test_doji_count_detects_doji_bars() {
let doji = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); let non_doji = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)); assert_eq!(OhlcvBar::doji_count(&[doji, non_doji], dec!(1)), 1);
}
#[test]
fn test_channel_width_none_for_empty_slice() {
assert!(OhlcvBar::channel_width(&[]).is_none());
}
#[test]
fn test_channel_width_correct() {
let b1 = make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(80), dec!(100));
assert_eq!(OhlcvBar::channel_width(&[b1, b2]), Some(dec!(40)));
}
#[test]
fn test_sma_none_for_zero_period() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::sma(&[bar], 0).is_none());
}
#[test]
fn test_sma_none_when_fewer_bars_than_period() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::sma(&[bar], 3).is_none());
}
#[test]
fn test_sma_correct_for_last_n_bars() {
let bars = vec![
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)),
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110)),
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(120)),
];
assert_eq!(OhlcvBar::sma(&bars, 3), Some(dec!(110)));
}
#[test]
fn test_mean_wick_ratio_none_for_empty_slice() {
assert!(OhlcvBar::mean_wick_ratio(&[]).is_none());
}
#[test]
fn test_mean_wick_ratio_in_range_zero_to_one() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(85), dec!(100));
let ratio = OhlcvBar::mean_wick_ratio(&[b1, b2]).unwrap();
assert!(ratio >= 0.0 && ratio <= 1.0);
}
#[test]
fn test_bullish_volume_zero_for_empty_slice() {
assert_eq!(OhlcvBar::bullish_volume(&[]), dec!(0));
}
#[test]
fn test_bullish_volume_sums_bullish_bars() {
let mut bull = make_ohlcv_bar(dec!(90), dec!(110), dec!(85), dec!(105));
bull.volume = dec!(100);
let mut bear = make_ohlcv_bar(dec!(110), dec!(115), dec!(85), dec!(95));
bear.volume = dec!(50);
assert_eq!(OhlcvBar::bullish_volume(&[bull, bear]), dec!(100));
}
#[test]
fn test_bearish_volume_zero_for_empty_slice() {
assert_eq!(OhlcvBar::bearish_volume(&[]), dec!(0));
}
#[test]
fn test_bearish_volume_sums_bearish_bars() {
let mut bull = make_ohlcv_bar(dec!(90), dec!(110), dec!(85), dec!(105));
bull.volume = dec!(100);
let mut bear = make_ohlcv_bar(dec!(110), dec!(115), dec!(85), dec!(95));
bear.volume = dec!(50);
assert_eq!(OhlcvBar::bearish_volume(&[bull, bear]), dec!(50));
}
#[test]
fn test_close_above_mid_count_zero_for_empty_slice() {
assert_eq!(OhlcvBar::close_above_mid_count(&[]), 0);
}
#[test]
fn test_close_above_mid_count_correct() {
let above_mid = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110)); let at_mid = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(100)); let below_mid = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(85)); assert_eq!(OhlcvBar::close_above_mid_count(&[above_mid, at_mid, below_mid]), 1);
}
#[test]
fn test_ema_none_for_empty_slice() {
assert!(OhlcvBar::ema(&[], 0.5).is_none());
}
#[test]
fn test_ema_single_bar_equals_close() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let e = OhlcvBar::ema(&[bar], 0.5).unwrap();
assert!((e - 105.0).abs() < 1e-9);
}
#[test]
fn test_ema_alpha_one_equals_last_close() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(200));
let e = OhlcvBar::ema(&[b1, b2], 1.0).unwrap();
assert!((e - 200.0).abs() < 1e-9);
}
#[test]
fn test_highest_open_none_for_empty_slice() {
assert!(OhlcvBar::highest_open(&[]).is_none());
}
#[test]
fn test_highest_open_returns_max() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(130), dec!(140), dec!(120), dec!(135));
assert_eq!(OhlcvBar::highest_open(&[b1, b2]), Some(dec!(130)));
}
#[test]
fn test_lowest_open_none_for_empty_slice() {
assert!(OhlcvBar::lowest_open(&[]).is_none());
}
#[test]
fn test_lowest_open_returns_min() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(130), dec!(140), dec!(120), dec!(135));
assert_eq!(OhlcvBar::lowest_open(&[b1, b2]), Some(dec!(100)));
}
#[test]
fn test_rising_close_count_zero_for_empty_slice() {
assert_eq!(OhlcvBar::rising_close_count(&[]), 0);
}
#[test]
fn test_rising_close_count_zero_for_single_bar() {
assert_eq!(OhlcvBar::rising_close_count(&[make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105))]), 0);
}
#[test]
fn test_rising_close_count_correct() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(90), dec!(110)); let b3 = make_ohlcv_bar(dec!(100), dec!(115), dec!(90), dec!(105)); let b4 = make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(115)); assert_eq!(OhlcvBar::rising_close_count(&[b1, b2, b3, b4]), 2);
}
#[test]
fn test_mean_body_ratio_none_for_empty_slice() {
assert!(OhlcvBar::mean_body_ratio(&[]).is_none());
}
#[test]
fn test_mean_body_ratio_in_range_zero_to_one() {
let b1 = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110));
let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(100));
let ratio = OhlcvBar::mean_body_ratio(&[b1, b2]).unwrap();
assert!(ratio >= 0.0 && ratio <= 1.0);
}
#[test]
fn test_volume_std_dev_none_for_single_bar() {
let mut b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
b.volume = dec!(100);
assert!(OhlcvBar::volume_std_dev(&[b]).is_none());
}
#[test]
fn test_volume_std_dev_zero_for_identical_volumes() {
let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(50);
let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b2.volume = dec!(50);
assert_eq!(OhlcvBar::volume_std_dev(&[b1, b2]), Some(0.0));
}
#[test]
fn test_volume_std_dev_positive_for_varied_volumes() {
let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(10);
let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b2.volume = dec!(100);
let std = OhlcvBar::volume_std_dev(&[b1, b2]).unwrap();
assert!(std > 0.0);
}
#[test]
fn test_max_volume_bar_none_for_empty_slice() {
assert!(OhlcvBar::max_volume_bar(&[]).is_none());
}
#[test]
fn test_max_volume_bar_returns_highest_volume() {
let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(10);
let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b2.volume = dec!(100);
let bars = [b1, b2];
let bar = OhlcvBar::max_volume_bar(&bars).unwrap();
assert_eq!(bar.volume, dec!(100));
}
#[test]
fn test_min_volume_bar_returns_lowest_volume() {
let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(10);
let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b2.volume = dec!(100);
let bars = [b1, b2];
let bar = OhlcvBar::min_volume_bar(&bars).unwrap();
assert_eq!(bar.volume, dec!(10));
}
#[test]
fn test_gap_sum_zero_for_empty_slice() {
assert_eq!(OhlcvBar::gap_sum(&[]), dec!(0));
}
#[test]
fn test_gap_sum_zero_for_single_bar() {
assert_eq!(OhlcvBar::gap_sum(&[make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105))]), dec!(0));
}
#[test]
fn test_gap_sum_positive_for_gap_up_sequence() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(105), dec!(115));
assert_eq!(OhlcvBar::gap_sum(&[b1, b2]), dec!(10));
}
#[test]
fn test_gap_sum_negative_for_gap_down_sequence() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let b2 = make_ohlcv_bar(dec!(90), dec!(95), dec!(80), dec!(85));
assert_eq!(OhlcvBar::gap_sum(&[b1, b2]), dec!(-10));
}
#[test]
fn test_three_white_soldiers_false_for_fewer_than_3_bars() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(!OhlcvBar::three_white_soldiers(&[b]));
}
#[test]
fn test_three_white_soldiers_true_for_classic_pattern() {
let b1 = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(112));
let b2 = make_ohlcv_bar(dec!(112), dec!(128), dec!(110), dec!(125));
let b3 = make_ohlcv_bar(dec!(125), dec!(142), dec!(123), dec!(140));
assert!(OhlcvBar::three_white_soldiers(&[b1, b2, b3]));
}
#[test]
fn test_three_white_soldiers_false_for_bearish_bar_in_sequence() {
let b1 = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(112));
let b2 = make_ohlcv_bar(dec!(115), dec!(120), dec!(105), dec!(108));
let b3 = make_ohlcv_bar(dec!(108), dec!(130), dec!(106), dec!(128));
assert!(!OhlcvBar::three_white_soldiers(&[b1, b2, b3]));
}
#[test]
fn test_three_black_crows_false_for_fewer_than_3_bars() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(95));
assert!(!OhlcvBar::three_black_crows(&[b]));
}
#[test]
fn test_three_black_crows_true_for_classic_pattern() {
let b1 = make_ohlcv_bar(dec!(140), dec!(142), dec!(110), dec!(112));
let b2 = make_ohlcv_bar(dec!(112), dec!(114), dec!(95), dec!(97));
let b3 = make_ohlcv_bar(dec!(97), dec!(99), dec!(80), dec!(82));
assert!(OhlcvBar::three_black_crows(&[b1, b2, b3]));
}
#[test]
fn test_three_black_crows_false_for_bullish_bar_in_sequence() {
let b1 = make_ohlcv_bar(dec!(140), dec!(142), dec!(110), dec!(112));
let b2 = make_ohlcv_bar(dec!(108), dec!(120), dec!(106), dec!(118));
let b3 = make_ohlcv_bar(dec!(115), dec!(116), dec!(90), dec!(92));
assert!(!OhlcvBar::three_black_crows(&[b1, b2, b3]));
}
#[test]
fn test_is_gap_bar_true_when_open_differs_from_prev_close() {
let bar = make_ohlcv_bar(dec!(105), dec!(115), dec!(100), dec!(110));
assert!(OhlcvBar::is_gap_bar(&bar, dec!(100)));
}
#[test]
fn test_is_gap_bar_false_when_open_equals_prev_close() {
let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
assert!(!OhlcvBar::is_gap_bar(&bar, dec!(100)));
}
#[test]
fn test_gap_bars_count_zero_for_empty_slice() {
assert_eq!(OhlcvBar::gap_bars_count(&[]), 0);
}
#[test]
fn test_gap_bars_count_zero_when_no_gaps() {
let b1 = make_ohlcv_bar(dec!(90), dec!(105), dec!(88), dec!(100));
let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
assert_eq!(OhlcvBar::gap_bars_count(&[b1, b2]), 0);
}
#[test]
fn test_gap_bars_count_counts_all_gaps() {
let b1 = make_ohlcv_bar(dec!(90), dec!(105), dec!(88), dec!(100));
let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110)); let b3 = make_ohlcv_bar(dec!(110), dec!(120), dec!(108), dec!(115)); let b4 = make_ohlcv_bar(dec!(120), dec!(130), dec!(118), dec!(128)); assert_eq!(OhlcvBar::gap_bars_count(&[b1, b2, b3, b4]), 2);
}
#[test]
fn test_inside_bar_true_when_range_inside_prior_v2() {
let prior = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110));
let bar = make_ohlcv_bar(dec!(105), dec!(115), dec!(90), dec!(108));
assert!(bar.inside_bar(&prior));
}
#[test]
fn test_inside_bar_false_when_high_exceeds_prior_v2() {
let prior = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110));
let bar = make_ohlcv_bar(dec!(105), dec!(125), dec!(90), dec!(118));
assert!(!bar.inside_bar(&prior));
}
#[test]
fn test_outside_bar_true_when_range_engulfs_prior_v2() {
let prior = make_ohlcv_bar(dec!(100), dec!(115), dec!(90), dec!(108));
let bar = make_ohlcv_bar(dec!(95), dec!(120), dec!(85), dec!(112));
assert!(bar.outside_bar(&prior));
}
#[test]
fn test_outside_bar_false_when_range_is_inside_v2() {
let prior = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110));
let bar = make_ohlcv_bar(dec!(105), dec!(115), dec!(90), dec!(108));
assert!(!bar.outside_bar(&prior));
}
#[test]
fn test_bar_efficiency_none_for_zero_range_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
assert!(OhlcvBar::bar_efficiency(&bar).is_none());
}
#[test]
fn test_bar_efficiency_one_for_full_trend_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
let eff = OhlcvBar::bar_efficiency(&bar).unwrap();
assert!((eff - 1.0).abs() < 1e-9);
}
#[test]
fn test_bar_efficiency_between_zero_and_one() {
let bar = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(108));
let eff = OhlcvBar::bar_efficiency(&bar).unwrap();
assert!(eff >= 0.0 && eff <= 1.0);
}
#[test]
fn test_wicks_sum_zero_for_empty_slice() {
assert_eq!(OhlcvBar::wicks_sum(&[]), dec!(0));
}
#[test]
fn test_wicks_sum_correct_for_doji_like_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
assert_eq!(OhlcvBar::wicks_sum(&[bar]), dec!(20));
}
#[test]
fn test_avg_close_to_high_none_for_empty_slice() {
assert!(OhlcvBar::avg_close_to_high(&[]).is_none());
}
#[test]
fn test_avg_close_to_high_correct_for_two_bars() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(105));
let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(108), dec!(115));
let avg = OhlcvBar::avg_close_to_high(&[b1, b2]).unwrap();
assert!((avg - 5.0).abs() < 1e-9);
}
#[test]
fn test_avg_range_r65_none_for_empty() {
assert!(OhlcvBar::avg_range(&[]).is_none());
}
#[test]
fn test_avg_range_r65_correct() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(105), dec!(120), dec!(100), dec!(115));
let avg = OhlcvBar::avg_range(&[b1, b2]).unwrap();
assert!((avg - 20.0).abs() < 1e-9);
}
#[test]
fn test_max_close_r65_none_empty() {
assert!(OhlcvBar::max_close(&[]).is_none());
}
#[test]
fn test_max_close_r65_highest() {
let b1 = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
let b2 = make_ohlcv_bar(dec!(110), dec!(130), dec!(108), dec!(125));
let b3 = make_ohlcv_bar(dec!(115), dec!(120), dec!(112), dec!(118));
assert_eq!(OhlcvBar::max_close(&[b1, b2, b3]), Some(dec!(125)));
}
#[test]
fn test_min_close_r65_lowest() {
let b1 = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110));
let b2 = make_ohlcv_bar(dec!(110), dec!(130), dec!(108), dec!(125));
let b3 = make_ohlcv_bar(dec!(90), dec!(105), dec!(88), dec!(95));
assert_eq!(OhlcvBar::min_close(&[b1, b2, b3]), Some(dec!(95)));
}
#[test]
fn test_trend_strength_r65_none_single() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::trend_strength(&[b]).is_none());
}
#[test]
fn test_trend_strength_r65_one_bullish() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(105));
let b2 = make_ohlcv_bar(dec!(105), dec!(120), dec!(103), dec!(115));
let b3 = make_ohlcv_bar(dec!(115), dec!(130), dec!(113), dec!(128));
let s = OhlcvBar::trend_strength(&[b1, b2, b3]).unwrap();
assert!((s - 1.0).abs() < 1e-9);
}
#[test]
fn test_trend_strength_r65_zero_bearish() {
let b1 = make_ohlcv_bar(dec!(128), dec!(130), dec!(113), dec!(128));
let b2 = make_ohlcv_bar(dec!(115), dec!(120), dec!(103), dec!(110));
let b3 = make_ohlcv_bar(dec!(105), dec!(110), dec!(95), dec!(100));
let s = OhlcvBar::trend_strength(&[b1, b2, b3]).unwrap();
assert!((s - 0.0).abs() < 1e-9);
}
#[test]
fn test_net_change_none_for_empty() {
assert!(OhlcvBar::net_change(&[]).is_none());
}
#[test]
fn test_net_change_positive_for_bullish_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(112));
assert_eq!(OhlcvBar::net_change(&[b]), Some(dec!(12)));
}
#[test]
fn test_net_change_negative_for_bearish_bar() {
let b = make_ohlcv_bar(dec!(110), dec!(112), dec!(95), dec!(100));
assert_eq!(OhlcvBar::net_change(&[b]), Some(dec!(-10)));
}
#[test]
fn test_open_to_close_pct_none_for_empty() {
assert!(OhlcvBar::open_to_close_pct(&[]).is_none());
}
#[test]
fn test_open_to_close_pct_correct() {
let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
let pct = OhlcvBar::open_to_close_pct(&[b]).unwrap();
assert!((pct - 10.0).abs() < 1e-9);
}
#[test]
fn test_high_to_low_pct_none_for_empty() {
assert!(OhlcvBar::high_to_low_pct(&[]).is_none());
}
#[test]
fn test_high_to_low_pct_correct() {
let b = make_ohlcv_bar(dec!(150), dec!(200), dec!(100), dec!(160));
let pct = OhlcvBar::high_to_low_pct(&[b]).unwrap();
assert!((pct - 50.0).abs() < 1e-9);
}
#[test]
fn test_consecutive_highs_zero_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(OhlcvBar::consecutive_highs(&[b]), 0);
}
#[test]
fn test_consecutive_highs_counts_trailing_highs() {
let b1 = make_ohlcv_bar(dec!(95), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(105), dec!(120), dec!(103), dec!(115));
let b3 = make_ohlcv_bar(dec!(115), dec!(130), dec!(113), dec!(125));
assert_eq!(OhlcvBar::consecutive_highs(&[b1, b2, b3]), 2);
}
#[test]
fn test_consecutive_lows_counts_trailing_lows() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(95), dec!(108), dec!(80), dec!(100));
let b3 = make_ohlcv_bar(dec!(90), dec!(102), dec!(70), dec!(95));
assert_eq!(OhlcvBar::consecutive_lows(&[b1, b2, b3]), 2);
}
#[test]
fn test_volume_change_pct_none_for_single_bar() {
let mut b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
b.volume = dec!(100);
assert!(OhlcvBar::volume_change_pct(&[b]).is_none());
}
#[test]
fn test_volume_change_pct_correct() {
let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(100);
let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110)); b2.volume = dec!(150);
let pct = OhlcvBar::volume_change_pct(&[b1, b2]).unwrap();
assert!((pct - 50.0).abs() < 1e-9);
}
#[test]
fn test_clv_r67_plus_one_at_high() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
let clv = b.close_location_value().unwrap();
assert!((clv - 1.0).abs() < 1e-9);
}
#[test]
fn test_clv_r67_minus_one_at_low() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90));
let clv = b.close_location_value().unwrap();
assert!((clv - (-1.0)).abs() < 1e-9);
}
#[test]
fn test_clv_r67_none_for_zero_range() {
let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
assert!(b.close_location_value().is_none());
}
#[test]
fn test_body_pct_r67_none_for_zero_range() {
let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
assert!(b.body_pct().is_none());
}
#[test]
fn test_body_pct_r67_100_for_full_body() {
let b = make_ohlcv_bar(dec!(90), dec!(110), dec!(90), dec!(110));
assert_eq!(b.body_pct(), Some(dec!(100)));
}
#[test]
fn test_bullish_count_r67_correct() {
let b1 = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(112)); let b2 = make_ohlcv_bar(dec!(112), dec!(120), dec!(105), dec!(108)); let b3 = make_ohlcv_bar(dec!(108), dec!(125), dec!(106), dec!(120)); assert_eq!(OhlcvBar::bullish_count(&[b1, b2, b3]), 2);
}
#[test]
fn test_bearish_count_r67_correct() {
let b1 = make_ohlcv_bar(dec!(115), dec!(118), dec!(100), dec!(105)); let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(112)); assert_eq!(OhlcvBar::bearish_count(&[b1, b2]), 1);
}
#[test]
fn test_open_gap_pct_none_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::open_gap_pct(&[b]).is_none());
}
#[test]
fn test_open_gap_pct_positive_for_gap_up() {
let b1 = make_ohlcv_bar(dec!(90), dec!(105), dec!(88), dec!(100));
let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110));
let pct = OhlcvBar::open_gap_pct(&[b1, b2]).unwrap();
assert!((pct - 5.0).abs() < 1e-9);
}
#[test]
fn test_volume_cumulative_zero_for_empty() {
assert_eq!(OhlcvBar::volume_cumulative(&[]), dec!(0));
}
#[test]
fn test_volume_cumulative_sums_all_volumes() {
let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(100);
let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110)); b2.volume = dec!(200);
assert_eq!(OhlcvBar::volume_cumulative(&[b1, b2]), dec!(300));
}
#[test]
fn test_price_position_none_for_empty() {
assert!(OhlcvBar::price_position(&[]).is_none());
}
#[test]
fn test_price_position_one_when_close_at_highest() {
let b1 = make_ohlcv_bar(dec!(85), dec!(100), dec!(80), dec!(95));
let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(100), dec!(120));
let pos = OhlcvBar::price_position(&[b1, b2]).unwrap();
assert!((pos - 1.0).abs() < 1e-9);
}
#[test]
fn test_close_above_open_count_zero_for_empty() {
assert_eq!(OhlcvBar::close_above_open_count(&[]), 0);
}
#[test]
fn test_close_above_open_count_correct() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(108));
let b2 = make_ohlcv_bar(dec!(110), dec!(115), dec!(100), dec!(102));
assert_eq!(OhlcvBar::close_above_open_count(&[b1, b2]), 1);
}
#[test]
fn test_volume_price_correlation_none_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::volume_price_correlation(&[b]).is_none());
}
#[test]
fn test_volume_price_correlation_positive_for_comoving() {
let mut b1 = make_ohlcv_bar(dec!(100), dec!(105), dec!(98), dec!(102)); b1.volume = dec!(100);
let mut b2 = make_ohlcv_bar(dec!(102), dec!(110), dec!(100), dec!(108)); b2.volume = dec!(200);
let corr = OhlcvBar::volume_price_correlation(&[b1, b2]).unwrap();
assert!(corr > 0.0, "expected positive correlation, got {}", corr);
}
#[test]
fn test_body_consistency_none_for_empty() {
assert!(OhlcvBar::body_consistency(&[]).is_none());
}
#[test]
fn test_body_consistency_one_for_all_big_bodies() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(108));
let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(110), dec!(118));
let r = OhlcvBar::body_consistency(&[b1, b2]).unwrap();
assert!((r - 1.0).abs() < 1e-9, "expected 1.0, got {}", r);
}
#[test]
fn test_close_volatility_ratio_none_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::close_volatility_ratio(&[b]).is_none());
}
#[test]
fn test_close_volatility_ratio_positive_for_varied_closes() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(105), dec!(115));
let r = OhlcvBar::close_volatility_ratio(&[b1, b2]).unwrap();
assert!(r > 0.0, "expected positive ratio, got {}", r);
}
#[test]
fn test_close_volatility_ratio_zero_for_identical_closes() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(100), dec!(112), dec!(88), dec!(105));
let r = OhlcvBar::close_volatility_ratio(&[b1, b2]).unwrap();
assert!((r - 0.0).abs() < 1e-9, "expected 0.0 for identical closes, got {}", r);
}
#[test]
fn test_is_trending_up_false_for_empty() {
assert!(!OhlcvBar::is_trending_up(&[], 3));
}
#[test]
fn test_is_trending_up_false_for_n_less_than_2() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(!OhlcvBar::is_trending_up(&[b], 1));
}
#[test]
fn test_is_trending_up_true_for_rising_closes() {
let b1 = make_ohlcv_bar(dec!(100), dec!(105), dec!(98), dec!(102));
let b2 = make_ohlcv_bar(dec!(102), dec!(110), dec!(100), dec!(107));
let b3 = make_ohlcv_bar(dec!(107), dec!(115), dec!(105), dec!(112));
assert!(OhlcvBar::is_trending_up(&[b1, b2, b3], 3));
}
#[test]
fn test_is_trending_down_true_for_falling_closes() {
let b1 = make_ohlcv_bar(dec!(112), dec!(115), dec!(105), dec!(110));
let b2 = make_ohlcv_bar(dec!(110), dec!(112), dec!(100), dec!(105));
let b3 = make_ohlcv_bar(dec!(105), dec!(108), dec!(95), dec!(98));
assert!(OhlcvBar::is_trending_down(&[b1, b2, b3], 3));
}
#[test]
fn test_volume_acceleration_none_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::volume_acceleration(&[b]).is_none());
}
#[test]
fn test_volume_acceleration_positive_when_volume_rises() {
let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(100);
let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110)); b2.volume = dec!(150);
let acc = OhlcvBar::volume_acceleration(&[b1, b2]).unwrap();
assert!(acc > 0.0, "volume rose so acceleration should be positive, got {}", acc);
}
#[test]
fn test_wick_body_ratio_none_for_empty() {
assert!(OhlcvBar::wick_body_ratio(&[]).is_none());
}
#[test]
fn test_wick_body_ratio_none_for_doji_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
assert!(OhlcvBar::wick_body_ratio(&[b]).is_none());
}
#[test]
fn test_wick_body_ratio_positive_for_wicked_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(105));
let r = OhlcvBar::wick_body_ratio(&[b]).unwrap();
assert!(r > 0.0, "expected positive wick/body ratio, got {}", r);
}
#[test]
fn test_close_momentum_score_none_for_empty() {
assert!(OhlcvBar::close_momentum_score(&[]).is_none());
}
#[test]
fn test_close_momentum_score_half_for_symmetric() {
let b1 = make_ohlcv_bar(dec!(88), dec!(95), dec!(85), dec!(90));
let b2 = make_ohlcv_bar(dec!(108), dec!(115), dec!(105), dec!(110));
let score = OhlcvBar::close_momentum_score(&[b1, b2]).unwrap();
assert!((score - 0.5).abs() < 1e-9, "expected 0.5, got {}", score);
}
#[test]
fn test_range_expansion_count_zero_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(OhlcvBar::range_expansion_count(&[b]), 0);
}
#[test]
fn test_range_expansion_count_correct() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(105), dec!(120), dec!(90), dec!(110));
assert_eq!(OhlcvBar::range_expansion_count(&[b1, b2]), 1);
}
#[test]
fn test_gap_count_zero_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(OhlcvBar::gap_count(&[b]), 0);
}
#[test]
fn test_gap_count_detects_gap() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(108), dec!(115), dec!(106), dec!(112));
assert_eq!(OhlcvBar::gap_count(&[b1, b2]), 1);
}
#[test]
fn test_gap_count_zero_when_open_equals_close() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(112));
assert_eq!(OhlcvBar::gap_count(&[b1, b2]), 0);
}
#[test]
fn test_avg_wick_size_none_for_empty() {
assert!(OhlcvBar::avg_wick_size(&[]).is_none());
}
#[test]
fn test_avg_wick_size_correct() {
let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(105));
let ws = OhlcvBar::avg_wick_size(&[b]).unwrap();
assert!((ws - 15.0).abs() < 1e-6, "expected 15.0, got {}", ws);
}
#[test]
fn test_mean_volume_ratio_empty_for_empty_slice() {
assert!(OhlcvBar::mean_volume_ratio(&[]).is_empty());
}
#[test]
fn test_mean_volume_ratio_sums_to_n_times_mean() {
let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(100);
let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110)); b2.volume = dec!(300);
let ratios = OhlcvBar::mean_volume_ratio(&[b1, b2]);
assert_eq!(ratios.len(), 2);
let r0 = ratios[0].unwrap();
let r1 = ratios[1].unwrap();
assert!((r0 - 0.5).abs() < 1e-6, "expected 0.5, got {}", r0);
assert!((r1 - 1.5).abs() < 1e-6, "expected 1.5, got {}", r1);
}
#[test]
fn test_price_compression_ratio_none_for_zero_range() {
let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
assert!(OhlcvBar::price_compression_ratio(&[b]).is_none());
}
#[test]
fn test_price_compression_ratio_in_range() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
let r = OhlcvBar::price_compression_ratio(&[b]).unwrap();
assert!(r >= 0.0 && r <= 1.0, "expected value in [0,1], got {}", r);
}
#[test]
fn test_open_close_spread_none_for_empty() {
assert!(OhlcvBar::open_close_spread(&[]).is_none());
}
#[test]
fn test_open_close_spread_zero_for_doji() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let s = OhlcvBar::open_close_spread(&[b]).unwrap();
assert!((s - 0.0).abs() < 1e-9, "doji should have spread=0, got {}", s);
}
#[test]
fn test_open_close_spread_positive_for_directional_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
let s = OhlcvBar::open_close_spread(&[b]).unwrap();
assert!(s > 0.0, "directional bar should have positive spread, got {}", s);
}
#[test]
fn test_close_above_high_ma_zero_for_too_few_bars() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(OhlcvBar::close_above_high_ma(&[b], 2), 0);
}
#[test]
fn test_close_above_high_ma_detects_breakout() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(108), dec!(118));
assert_eq!(OhlcvBar::close_above_high_ma(&[b1, b2], 2), 1);
}
#[test]
fn test_max_consecutive_gains_zero_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(OhlcvBar::max_consecutive_gains(&[b]), 0);
}
#[test]
fn test_max_consecutive_gains_correct() {
let b1 = make_ohlcv_bar(dec!(98), dec!(102), dec!(96), dec!(100));
let b2 = make_ohlcv_bar(dec!(100), dec!(108), dec!(99), dec!(105));
let b3 = make_ohlcv_bar(dec!(105), dec!(112), dec!(104), dec!(110));
let b4 = make_ohlcv_bar(dec!(110), dec!(111), dec!(105), dec!(108));
let b5 = make_ohlcv_bar(dec!(108), dec!(116), dec!(107), dec!(115));
assert_eq!(OhlcvBar::max_consecutive_gains(&[b1, b2, b3, b4, b5]), 2);
}
#[test]
fn test_max_consecutive_losses_correct() {
let b1 = make_ohlcv_bar(dec!(112), dec!(115), dec!(108), dec!(110));
let b2 = make_ohlcv_bar(dec!(110), dec!(112), dec!(103), dec!(105));
let b3 = make_ohlcv_bar(dec!(105), dec!(108), dec!(98), dec!(100));
let b4 = make_ohlcv_bar(dec!(100), dec!(112), dec!(98), dec!(108));
assert_eq!(OhlcvBar::max_consecutive_losses(&[b1, b2, b3, b4]), 2);
}
#[test]
fn test_price_path_length_none_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::price_path_length(&[b]).is_none());
}
#[test]
fn test_price_path_length_correct() {
let b1 = make_ohlcv_bar(dec!(98), dec!(102), dec!(96), dec!(100));
let b2 = make_ohlcv_bar(dec!(100), dec!(112), dec!(99), dec!(110));
let b3 = make_ohlcv_bar(dec!(110), dec!(112), dec!(103), dec!(105));
let len = OhlcvBar::price_path_length(&[b1, b2, b3]).unwrap();
assert!((len - 15.0).abs() < 1e-6, "expected 15.0, got {}", len);
}
#[test]
fn test_close_reversion_count_zero_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(OhlcvBar::close_reversion_count(&[b]), 0);
}
#[test]
fn test_close_reversion_count_returns_usize() {
let b1 = make_ohlcv_bar(dec!(98), dec!(102), dec!(96), dec!(100));
let b2 = make_ohlcv_bar(dec!(100), dec!(112), dec!(99), dec!(110));
let b3 = make_ohlcv_bar(dec!(110), dec!(112), dec!(103), dec!(105));
let _ = OhlcvBar::close_reversion_count(&[b1, b2, b3]);
}
#[test]
fn test_atr_ratio_none_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::atr_ratio(&[b]).is_none());
}
#[test]
fn test_atr_ratio_positive_for_valid_bars() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110));
let r = OhlcvBar::atr_ratio(&[b1, b2]).unwrap();
assert!(r > 0.0, "expected positive ATR ratio, got {}", r);
}
#[test]
fn test_volume_trend_strength_none_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::volume_trend_strength(&[b]).is_none());
}
#[test]
fn test_volume_trend_strength_positive_for_rising_volume() {
let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.volume = dec!(100);
let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(103), dec!(110)); b2.volume = dec!(200);
let mut b3 = make_ohlcv_bar(dec!(110), dec!(120), dec!(108), dec!(115)); b3.volume = dec!(300);
let s = OhlcvBar::volume_trend_strength(&[b1, b2, b3]).unwrap();
assert!(s > 0.0, "rising volume should give positive strength, got {}", s);
}
#[test]
fn test_high_close_spread_none_for_empty() {
assert!(OhlcvBar::high_close_spread(&[]).is_none());
}
#[test]
fn test_high_close_spread_zero_when_close_equals_high() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
let s = OhlcvBar::high_close_spread(&[b]).unwrap();
assert!((s - 0.0).abs() < 1e-9, "expected 0.0, got {}", s);
}
#[test]
fn test_high_close_spread_positive_for_wicked_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(105));
let s = OhlcvBar::high_close_spread(&[b]).unwrap();
assert!(s > 0.0, "expected positive spread, got {}", s);
}
#[test]
fn test_open_range_none_for_empty() {
assert!(OhlcvBar::open_range(&[]).is_none());
}
#[test]
fn test_open_range_zero_for_doji() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let r = OhlcvBar::open_range(&[b]).unwrap();
assert!((r - 0.0).abs() < 1e-9, "doji should have open_range=0, got {}", r);
}
#[test]
fn test_open_range_positive_for_directional() {
let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(98), dec!(110));
let r = OhlcvBar::open_range(&[b]).unwrap();
assert!(r > 0.0, "directional bar should have positive open_range, got {}", r);
}
#[test]
fn test_normalized_close_none_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::normalized_close(&[b]).is_none());
}
#[test]
fn test_normalized_close_one_when_last_close_is_max() {
let b1 = make_ohlcv_bar(dec!(98), dec!(105), dec!(96), dec!(100));
let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(99), dec!(110));
let nc = OhlcvBar::normalized_close(&[b1, b2]).unwrap();
assert!((nc - 1.0).abs() < 1e-9, "last close = max should give 1.0, got {}", nc);
}
#[test]
fn test_normalized_close_zero_when_last_close_is_min() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90));
let b2 = make_ohlcv_bar(dec!(90), dec!(105), dec!(88), dec!(100));
let nc = OhlcvBar::normalized_close(&[b1, b2]).unwrap();
assert!(nc >= 0.0 && nc <= 1.0, "normalized close should be in [0,1], got {}", nc);
}
#[test]
fn test_candle_score_none_for_empty() {
assert!(OhlcvBar::candle_score(&[]).is_none());
}
#[test]
fn test_candle_score_one_for_strong_bull_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(99), dec!(108));
let s = OhlcvBar::candle_score(&[b]).unwrap();
assert_eq!(s, 1.0, "strong bullish bar should score 1.0, got {}", s);
}
#[test]
fn test_candle_score_zero_for_bear_bar() {
let b = make_ohlcv_bar(dec!(108), dec!(110), dec!(99), dec!(100));
let s = OhlcvBar::candle_score(&[b]).unwrap();
assert_eq!(s, 0.0, "bearish bar should score 0.0, got {}", s);
}
#[test]
fn test_bar_speed_none_for_empty() {
assert!(OhlcvBar::bar_speed(&[]).is_none());
}
#[test]
fn test_higher_highs_count_zero_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(OhlcvBar::higher_highs_count(&[b]), 0);
}
#[test]
fn test_higher_highs_count_correct() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(105), dec!(120), dec!(103), dec!(115)); let b3 = make_ohlcv_bar(dec!(115), dec!(115), dec!(110), dec!(112)); assert_eq!(OhlcvBar::higher_highs_count(&[b1, b2, b3]), 1);
}
#[test]
fn test_lower_lows_count_correct() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(105), dec!(112), dec!(85), dec!(108)); let b3 = make_ohlcv_bar(dec!(108), dec!(115), dec!(95), dec!(112)); assert_eq!(OhlcvBar::lower_lows_count(&[b1, b2, b3]), 1);
}
#[test]
fn test_close_minus_open_pct_none_for_empty() {
assert!(OhlcvBar::close_minus_open_pct(&[]).is_none());
}
#[test]
fn test_close_minus_open_pct_positive_for_bull_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(98), dec!(110));
let p = OhlcvBar::close_minus_open_pct(&[b]).unwrap();
assert!(p > 0.0, "bullish bar should give positive pct, got {}", p);
}
#[test]
fn test_volume_per_range_none_for_empty() {
assert!(OhlcvBar::volume_per_range(&[]).is_none());
}
#[test]
fn test_volume_per_range_positive_for_valid_bar() {
let mut b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b.volume = dec!(100);
let r = OhlcvBar::volume_per_range(&[b]).unwrap();
assert!(r > 0.0, "expected positive volume/range, got {}", r);
}
#[test]
fn test_up_volume_fraction_none_for_empty() {
assert!(OhlcvBar::up_volume_fraction(&[]).is_none());
}
#[test]
fn test_up_volume_fraction_all_up() {
let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(108)); b1.volume = dec!(50);
let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(100), dec!(112)); b2.volume = dec!(50);
let f = OhlcvBar::up_volume_fraction(&[b1, b2]).unwrap();
assert!((f - 1.0).abs() < 1e-9, "all up bars → fraction=1.0, got {}", f);
}
#[test]
fn test_tail_upper_fraction_none_for_empty() {
assert!(OhlcvBar::tail_upper_fraction(&[]).is_none());
}
#[test]
fn test_tail_upper_fraction_correct() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let f = OhlcvBar::tail_upper_fraction(&[b]).unwrap();
assert!((f - 0.25).abs() < 1e-9, "expected 0.25, got {}", f);
}
#[test]
fn test_tail_lower_fraction_none_for_empty() {
assert!(OhlcvBar::tail_lower_fraction(&[]).is_none());
}
#[test]
fn test_tail_lower_fraction_correct() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let f = OhlcvBar::tail_lower_fraction(&[b]).unwrap();
assert!((f - 0.5).abs() < 1e-9, "expected 0.5, got {}", f);
}
#[test]
fn test_range_std_dev_none_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::range_std_dev(&[b]).is_none());
}
#[test]
fn test_range_std_dev_zero_for_equal_ranges() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(200), dec!(210), dec!(190), dec!(205));
let sd = OhlcvBar::range_std_dev(&[b1, b2]).unwrap();
assert!(sd.abs() < 1e-9, "equal ranges → std_dev=0, got {}", sd);
}
#[test]
fn test_body_fraction_none_for_empty() {
assert!(OhlcvBar::body_fraction(&[]).is_none());
}
#[test]
fn test_body_fraction_doji_is_zero() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let f = OhlcvBar::body_fraction(&[b]).unwrap();
assert!(f.abs() < 1e-9, "doji → body_fraction=0, got {}", f);
}
#[test]
fn test_bullish_ratio_none_for_empty() {
assert!(OhlcvBar::bullish_ratio(&[]).is_none());
}
#[test]
fn test_bullish_ratio_all_bullish() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(95), dec!(108));
let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(100), dec!(112));
let r = OhlcvBar::bullish_ratio(&[b1, b2]).unwrap();
assert!((r - 1.0).abs() < 1e-9, "all bullish → ratio=1.0, got {}", r);
}
#[test]
fn test_peak_trough_close_none_for_empty() {
assert!(OhlcvBar::peak_close(&[]).is_none());
assert!(OhlcvBar::trough_close(&[]).is_none());
}
#[test]
fn test_peak_trough_close_correct() {
let bars = vec![
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)),
make_ohlcv_bar(dec!(105), dec!(120), dec!(100), dec!(115)),
make_ohlcv_bar(dec!(110), dec!(115), dec!(95), dec!(98)),
];
assert_eq!(OhlcvBar::peak_close(&bars).unwrap(), dec!(115));
assert_eq!(OhlcvBar::trough_close(&bars).unwrap(), dec!(98));
}
#[test]
fn test_close_to_range_position_none_for_empty() {
assert!(OhlcvBar::close_to_range_position(&[]).is_none());
}
#[test]
fn test_close_to_range_position_one_when_close_at_high() {
let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(120));
let r = OhlcvBar::close_to_range_position(&[bar]).unwrap();
assert!((r - 1.0).abs() < 1e-9, "close at high → position=1, got {}", r);
}
#[test]
fn test_close_to_range_position_zero_when_close_at_low() {
let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(80));
let r = OhlcvBar::close_to_range_position(&[bar]).unwrap();
assert!(r.abs() < 1e-9, "close at low → position=0, got {}", r);
}
#[test]
fn test_volume_oscillator_none_for_insufficient_bars() {
let bars = vec![make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105))];
assert!(OhlcvBar::volume_oscillator(&bars, 1, 3).is_none());
}
#[test]
fn test_volume_oscillator_none_when_short_ge_long() {
let bars: Vec<_> = (0..5)
.map(|_| make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)))
.collect();
assert!(OhlcvBar::volume_oscillator(&bars, 3, 2).is_none());
}
#[test]
fn test_volume_oscillator_zero_for_constant_volume() {
let bars: Vec<_> = (0..5)
.map(|_| make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)))
.collect();
let v = OhlcvBar::volume_oscillator(&bars, 2, 4).unwrap();
assert!(v.abs() < 1e-9, "constant volume → oscillator=0, got {}", v);
}
#[test]
fn test_direction_reversal_count_zero_for_single_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(OhlcvBar::direction_reversal_count(&[bar]), 0);
}
#[test]
fn test_direction_reversal_count_zero_for_all_bullish() {
let bars = vec![
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)),
make_ohlcv_bar(dec!(105), dec!(115), dec!(95), dec!(112)),
];
assert_eq!(OhlcvBar::direction_reversal_count(&bars), 0);
}
#[test]
fn test_direction_reversal_count_two_for_alternating() {
let bull = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
let bear = make_ohlcv_bar(dec!(108), dec!(112), dec!(95), dec!(102));
let bull2 = make_ohlcv_bar(dec!(102), dec!(115), dec!(98), dec!(110));
let bear2 = make_ohlcv_bar(dec!(110), dec!(115), dec!(100), dec!(104));
assert_eq!(OhlcvBar::direction_reversal_count(&[bull, bear, bull2, bear2]), 3);
}
#[test]
fn test_upper_wick_dominance_fraction_none_for_empty() {
assert!(OhlcvBar::upper_wick_dominance_fraction(&[]).is_none());
}
#[test]
fn test_upper_wick_dominance_fraction_one_when_all_upper() {
let bar = make_ohlcv_bar(dec!(100), dec!(130), dec!(99), dec!(101));
let r = OhlcvBar::upper_wick_dominance_fraction(&[bar]).unwrap();
assert!((r - 1.0).abs() < 1e-9, "all upper dominant → 1.0, got {}", r);
}
#[test]
fn test_avg_open_to_high_ratio_none_for_empty() {
assert!(OhlcvBar::avg_open_to_high_ratio(&[]).is_none());
}
#[test]
fn test_avg_open_to_high_ratio_one_when_open_at_low() {
let bar = make_ohlcv_bar(dec!(80), dec!(120), dec!(80), dec!(100));
let r = OhlcvBar::avg_open_to_high_ratio(&[bar]).unwrap();
assert!((r - 1.0).abs() < 1e-9, "open at low → ratio=1, got {}", r);
}
#[test]
fn test_volume_weighted_range_none_for_empty() {
assert!(OhlcvBar::volume_weighted_range(&[]).is_none());
}
#[test]
fn test_volume_weighted_range_positive() {
let b1 = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110));
let b2 = make_ohlcv_bar(dec!(110), dec!(130), dec!(100), dec!(120));
let r = OhlcvBar::volume_weighted_range(&[b1, b2]).unwrap();
assert!(r > 0.0, "should be positive, got {}", r);
}
#[test]
fn test_bar_strength_index_none_for_empty() {
assert!(OhlcvBar::bar_strength_index(&[]).is_none());
}
#[test]
fn test_bar_strength_index_positive_when_closes_near_high() {
let bar = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(120));
let s = OhlcvBar::bar_strength_index(&[bar]).unwrap();
assert!(s > 0.0, "close at high → positive strength, got {}", s);
}
#[test]
fn test_shadow_to_body_ratio_none_for_empty() {
assert!(OhlcvBar::shadow_to_body_ratio(&[]).is_none());
}
#[test]
fn test_shadow_to_body_ratio_zero_for_marubozu() {
let bar = make_ohlcv_bar(dec!(80), dec!(120), dec!(80), dec!(120));
let r = OhlcvBar::shadow_to_body_ratio(&[bar]).unwrap();
assert!(r.abs() < 1e-9, "marubozu → ratio=0, got {}", r);
}
#[test]
fn test_first_last_close_pct_none_for_empty() {
assert!(OhlcvBar::first_last_close_pct(&[]).is_none());
}
#[test]
fn test_first_last_close_pct_zero_for_same_close() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let r = OhlcvBar::first_last_close_pct(&[bar]).unwrap();
assert!(r.abs() < 1e-9, "same open/close → pct=0, got {}", r);
}
#[test]
fn test_first_last_close_pct_positive_for_rise() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(95), dec!(110));
let r = OhlcvBar::first_last_close_pct(&[b1, b2]).unwrap();
assert!(r > 0.0, "price rose → positive pct, got {}", r);
}
#[test]
fn test_open_to_close_volatility_none_for_single_bar() {
let bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::open_to_close_volatility(&[bar]).is_none());
}
#[test]
fn test_open_to_close_volatility_zero_for_identical_bars() {
let bars = vec![
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)),
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)),
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)),
];
let v = OhlcvBar::open_to_close_volatility(&bars).unwrap();
assert!(v.abs() < 1e-9, "identical bars → volatility=0, got {}", v);
}
#[test]
fn test_close_recovery_ratio_none_for_empty() {
assert!(OhlcvBar::close_recovery_ratio(&[]).is_none());
}
#[test]
fn test_close_recovery_ratio_one_for_close_at_high() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
let r = OhlcvBar::close_recovery_ratio(&[b]).unwrap();
assert!((r - 1.0).abs() < 1e-9, "close at high → ratio=1, got {}", r);
}
#[test]
fn test_median_range_none_for_empty() {
assert!(OhlcvBar::median_range(&[]).is_none());
}
#[test]
fn test_median_range_correct_odd() {
let bars = vec![
make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)), make_ohlcv_bar(dec!(100), dec!(115), dec!(90), dec!(105)), make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(105)), ];
assert_eq!(OhlcvBar::median_range(&bars).unwrap(), dec!(25));
}
#[test]
fn test_mean_typical_price_none_for_empty() {
assert!(OhlcvBar::mean_typical_price(&[]).is_none());
}
#[test]
fn test_mean_typical_price_correct() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let expected = b.typical_price();
let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let tp = OhlcvBar::mean_typical_price(&[b2]).unwrap();
assert_eq!(tp, expected);
}
#[test]
fn test_directional_volume_ratio_none_for_empty() {
assert!(OhlcvBar::directional_volume_ratio(&[]).is_none());
}
#[test]
fn test_directional_volume_ratio_one_for_all_bullish() {
let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108)); b1.volume = dec!(50);
let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(100), dec!(112)); b2.volume = dec!(50);
let r = OhlcvBar::directional_volume_ratio(&[b1, b2]).unwrap();
assert!((r - 1.0).abs() < 1e-9, "all bullish → ratio=1, got {}", r);
}
#[test]
fn test_inside_bar_fraction_none_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::inside_bar_fraction(&[b]).is_none());
}
#[test]
fn test_body_momentum_empty_is_zero() {
assert_eq!(OhlcvBar::body_momentum(&[]), Decimal::ZERO);
}
#[test]
fn test_body_momentum_bullish_positive() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
let m = OhlcvBar::body_momentum(&[b]);
assert!(m > Decimal::ZERO, "bullish bar → positive body momentum");
}
#[test]
fn test_avg_trade_count_none_for_empty() {
assert!(OhlcvBar::avg_trade_count(&[]).is_none());
}
#[test]
fn test_max_trade_count_none_for_empty() {
assert!(OhlcvBar::max_trade_count(&[]).is_none());
}
#[test]
fn test_max_trade_count_returns_max() {
let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.trade_count = 5;
let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b2.trade_count = 10;
assert_eq!(OhlcvBar::max_trade_count(&[b1, b2]).unwrap(), 10);
}
#[test]
fn test_close_to_high_std_none_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::close_to_high_std(&[b]).is_none());
}
#[test]
fn test_close_to_high_std_zero_for_identical_bars() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let sd = OhlcvBar::close_to_high_std(&[b1, b2]).unwrap();
assert!(sd.abs() < 1e-9, "identical bars → std=0, got {}", sd);
}
#[test]
fn test_avg_open_volume_ratio_none_for_empty() {
assert!(OhlcvBar::avg_open_volume_ratio(&[]).is_none());
}
#[test]
fn test_typical_price_std_none_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::typical_price_std(&[b]).is_none());
}
#[test]
fn test_typical_price_std_zero_for_identical_bars() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let sd = OhlcvBar::typical_price_std(&[b1, b2]).unwrap();
assert!(sd.abs() < 1e-9, "identical bars → std=0, got {}", sd);
}
#[test]
fn test_vwap_deviation_avg_none_for_empty() {
assert!(OhlcvBar::vwap_deviation_avg(&[]).is_none());
}
#[test]
fn test_avg_high_low_ratio_none_for_empty() {
assert!(OhlcvBar::avg_high_low_ratio(&[]).is_none());
}
#[test]
fn test_avg_high_low_ratio_one_for_doji() {
let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
let r = OhlcvBar::avg_high_low_ratio(&[b]).unwrap();
assert!((r - 1.0).abs() < 1e-9, "high==low → ratio=1, got {}", r);
}
#[test]
fn test_gap_fill_fraction_none_for_empty() {
assert!(OhlcvBar::gap_fill_fraction(&[]).is_none());
}
#[test]
fn test_gap_fill_fraction_zero_for_no_gaps() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let f = OhlcvBar::gap_fill_fraction(&[b]).unwrap();
assert!(f.abs() < 1e-9, "no gap fills → fraction=0, got {}", f);
}
#[test]
fn test_complete_bar_count_zero_for_incomplete() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(OhlcvBar::complete_bar_count(&[b]), 0);
}
#[test]
fn test_min_trade_count_none_for_empty() {
assert!(OhlcvBar::min_trade_count(&[]).is_none());
}
#[test]
fn test_min_trade_count_returns_min() {
let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b1.trade_count = 5;
let mut b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); b2.trade_count = 2;
assert_eq!(OhlcvBar::min_trade_count(&[b1, b2]).unwrap(), 2);
}
#[test]
fn test_avg_bar_range_none_for_empty() {
assert!(OhlcvBar::avg_bar_range(&[]).is_none());
}
#[test]
fn test_avg_bar_range_correct_value() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(105)); let r = OhlcvBar::avg_bar_range(&[b1, b2]).unwrap();
assert_eq!(r, dec!(20));
}
#[test]
fn test_max_up_move_none_for_empty() {
assert!(OhlcvBar::max_up_move(&[]).is_none());
}
#[test]
fn test_max_up_move_largest_bullish_body() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108)); let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); assert_eq!(OhlcvBar::max_up_move(&[b1, b2]).unwrap(), dec!(8));
}
#[test]
fn test_max_down_move_none_for_empty() {
assert!(OhlcvBar::max_down_move(&[]).is_none());
}
#[test]
fn test_max_down_move_largest_bearish_body() {
let b1 = make_ohlcv_bar(dec!(108), dec!(115), dec!(85), dec!(100)); let b2 = make_ohlcv_bar(dec!(103), dec!(110), dec!(90), dec!(100)); assert_eq!(OhlcvBar::max_down_move(&[b1, b2]).unwrap(), dec!(8));
}
#[test]
fn test_avg_close_position_none_for_doji_only() {
let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100)); assert!(OhlcvBar::avg_close_position(&[b]).is_none());
}
#[test]
fn test_avg_close_position_one_for_close_at_high() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(100), dec!(110));
let pos = OhlcvBar::avg_close_position(&[b]).unwrap();
assert!((pos - 1.0).abs() < 1e-9, "close at high → position=1, got {}", pos);
}
#[test]
fn test_volume_std_none_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::volume_std(&[b]).is_none());
}
#[test]
fn test_volume_std_zero_for_equal_volumes() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let s = OhlcvBar::volume_std(&[b1, b2]).unwrap();
assert!(s.abs() < 1e-9, "equal volumes → std=0, got {}", s);
}
#[test]
fn test_avg_wick_ratio_none_for_doji_only() {
let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
assert!(OhlcvBar::avg_wick_ratio(&[b]).is_none());
}
#[test]
fn test_avg_wick_ratio_in_range() {
let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(85), dec!(105));
let r = OhlcvBar::avg_wick_ratio(&[b]).unwrap();
assert!(r >= 0.0 && r <= 1.0, "wick ratio should be in [0,1], got {}", r);
}
#[test]
fn test_open_gap_mean_none_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::open_gap_mean(&[b]).is_none());
}
#[test]
fn test_open_gap_mean_zero_for_no_gap() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(95), dec!(110));
b2.open = dec!(105); let g = OhlcvBar::open_gap_mean(&[b1, b2]).unwrap();
assert!(g.abs() < 1e-9, "no gap → mean=0, got {}", g);
}
#[test]
fn test_net_directional_move_none_for_empty() {
assert!(OhlcvBar::net_directional_move(&[]).is_none());
}
#[test]
fn test_net_directional_move_positive_for_rising_close() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(105), dec!(120), dec!(100), dec!(115));
let m = OhlcvBar::net_directional_move(&[b1, b2]).unwrap();
assert!(m > 0.0, "rising bar sequence → positive move, got {}", m);
}
#[test]
fn test_close_above_median_fraction_none_for_empty() {
assert!(OhlcvBar::close_above_median_fraction(&[]).is_none());
}
#[test]
fn test_close_above_median_fraction_half_for_symmetric() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(95));
let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b3 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(95));
let b4 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
let f = OhlcvBar::close_above_median_fraction(&[b1, b2, b3, b4]).unwrap();
assert!(f >= 0.0 && f <= 1.0, "fraction in [0,1], got {}", f);
}
#[test]
fn test_avg_range_to_open_none_for_empty() {
assert!(OhlcvBar::avg_range_to_open(&[]).is_none());
}
#[test]
fn test_close_sum_zero_for_empty() {
assert_eq!(OhlcvBar::close_sum(&[]), dec!(0));
}
#[test]
fn test_close_sum_sums_all_closes() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(107));
assert_eq!(OhlcvBar::close_sum(&[b1, b2]), dec!(212));
}
#[test]
fn test_above_avg_volume_count_zero_for_empty() {
assert_eq!(OhlcvBar::above_avg_volume_count(&[]), 0);
}
#[test]
fn test_median_close_none_for_empty() {
assert!(OhlcvBar::median_close(&[]).is_none());
}
#[test]
fn test_median_close_correct_for_sorted() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b3 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
let m = OhlcvBar::median_close(&[b1, b2, b3]).unwrap();
assert_eq!(m, dec!(105));
}
#[test]
fn test_flat_bar_fraction_none_for_empty() {
assert!(OhlcvBar::flat_bar_fraction(&[]).is_none());
}
#[test]
fn test_avg_body_to_range_none_for_empty() {
assert!(OhlcvBar::avg_body_to_range(&[]).is_none());
}
#[test]
fn test_avg_body_to_range_in_range() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let r = OhlcvBar::avg_body_to_range(&[b]).unwrap();
assert!(r >= 0.0 && r <= 1.0, "body-to-range in [0,1], got {}", r);
}
#[test]
fn test_max_open_gap_none_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::max_open_gap(&[b]).is_none());
}
#[test]
fn test_volume_trend_slope_none_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::volume_trend_slope(&[b]).is_none());
}
#[test]
fn test_up_close_fraction_none_for_empty() {
assert!(OhlcvBar::up_close_fraction(&[]).is_none());
}
#[test]
fn test_avg_upper_shadow_ratio_none_for_doji_only() {
let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
assert!(OhlcvBar::avg_upper_shadow_ratio(&[b]).is_none());
}
#[test]
fn test_avg_lower_shadow_ratio_none_for_empty() {
assert!(OhlcvBar::avg_lower_shadow_ratio(&[]).is_none());
}
#[test]
fn test_avg_lower_shadow_ratio_in_range() {
let b = make_ohlcv_bar(dec!(100), dec!(115), dec!(85), dec!(105));
let r = OhlcvBar::avg_lower_shadow_ratio(&[b]).unwrap();
assert!(r >= 0.0 && r <= 1.0, "lower shadow ratio in [0,1], got {}", r);
}
#[test]
fn test_close_to_open_range_ratio_none_for_doji_only() {
let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
assert!(OhlcvBar::close_to_open_range_ratio(&[b]).is_none());
}
#[test]
fn test_max_high_none_for_empty() {
assert!(OhlcvBar::max_high(&[]).is_none());
}
#[test]
fn test_max_high_returns_maximum() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(110));
assert_eq!(OhlcvBar::max_high(&[b1, b2]).unwrap(), dec!(120));
}
#[test]
fn test_min_low_none_for_empty() {
assert!(OhlcvBar::min_low(&[]).is_none());
}
#[test]
fn test_min_low_returns_minimum() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(85), dec!(105));
let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(OhlcvBar::min_low(&[b1, b2]).unwrap(), dec!(85));
}
#[test]
fn test_avg_bar_efficiency_none_for_doji_only() {
let b = make_ohlcv_bar(dec!(100), dec!(100), dec!(100), dec!(100));
assert!(OhlcvBar::avg_bar_efficiency(&[b]).is_none());
}
#[test]
fn test_avg_bar_efficiency_one_for_full_body_bar() {
let b = make_ohlcv_bar(dec!(90), dec!(110), dec!(90), dec!(110));
let e = OhlcvBar::avg_bar_efficiency(&[b]).unwrap();
assert!((e - 1.0).abs() < 1e-9, "full body → efficiency=1, got {}", e);
}
#[test]
fn test_open_range_fraction_none_for_empty() {
assert!(OhlcvBar::open_range_fraction(&[]).is_none());
}
#[test]
fn test_open_range_fraction_in_range() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let f = OhlcvBar::open_range_fraction(&[b]).unwrap();
assert!(f >= 0.0 && f <= 1.0, "fraction in [0,1], got {}", f);
}
#[test]
fn test_close_skewness_none_for_two_bars() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(95), dec!(110));
assert!(OhlcvBar::close_skewness(&[b1, b2]).is_none());
}
#[test]
fn test_close_skewness_returns_value_for_three_bars() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b3 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(200));
let s = OhlcvBar::close_skewness(&[b1, b2, b3]);
assert!(s.is_some(), "skewness should be computed for 3 bars");
}
#[test]
fn test_volume_above_median_fraction_none_for_empty() {
assert!(OhlcvBar::volume_above_median_fraction(&[]).is_none());
}
#[test]
fn test_volume_above_median_fraction_in_range() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let f = OhlcvBar::volume_above_median_fraction(&[b1, b2]).unwrap();
assert!(f >= 0.0 && f <= 1.0, "fraction in [0,1], got {}", f);
}
#[test]
fn test_typical_price_sum_zero_for_empty() {
assert_eq!(OhlcvBar::typical_price_sum(&[]), dec!(0));
}
#[test]
fn test_typical_price_sum_correct_value() {
let b = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(100));
assert_eq!(OhlcvBar::typical_price_sum(&[b]), dec!(100));
}
#[test]
fn test_max_body_size_none_for_empty() {
assert!(OhlcvBar::max_body_size(&[]).is_none());
}
#[test]
fn test_max_body_size_correct_value() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108));
let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(103));
assert_eq!(OhlcvBar::max_body_size(&[b1, b2]).unwrap(), dec!(8));
}
#[test]
fn test_min_body_size_none_for_empty() {
assert!(OhlcvBar::min_body_size(&[]).is_none());
}
#[test]
fn test_avg_lower_wick_to_range_none_for_empty() {
assert!(OhlcvBar::avg_lower_wick_to_range(&[]).is_none());
}
#[test]
fn test_avg_lower_wick_to_range_zero_for_open_at_low() {
let b = make_ohlcv_bar(dec!(90), dec!(110), dec!(90), dec!(105));
let r = OhlcvBar::avg_lower_wick_to_range(&[b]).unwrap();
assert!(r.abs() < 1e-9, "open=low → lower wick=0, got {}", r);
}
#[test]
fn test_total_range_zero_for_empty() {
assert_eq!(OhlcvBar::total_range(&[]), dec!(0));
}
#[test]
fn test_total_range_sum_of_ranges() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); let b2 = make_ohlcv_bar(dec!(100), dec!(115), dec!(95), dec!(110)); assert_eq!(OhlcvBar::total_range(&[b1, b2]), dec!(40));
}
#[test]
fn test_close_at_high_fraction_none_for_empty() {
assert!(OhlcvBar::close_at_high_fraction(&[]).is_none());
}
#[test]
fn test_close_at_high_fraction_one_when_all_close_at_high() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(110));
let f = OhlcvBar::close_at_high_fraction(&[b]).unwrap();
assert!((f - 1.0).abs() < 1e-9, "close=high → fraction=1, got {}", f);
}
#[test]
fn test_close_at_low_fraction_none_for_empty() {
assert!(OhlcvBar::close_at_low_fraction(&[]).is_none());
}
#[test]
fn test_close_at_low_fraction_one_when_all_close_at_low() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(90));
let f = OhlcvBar::close_at_low_fraction(&[b]).unwrap();
assert!((f - 1.0).abs() < 1e-9, "close=low → fraction=1, got {}", f);
}
#[test]
fn test_avg_high_above_open_ratio_none_for_empty() {
assert!(OhlcvBar::avg_high_above_open_ratio(&[]).is_none());
}
#[test]
fn test_avg_high_above_open_ratio_in_range() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let r = OhlcvBar::avg_high_above_open_ratio(&[b]).unwrap();
assert!(r >= 0.0 && r <= 1.0, "ratio in [0,1], got {}", r);
}
#[test]
fn test_continuation_bar_count_zero_for_single() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(OhlcvBar::continuation_bar_count(&[b]), 0);
}
#[test]
fn test_down_close_volume_zero_for_all_up_close() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); assert_eq!(OhlcvBar::down_close_volume(&[b]), dec!(0));
}
#[test]
fn test_up_close_volume_zero_for_all_down_close() {
let b = make_ohlcv_bar(dec!(105), dec!(110), dec!(90), dec!(100)); assert_eq!(OhlcvBar::up_close_volume(&[b]), dec!(0));
}
#[test]
fn test_mean_open_none_for_empty() {
assert!(OhlcvBar::mean_open(&[]).is_none());
}
#[test]
fn test_mean_open_correct() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(200), dec!(210), dec!(190), dec!(205));
assert_eq!(OhlcvBar::mean_open(&[b1, b2]).unwrap(), dec!(150));
}
#[test]
fn test_new_high_count_zero_for_single() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(OhlcvBar::new_high_count(&[b]), 0);
}
#[test]
fn test_new_high_count_correct() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(90), dec!(115));
let b3 = make_ohlcv_bar(dec!(100), dec!(115), dec!(90), dec!(110));
assert_eq!(OhlcvBar::new_high_count(&[b1, b2, b3]), 1);
}
#[test]
fn test_new_low_count_zero_for_single() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(OhlcvBar::new_low_count(&[b]), 0);
}
#[test]
fn test_close_std_none_for_single() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::close_std(&[b]).is_none());
}
#[test]
fn test_close_std_zero_for_constant_close() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(101), dec!(111), dec!(91), dec!(105));
let s = OhlcvBar::close_std(&[b1, b2]).unwrap();
assert!(s.abs() < 1e-9, "constant close → std=0, got {}", s);
}
#[test]
fn test_zero_volume_fraction_none_for_empty() {
assert!(OhlcvBar::zero_volume_fraction(&[]).is_none());
}
#[test]
fn test_zero_volume_fraction_zero_when_no_zero_volume() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let f = OhlcvBar::zero_volume_fraction(&[b]).unwrap();
assert!(f.abs() < 1e-9, "bar has volume → zero_vol_fraction=0, got {}", f);
}
#[test]
fn test_avg_open_to_close_none_for_empty() {
assert!(OhlcvBar::avg_open_to_close(&[]).is_none());
}
#[test]
fn test_avg_open_to_close_positive_when_all_bullish() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); let r = OhlcvBar::avg_open_to_close(&[b]).unwrap();
assert!(r > dec!(0), "bullish bar → avg_open_to_close > 0, got {}", r);
}
#[test]
fn test_avg_open_to_close_zero_for_doji() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); let r = OhlcvBar::avg_open_to_close(&[b]).unwrap();
assert_eq!(r, dec!(0));
}
#[test]
fn test_max_bar_volume_none_for_empty() {
assert!(OhlcvBar::max_bar_volume(&[]).is_none());
}
#[test]
fn test_max_bar_volume_selects_largest() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let vol = OhlcvBar::max_bar_volume(&[b1, b2]).unwrap();
assert!(vol > dec!(0));
}
#[test]
fn test_min_bar_volume_none_for_empty() {
assert!(OhlcvBar::min_bar_volume(&[]).is_none());
}
#[test]
fn test_body_to_range_std_none_for_single() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::body_to_range_std(&[b]).is_none());
}
#[test]
fn test_body_to_range_std_nonneg_for_varied_bars() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(100));
let s = OhlcvBar::body_to_range_std(&[b1, b2]).unwrap();
assert!(s >= 0.0, "std dev should be non-negative, got {}", s);
}
#[test]
fn test_avg_wick_symmetry_none_for_empty() {
assert!(OhlcvBar::avg_wick_symmetry(&[]).is_none());
}
#[test]
fn test_avg_wick_symmetry_in_range() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let s = OhlcvBar::avg_wick_symmetry(&[b]).unwrap();
assert!(s >= 0.0 && s <= 1.0, "symmetry in [0,1], got {}", s);
}
#[test]
fn test_avg_range_pct_of_open_none_for_empty() {
assert!(OhlcvBar::avg_range_pct_of_open(&[]).is_none());
}
#[test]
fn test_avg_range_pct_of_open_correct() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let r = OhlcvBar::avg_range_pct_of_open(&[b]).unwrap();
assert!((r - 0.2).abs() < 1e-9, "range/open = 0.2, got {}", r);
}
#[test]
fn test_high_volume_fraction_none_for_empty() {
assert!(OhlcvBar::high_volume_fraction(&[]).is_none());
}
#[test]
fn test_close_cluster_count_zero_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(OhlcvBar::close_cluster_count(&[b]), 0);
}
#[test]
fn test_mean_vwap_none_for_bars_without_vwap() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::mean_vwap(&[b]).is_none());
}
#[test]
fn test_complete_fraction_none_for_empty() {
assert!(OhlcvBar::complete_fraction(&[]).is_none());
}
#[test]
fn test_complete_fraction_zero_when_none_complete() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let f = OhlcvBar::complete_fraction(&[b]).unwrap();
assert!(f.abs() < 1e-9, "no complete bars → fraction=0, got {}", f);
}
#[test]
fn test_total_body_movement_zero_for_empty() {
assert_eq!(OhlcvBar::total_body_movement(&[]), rust_decimal::Decimal::ZERO);
}
#[test]
fn test_total_body_movement_correct() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(100), dec!(100)); assert_eq!(OhlcvBar::total_body_movement(&[b1, b2]), dec!(15));
}
#[test]
fn test_open_std_none_for_single() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::open_std(&[b]).is_none());
}
#[test]
fn test_open_std_zero_for_constant_open() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(100), dec!(120), dec!(80), dec!(110));
let s = OhlcvBar::open_std(&[b1, b2]).unwrap();
assert!(s.abs() < 1e-9, "constant open → std=0, got {}", s);
}
#[test]
fn test_mean_high_low_ratio_none_for_empty() {
assert!(OhlcvBar::mean_high_low_ratio(&[]).is_none());
}
#[test]
fn test_mean_high_low_ratio_above_one() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let r = OhlcvBar::mean_high_low_ratio(&[b]).unwrap();
assert!(r > 1.0, "high > low → ratio > 1, got {}", r);
}
#[test]
fn test_max_consecutive_up_bars_zero_for_empty() {
assert_eq!(OhlcvBar::max_consecutive_up_bars(&[]), 0);
}
#[test]
fn test_max_consecutive_up_bars_zero_for_all_down() {
let b = make_ohlcv_bar(dec!(105), dec!(110), dec!(90), dec!(100)); assert_eq!(OhlcvBar::max_consecutive_up_bars(&[b]), 0);
}
#[test]
fn test_max_consecutive_up_bars_correct_run() {
let up = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105)); let dn = make_ohlcv_bar(dec!(105), dec!(115), dec!(90), dec!(100)); let up2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(108)); let up3 = make_ohlcv_bar(dec!(108), dec!(120), dec!(100), dec!(115)); assert_eq!(OhlcvBar::max_consecutive_up_bars(&[up, dn, up2, up3]), 2);
}
#[test]
fn test_avg_upper_shadow_fraction_none_for_empty() {
assert!(OhlcvBar::avg_upper_shadow_fraction(&[]).is_none());
}
#[test]
fn test_avg_upper_shadow_fraction_in_range() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); let f = OhlcvBar::avg_upper_shadow_fraction(&[b]).unwrap();
assert!(f >= 0.0 && f <= 1.0, "fraction in [0,1], got {}", f);
}
#[test]
fn test_up_down_bar_ratio_none_for_no_down_bars() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::up_down_bar_ratio(&[b]).is_none());
}
#[test]
fn test_up_down_bar_ratio_one_for_balanced() {
let up_bar = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let dn_bar = make_ohlcv_bar(dec!(105), dec!(110), dec!(90), dec!(100));
let r = OhlcvBar::up_down_bar_ratio(&[up_bar, dn_bar]).unwrap();
assert!((r - 1.0).abs() < 1e-9, "1 up / 1 down → 1.0, got {}", r);
}
#[test]
fn test_close_range_fraction_none_for_empty() {
assert!(OhlcvBar::close_range_fraction(&[]).is_none());
}
#[test]
fn test_close_range_fraction_one_for_close_at_high() {
let b = make_ohlcv_bar(dec!(90), dec!(110), dec!(90), dec!(110));
let f = OhlcvBar::close_range_fraction(&[b]).unwrap();
assert!((f - 1.0).abs() < 1e-9, "close=high → 1.0, got {}", f);
}
#[test]
fn test_close_range_fraction_zero_for_close_at_low() {
let b = make_ohlcv_bar(dec!(110), dec!(120), dec!(90), dec!(90));
let f = OhlcvBar::close_range_fraction(&[b]).unwrap();
assert!((f - 0.0).abs() < 1e-9, "close=low → 0.0, got {}", f);
}
#[test]
fn test_tail_symmetry_none_for_empty() {
assert!(OhlcvBar::tail_symmetry(&[]).is_none());
}
#[test]
fn test_tail_symmetry_one_for_symmetric_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100)); let s = OhlcvBar::tail_symmetry(&[b]).unwrap();
assert!((s - 1.0).abs() < 1e-9, "symmetric bar → 1.0, got {}", s);
}
#[test]
fn test_bar_trend_strength_none_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::bar_trend_strength(&[b]).is_none());
}
#[test]
fn test_bar_trend_strength_one_for_monotone_up() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(100));
let b2 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b3 = make_ohlcv_bar(dec!(105), dec!(115), dec!(95), dec!(110));
let s = OhlcvBar::bar_trend_strength(&[b1, b2, b3]).unwrap();
assert!((s - 1.0).abs() < 1e-9, "monotone up → 1.0, got {}", s);
}
#[test]
fn test_gap_up_count_zero_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(OhlcvBar::gap_up_count(&[b]), 0);
}
#[test]
fn test_gap_up_count_one_for_gap() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(110), dec!(120), dec!(105), dec!(115));
assert_eq!(OhlcvBar::gap_up_count(&[b1, b2]), 1);
}
#[test]
fn test_gap_down_count_zero_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert_eq!(OhlcvBar::gap_down_count(&[b]), 0);
}
#[test]
fn test_gap_down_count_one_for_gap() {
let b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
let b2 = make_ohlcv_bar(dec!(95), dec!(100), dec!(85), dec!(90));
assert_eq!(OhlcvBar::gap_down_count(&[b1, b2]), 1);
}
#[test]
fn test_mean_bar_efficiency_none_for_empty() {
assert!(OhlcvBar::mean_bar_efficiency(&[]).is_none());
}
#[test]
fn test_mean_bar_efficiency_one_for_full_body() {
let b = make_ohlcv_bar(dec!(90), dec!(110), dec!(90), dec!(110));
let e = OhlcvBar::mean_bar_efficiency(&[b]).unwrap();
assert!((e - 1.0).abs() < 1e-9, "full body → 1.0, got {}", e);
}
#[test]
fn test_volume_trend_slope_none_for_single_bar() {
let b = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
assert!(OhlcvBar::volume_trend_slope(&[b]).is_none());
}
#[test]
fn test_volume_trend_slope_positive_for_rising_volume() {
let mut b1 = make_ohlcv_bar(dec!(100), dec!(110), dec!(90), dec!(105));
b1.volume = dec!(100);
let mut b2 = make_ohlcv_bar(dec!(105), dec!(115), dec!(95), dec!(110));
b2.volume = dec!(200);
let s = OhlcvBar::volume_trend_slope(&[b1, b2]).unwrap();
assert!(s > 0.0, "rising volume → positive slope, got {}", s);
}
}