use crate::error::FinError;
use crate::tick::Tick;
use crate::types::{NanoTimestamp, Price, Quantity, Symbol};
use rust_decimal::Decimal;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct OhlcvBar {
pub symbol: Symbol,
pub open: Price,
pub high: Price,
pub low: Price,
pub close: Price,
pub volume: Quantity,
pub ts_open: NanoTimestamp,
pub ts_close: NanoTimestamp,
pub tick_count: u64,
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
pub struct PivotPoints {
pub pp: Decimal,
pub r1: Decimal,
pub s1: Decimal,
pub r2: Decimal,
pub s2: Decimal,
}
impl OhlcvBar {
#[allow(clippy::too_many_arguments)]
pub fn new(
symbol: Symbol,
open: Price,
high: Price,
low: Price,
close: Price,
volume: Quantity,
ts_open: NanoTimestamp,
ts_close: NanoTimestamp,
tick_count: u64,
) -> Result<Self, FinError> {
let bar = Self {
symbol,
open,
high,
low,
close,
volume,
ts_open,
ts_close,
tick_count,
};
bar.validate()?;
Ok(bar)
}
pub fn validate(&self) -> Result<(), FinError> {
let h = self.high.value();
let l = self.low.value();
let o = self.open.value();
let c = self.close.value();
if h < o {
return Err(FinError::BarInvariant(format!("high {h} < open {o}")));
}
if h < c {
return Err(FinError::BarInvariant(format!("high {h} < close {c}")));
}
if l > o {
return Err(FinError::BarInvariant(format!("low {l} > open {o}")));
}
if l > c {
return Err(FinError::BarInvariant(format!("low {l} > close {c}")));
}
if h < l {
return Err(FinError::BarInvariant(format!("high {h} < low {l}")));
}
Ok(())
}
pub fn to_bar_input(&self) -> crate::signals::BarInput {
crate::signals::BarInput::from(self)
}
pub fn typical_price(&self) -> Decimal {
(self.high.value() + self.low.value() + self.close.value()) / Decimal::from(3u32)
}
pub fn range(&self) -> Decimal {
self.high.value() - self.low.value()
}
pub fn hlcc4(&self) -> Decimal {
(self.high.value() + self.low.value() + self.close.value() + self.close.value())
/ Decimal::from(4u32)
}
pub fn weighted_close(&self) -> Decimal {
self.hlcc4()
}
pub fn ohlc4(&self) -> Decimal {
(self.open.value() + self.high.value() + self.low.value() + self.close.value())
/ Decimal::from(4u32)
}
pub fn dollar_volume(&self) -> Decimal {
self.typical_price() * self.volume.value()
}
pub fn is_gap_fill(&self) -> bool {
self.tick_count == 0
}
pub fn is_inside_bar(&self, prev: &OhlcvBar) -> bool {
self.high.value() < prev.high.value() && self.low.value() > prev.low.value()
}
pub fn is_outside_bar(&self, prev: &OhlcvBar) -> bool {
self.high.value() > prev.high.value() && self.low.value() < prev.low.value()
}
pub fn is_engulfing(&self, prev: &OhlcvBar) -> bool {
let s_o = self.open.value();
let s_c = self.close.value();
let p_o = prev.open.value();
let p_c = prev.close.value();
let bullish = p_c < p_o && s_c > s_o && s_c >= p_o && s_o <= p_c;
let bearish = p_c > p_o && s_c < s_o && s_c <= p_o && s_o >= p_c;
bullish || bearish
}
pub fn is_bullish(&self) -> bool {
self.close.value() >= self.open.value()
}
pub fn is_bearish(&self) -> bool {
self.close.value() < self.open.value()
}
pub fn is_hammer(&self) -> bool {
let body = self.body_size();
if body.is_zero() {
return false;
}
self.lower_shadow() >= body * Decimal::TWO && self.upper_shadow() <= body
}
pub fn is_marubozu(&self) -> bool {
let range = self.range();
if range.is_zero() {
return false;
}
let body = self.body_size();
if body.is_zero() {
return false;
}
let threshold = range / Decimal::from(20u32); self.upper_shadow() < threshold && self.lower_shadow() < threshold
}
pub fn is_spinning_top(&self) -> bool {
let range = self.range();
if range.is_zero() {
return false;
}
let body = self.body_size();
let body_ratio = body / range;
let upper_ratio = self.upper_shadow() / range;
let lower_ratio = self.lower_shadow() / range;
let threshold_30 = Decimal::new(30, 2);
let threshold_20 = Decimal::new(20, 2);
body_ratio < threshold_30 && upper_ratio >= threshold_20 && lower_ratio >= threshold_20
}
pub fn is_shooting_star(&self) -> bool {
let body = self.body_size();
if body.is_zero() {
return false;
}
self.upper_shadow() >= body * Decimal::TWO && self.lower_shadow() <= body
}
pub fn body_pct(&self) -> Option<Decimal> {
let o = self.open.value();
if o.is_zero() {
return None;
}
Some(self.body_size() / o * Decimal::ONE_HUNDRED)
}
pub fn bar_return(&self) -> Option<Decimal> {
let o = self.open.value();
if o.is_zero() {
return None;
}
Some((self.close.value() - o) / o * Decimal::ONE_HUNDRED)
}
pub fn midpoint(&self) -> Decimal {
(self.high.value() + self.low.value()) / Decimal::TWO
}
pub fn body_size(&self) -> Decimal {
(self.close.value() - self.open.value()).abs()
}
pub fn body_to_range_ratio(&self) -> Option<Decimal> {
let r = self.range();
if r.is_zero() {
return None;
}
Some(self.body_size() / r)
}
pub fn is_long_candle(&self, factor: Decimal) -> bool {
let r = self.range();
if r == Decimal::ZERO {
return false;
}
self.body_size() / r >= factor
}
pub fn is_doji(&self, threshold: Decimal) -> bool {
let r = self.range();
if r == Decimal::ZERO {
return threshold > Decimal::ZERO;
}
self.body_size() / r < threshold
}
pub fn body_ratio(&self) -> Option<Decimal> {
let r = self.range();
if r == Decimal::ZERO {
return None;
}
Some(self.body_size() / r)
}
pub fn true_range(&self, prev: Option<&OhlcvBar>) -> Decimal {
let hl = self.high.value() - self.low.value();
match prev {
None => hl,
Some(p) => {
let pc = p.close.value();
let hc = (self.high.value() - pc).abs();
let lc = (self.low.value() - pc).abs();
hl.max(hc).max(lc)
}
}
}
pub fn shadow_ratio(&self) -> Option<Decimal> {
let r = self.range();
if r.is_zero() {
return None;
}
Some((self.upper_shadow() + self.lower_shadow()) / r)
}
pub fn gap_up_from(&self, prev: &OhlcvBar) -> bool {
self.low.value() > prev.high.value()
}
pub fn gap_down_from(&self, prev: &OhlcvBar) -> bool {
self.high.value() < prev.low.value()
}
pub fn gap_from(&self, prev: &OhlcvBar) -> Decimal {
self.open.value() - prev.close.value()
}
pub fn upper_shadow(&self) -> Decimal {
let body_top = self.open.value().max(self.close.value());
self.high.value() - body_top
}
pub fn lower_shadow(&self) -> Decimal {
let body_bottom = self.open.value().min(self.close.value());
body_bottom - self.low.value()
}
pub fn duration_nanos(&self) -> i64 {
self.ts_close.nanos() - self.ts_open.nanos()
}
pub fn gap_pct(&self, prev: &OhlcvBar) -> Option<Decimal> {
let prev_close = prev.close.value();
if prev_close.is_zero() {
return None;
}
Some((self.open.value() - prev_close) / prev_close * Decimal::ONE_HUNDRED)
}
pub fn has_gap(&self, prev: &OhlcvBar, pct_threshold: Decimal) -> bool {
self.gap_pct(prev)
.map_or(false, |g| g.abs() >= pct_threshold)
}
pub fn from_tick(tick: &Tick) -> Self {
Self {
symbol: tick.symbol.clone(),
open: tick.price,
high: tick.price,
low: tick.price,
close: tick.price,
volume: tick.quantity,
ts_open: tick.timestamp,
ts_close: tick.timestamp,
tick_count: 1,
}
}
pub fn merge(&self, other: &OhlcvBar) -> Result<OhlcvBar, FinError> {
let high = self.high.value().max(other.high.value());
let low = self.low.value().min(other.low.value());
let volume_sum = self.volume.value() + other.volume.value();
let bar = OhlcvBar {
symbol: self.symbol.clone(),
open: self.open,
high: Price::new(high)?,
low: Price::new(low)?,
close: other.close,
volume: Quantity::new(volume_sum)?,
ts_open: self.ts_open,
ts_close: other.ts_close,
tick_count: self.tick_count + other.tick_count,
};
bar.validate()?;
Ok(bar)
}
pub fn is_bullish_engulfing(&self, prev: &OhlcvBar) -> bool {
let prev_bearish = prev.open.value() > prev.close.value();
let self_bullish = self.close.value() > self.open.value();
prev_bearish
&& self_bullish
&& self.open.value() <= prev.close.value()
&& self.close.value() >= prev.open.value()
}
pub fn is_bearish_engulfing(&self, prev: &OhlcvBar) -> bool {
let prev_bullish = prev.close.value() > prev.open.value();
let self_bearish = self.open.value() > self.close.value();
prev_bullish
&& self_bearish
&& self.open.value() >= prev.close.value()
&& self.close.value() <= prev.open.value()
}
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
pub enum Timeframe {
Seconds(u32),
Minutes(u32),
Hours(u32),
Days(u32),
Weeks(u32),
}
impl Timeframe {
pub fn to_nanos(&self) -> Result<i64, FinError> {
let secs: u64 = match self {
Timeframe::Seconds(n) => u64::from(*n),
Timeframe::Minutes(n) => u64::from(*n) * 60,
Timeframe::Hours(n) => u64::from(*n) * 3_600,
Timeframe::Days(n) => u64::from(*n) * 86_400,
Timeframe::Weeks(n) => u64::from(*n) * 7 * 86_400,
};
if secs == 0 {
return Err(FinError::InvalidTimeframe);
}
#[allow(clippy::cast_possible_wrap)]
Ok((secs * 1_000_000_000) as i64)
}
pub fn bucket_start(&self, ts: NanoTimestamp) -> Result<NanoTimestamp, FinError> {
let nanos = self.to_nanos()?;
let bucket = (ts.nanos() / nanos) * nanos;
Ok(NanoTimestamp::new(bucket))
}
}
pub struct OhlcvAggregator {
symbol: Symbol,
timeframe: Timeframe,
current_bar: Option<OhlcvBar>,
current_bucket_start: Option<NanoTimestamp>,
last_close: Option<Price>,
bars_emitted: usize,
}
impl OhlcvAggregator {
pub fn new(symbol: Symbol, timeframe: Timeframe) -> Result<Self, FinError> {
timeframe.to_nanos()?;
Ok(Self {
symbol,
timeframe,
current_bar: None,
current_bucket_start: None,
last_close: None,
bars_emitted: 0,
})
}
pub fn push_tick(&mut self, tick: &Tick) -> Result<Vec<OhlcvBar>, FinError> {
if tick.symbol != self.symbol {
return Ok(vec![]);
}
let bucket = self.timeframe.bucket_start(tick.timestamp)?;
match self.current_bucket_start {
None => {
self.current_bucket_start = Some(bucket);
self.current_bar = Some(self.new_bar(tick));
Ok(vec![])
}
Some(current_bucket) if bucket == current_bucket => {
self.update_bar(tick);
Ok(vec![])
}
Some(_) => {
let completed = self.current_bar.take().expect("current bar must be Some here");
self.last_close = Some(completed.close);
let mut out = vec![completed];
let nanos = self.timeframe.to_nanos()?;
let prev_bucket = self.current_bucket_start.expect("set above");
let mut gap_bucket = NanoTimestamp::new(prev_bucket.nanos() + nanos);
while gap_bucket < bucket {
if let Some(close) = self.last_close {
out.push(OhlcvBar {
symbol: self.symbol.clone(),
open: close,
high: close,
low: close,
close,
volume: Quantity::zero(),
ts_open: gap_bucket,
ts_close: gap_bucket,
tick_count: 0,
});
}
gap_bucket = NanoTimestamp::new(gap_bucket.nanos() + nanos);
}
self.bars_emitted += out.len();
self.current_bucket_start = Some(bucket);
self.current_bar = Some(self.new_bar(tick));
Ok(out)
}
}
}
pub fn flush(&mut self) -> Option<OhlcvBar> {
self.current_bucket_start = None;
let bar = self.current_bar.take();
if let Some(ref b) = bar {
self.last_close = Some(b.close);
self.bars_emitted += 1;
}
bar
}
pub fn symbol(&self) -> &Symbol {
&self.symbol
}
pub fn timeframe(&self) -> Timeframe {
self.timeframe
}
pub fn reset(&mut self) {
self.current_bar = None;
self.current_bucket_start = None;
self.last_close = None;
self.bars_emitted = 0;
}
pub fn bar_count(&self) -> usize {
self.bars_emitted
}
pub fn current_bar(&self) -> Option<&OhlcvBar> {
self.current_bar.as_ref()
}
pub fn current_bar_open_ts(&self) -> Option<NanoTimestamp> {
self.current_bucket_start
}
fn new_bar(&self, tick: &Tick) -> OhlcvBar {
OhlcvBar {
symbol: self.symbol.clone(),
open: tick.price,
high: tick.price,
low: tick.price,
close: tick.price,
volume: tick.quantity,
ts_open: tick.timestamp,
ts_close: tick.timestamp,
tick_count: 1,
}
}
fn update_bar(&mut self, tick: &Tick) {
if let Some(ref mut bar) = self.current_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 =
Quantity::new(bar.volume.value() + tick.quantity.value()).unwrap_or(bar.volume);
bar.ts_close = tick.timestamp;
bar.tick_count += 1;
}
}
}
pub struct OhlcvSeries {
bars: Vec<OhlcvBar>,
}
impl OhlcvSeries {
pub fn new() -> Self {
Self { bars: Vec::new() }
}
pub fn from_bars(bars: Vec<OhlcvBar>) -> Result<Self, FinError> {
for bar in &bars {
bar.validate()?;
}
Ok(Self { bars })
}
pub fn with_capacity(capacity: usize) -> Self {
Self {
bars: Vec::with_capacity(capacity),
}
}
pub fn push(&mut self, bar: OhlcvBar) -> Result<(), FinError> {
bar.validate()?;
self.bars.push(bar);
Ok(())
}
pub fn len(&self) -> usize {
self.bars.len()
}
pub fn is_empty(&self) -> bool {
self.bars.is_empty()
}
pub fn clear(&mut self) {
self.bars.clear();
}
pub fn retain(&mut self, mut predicate: impl FnMut(&OhlcvBar) -> bool) {
self.bars.retain(|b| predicate(b));
}
pub fn get(&self, index: usize) -> Option<&OhlcvBar> {
self.bars.get(index)
}
pub fn first(&self) -> Option<&OhlcvBar> {
self.bars.first()
}
pub fn last(&self) -> Option<&OhlcvBar> {
self.bars.last()
}
pub fn n_bars_ago(&self, n: usize) -> Option<&OhlcvBar> {
let len = self.bars.len();
if n >= len {
return None;
}
self.bars.get(len - 1 - n)
}
pub fn window(&self, n: usize) -> &[OhlcvBar] {
let len = self.bars.len();
if n >= len {
&self.bars
} else {
&self.bars[len - n..]
}
}
pub fn iter(&self) -> std::slice::Iter<'_, OhlcvBar> {
self.bars.iter()
}
pub fn consecutive_ups(&self) -> usize {
self.bars
.iter()
.rev()
.take_while(|b| b.is_bullish())
.count()
}
pub fn consecutive_downs(&self) -> usize {
self.bars
.iter()
.rev()
.take_while(|b| b.is_bearish())
.count()
}
pub fn opens(&self) -> Vec<Decimal> {
self.bars.iter().map(|b| b.open.value()).collect()
}
pub fn highs(&self) -> Vec<Decimal> {
self.bars.iter().map(|b| b.high.value()).collect()
}
pub fn lows(&self) -> Vec<Decimal> {
self.bars.iter().map(|b| b.low.value()).collect()
}
pub fn closes(&self) -> Vec<Decimal> {
self.bars.iter().map(|b| b.close.value()).collect()
}
pub fn volumes(&self) -> Vec<Decimal> {
self.bars.iter().map(|b| b.volume.value()).collect()
}
pub fn typical_prices(&self) -> Vec<Decimal> {
self.bars.iter().map(|b| b.typical_price()).collect()
}
pub fn bars(&self) -> &[OhlcvBar] {
&self.bars
}
pub fn max_high(&self) -> Option<Decimal> {
self.bars.iter().map(|b| b.high.value()).reduce(Decimal::max)
}
pub fn min_low(&self) -> Option<Decimal> {
self.bars.iter().map(|b| b.low.value()).reduce(Decimal::min)
}
pub fn highest_high(&self, n: usize) -> Option<Decimal> {
let start = self.bars.len().saturating_sub(n);
self.bars[start..].iter().map(|b| b.high.value()).reduce(Decimal::max)
}
pub fn lowest_low(&self, n: usize) -> Option<Decimal> {
let start = self.bars.len().saturating_sub(n);
self.bars[start..].iter().map(|b| b.low.value()).reduce(Decimal::min)
}
pub fn vwap(&self) -> Option<Decimal> {
if self.bars.is_empty() {
return None;
}
let total_vol: Decimal = self.bars.iter().map(|b| b.volume.value()).sum();
if total_vol == Decimal::ZERO {
return None;
}
let weighted_sum: Decimal = self
.bars
.iter()
.map(|b| b.typical_price() * b.volume.value())
.sum();
Some(weighted_sum / total_vol)
}
pub fn sum_volume(&self) -> Decimal {
self.bars.iter().map(|b| b.volume.value()).sum()
}
pub fn avg_volume(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let sum: Decimal = self.bars.iter().rev().take(n).map(|b| b.volume.value()).sum();
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(n as u32))
}
pub fn price_range(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let hh = self.highest_high(n)?;
let ll = self.lowest_low(n)?;
Some(hh - ll)
}
pub fn close_location_value(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let sum: Decimal = self.bars[start..].iter().map(|b| {
let h = b.high.value();
let l = b.low.value();
let c = b.close.value();
let range = h - l;
if range == Decimal::ZERO { Decimal::ZERO } else { ((c - l) - (h - c)) / range }
}).sum();
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(n as u32))
}
pub fn avg_dollar_volume(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let sum: Decimal = self.bars.iter().rev().take(n).map(|b| b.dollar_volume()).sum();
Some(sum / Decimal::from(n as u64))
}
pub fn slice(&self, from: usize, to: usize) -> Option<&[OhlcvBar]> {
if from > to || to > self.bars.len() {
return None;
}
Some(&self.bars[from..to])
}
pub fn truncate(&mut self, n: usize) {
let len = self.bars.len();
if n < len {
self.bars.drain(0..len - n);
}
}
pub fn extend(&mut self, bars: impl IntoIterator<Item = OhlcvBar>) -> Result<(), FinError> {
for bar in bars {
self.push(bar)?;
}
Ok(())
}
pub fn extend_from_series(&mut self, other: &OhlcvSeries) -> Result<(), FinError> {
for bar in &other.bars {
self.push(bar.clone())?;
}
Ok(())
}
pub fn to_bar_inputs(&self) -> Vec<crate::signals::BarInput> {
self.bars
.iter()
.map(crate::signals::BarInput::from)
.collect()
}
pub fn apply_signal(
&self,
signal: &mut dyn crate::signals::Signal,
) -> Result<Vec<crate::signals::SignalValue>, FinError> {
self.bars.iter().map(|b| signal.update_bar(b)).collect()
}
pub fn returns(&self) -> Vec<Decimal> {
if self.bars.len() < 2 {
return Vec::new();
}
self.bars
.windows(2)
.filter_map(|w| {
let prev = w[0].close.value();
if prev.is_zero() {
return None;
}
Some((w[1].close.value() - prev) / prev)
})
.collect()
}
pub fn highest_close(&self, n: usize) -> Option<Decimal> {
let start = self.bars.len().saturating_sub(n);
self.bars[start..].iter().map(|b| b.close.value()).reduce(Decimal::max)
}
pub fn lowest_close(&self, n: usize) -> Option<Decimal> {
let start = self.bars.len().saturating_sub(n);
self.bars[start..].iter().map(|b| b.close.value()).reduce(Decimal::min)
}
pub fn mean_close(&self, n: usize) -> Option<Decimal> {
let start = self.bars.len().saturating_sub(n);
let slice = &self.bars[start..];
if slice.is_empty() {
return None;
}
let sum: Decimal = slice.iter().map(|b| b.close.value()).sum();
Some(sum / Decimal::from(slice.len() as u64))
}
pub fn std_dev(&self, n: usize) -> Option<Decimal> {
let start = self.bars.len().saturating_sub(n);
let slice = &self.bars[start..];
if slice.len() < 2 {
return None;
}
let n_dec = Decimal::from(slice.len() as u64);
let mean: Decimal = slice.iter().map(|b| b.close.value()).sum::<Decimal>() / n_dec;
let variance: Decimal = slice
.iter()
.map(|b| { let d = b.close.value() - mean; d * d })
.sum::<Decimal>()
/ n_dec;
decimal_sqrt(variance).ok()
}
pub fn median_close(&self, n: usize) -> Option<Decimal> {
let start = self.bars.len().saturating_sub(n);
let mut closes: Vec<Decimal> =
self.bars[start..].iter().map(|b| b.close.value()).collect();
if closes.is_empty() {
return None;
}
closes.sort();
let mid = closes.len() / 2;
if closes.len() % 2 == 1 {
Some(closes[mid])
} else {
Some((closes[mid - 1] + closes[mid]) / Decimal::TWO)
}
}
pub fn percentile_rank(&self, value: Decimal, n: usize) -> Option<Decimal> {
let start = self.bars.len().saturating_sub(n);
let slice = &self.bars[start..];
if slice.is_empty() {
return None;
}
let below = slice.iter().filter(|b| b.close.value() < value).count();
Some(Decimal::from(below as u64) / Decimal::from(slice.len() as u64) * Decimal::ONE_HUNDRED)
}
pub fn correlation(&self, other: &OhlcvSeries) -> Option<Decimal> {
let n = self.bars.len().min(other.bars.len());
if n < 2 {
return None;
}
let xs: Vec<Decimal> = self.bars[self.bars.len() - n..].iter().map(|b| b.close.value()).collect();
let ys: Vec<Decimal> = other.bars[other.bars.len() - n..].iter().map(|b| b.close.value()).collect();
let n_dec = Decimal::from(n);
let mean_x: Decimal = xs.iter().copied().sum::<Decimal>() / n_dec;
let mean_y: Decimal = ys.iter().copied().sum::<Decimal>() / n_dec;
let cov: Decimal = xs.iter().zip(ys.iter())
.map(|(x, y)| (*x - mean_x) * (*y - mean_y))
.sum::<Decimal>() / n_dec;
let var_x: Decimal = xs.iter().map(|x| (*x - mean_x) * (*x - mean_x)).sum::<Decimal>() / n_dec;
let var_y: Decimal = ys.iter().map(|y| (*y - mean_y) * (*y - mean_y)).sum::<Decimal>() / n_dec;
if var_x.is_zero() || var_y.is_zero() {
return None;
}
let std_x = decimal_sqrt(var_x).ok()?;
let std_y = decimal_sqrt(var_y).ok()?;
Some(cov / (std_x * std_y))
}
pub fn rolling_sma(&self, period: usize) -> Vec<Option<Decimal>> {
if period == 0 {
return self.bars.iter().map(|_| None).collect();
}
let closes: Vec<Decimal> = self.bars.iter().map(|b| b.close.value()).collect();
closes
.windows(period)
.enumerate()
.fold(vec![None; closes.len()], |mut acc, (i, window)| {
let sum: Decimal = window.iter().copied().sum();
acc[i + period - 1] = Some(sum / Decimal::from(period as u64));
acc
})
}
pub fn zscore(&self, period: usize) -> Vec<Option<Decimal>> {
if period < 2 {
return self.bars.iter().map(|_| None).collect();
}
let closes: Vec<Decimal> = self.bars.iter().map(|b| b.close.value()).collect();
let n = closes.len();
let mut result = vec![None; n];
let period_dec = Decimal::from(period as u64);
for i in (period - 1)..n {
let window = &closes[(i + 1 - period)..=i];
let mean: Decimal = window.iter().copied().sum::<Decimal>() / period_dec;
let variance: Decimal = window
.iter()
.map(|x| (*x - mean) * (*x - mean))
.sum::<Decimal>()
/ period_dec;
if let Ok(std_dev) = decimal_sqrt(variance) {
if !std_dev.is_zero() {
result[i] = Some((closes[i] - mean) / std_dev);
}
}
}
result
}
#[allow(clippy::cast_precision_loss)]
pub fn log_returns(&self) -> Vec<f64> {
if self.bars.len() < 2 {
return Vec::new();
}
self.bars
.windows(2)
.filter_map(|w| {
let prev = w[0].close.value();
if prev.is_zero() {
return None;
}
let ratio = w[1].close.value().checked_div(prev)?;
use rust_decimal::prelude::ToPrimitive;
let ratio_f64 = ratio.to_f64()?;
if ratio_f64 > 0.0 {
Some(ratio_f64.ln())
} else {
None
}
})
.collect()
}
pub fn cumulative_returns(&self) -> Vec<Decimal> {
let first = match self.bars.first() {
Some(b) => b.close.value(),
None => return Vec::new(),
};
if first.is_zero() {
return Vec::new();
}
self.bars
.iter()
.map(|b| b.close.value() / first - Decimal::ONE)
.collect()
}
pub fn resample(&self, n: usize) -> Result<Vec<OhlcvBar>, FinError> {
if n == 0 || self.bars.is_empty() {
return Ok(Vec::new());
}
let mut result = Vec::new();
let mut chunks = self.bars.chunks(n);
for chunk in &mut chunks {
let mut merged = chunk[0].clone();
for b in &chunk[1..] {
merged = merged.merge(b)?;
}
result.push(merged);
}
Ok(result)
}
pub fn max_drawdown(&self) -> Option<Decimal> {
let closes: Vec<Decimal> = self.bars.iter().map(|b| b.close.value()).collect();
if closes.is_empty() {
return None;
}
let mut peak = closes[0];
let mut max_dd = Decimal::ZERO;
for &c in &closes[1..] {
if c > peak {
peak = c;
} else if !peak.is_zero() {
let dd = (peak - c) / peak;
if dd > max_dd {
max_dd = dd;
}
}
}
Some(max_dd)
}
pub fn sharpe_ratio(&self, risk_free_rate: f64, bars_per_year: f64) -> Option<f64> {
let lr = self.log_returns();
if lr.len() < 2 {
return None;
}
let n = lr.len() as f64;
let mean = lr.iter().sum::<f64>() / n;
let variance = lr.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / n;
let std_dev = variance.sqrt();
if std_dev == 0.0 {
return None;
}
let bars_per_year = if bars_per_year <= 0.0 { 252.0 } else { bars_per_year };
Some((mean - risk_free_rate) / std_dev * bars_per_year.sqrt())
}
pub fn price_change_pct(&self, n: usize) -> Option<Decimal> {
let len = self.bars.len();
if len < n + 1 {
return None;
}
let ref_close = self.bars[len - 1 - n].close.value();
if ref_close.is_zero() {
return None;
}
let last_close = self.bars[len - 1].close.value();
Some((last_close - ref_close) / ref_close * Decimal::ONE_HUNDRED)
}
pub fn count_bullish(&self, n: usize) -> usize {
let start = self.bars.len().saturating_sub(n);
self.bars[start..].iter().filter(|b| b.is_bullish()).count()
}
pub fn count_bearish(&self, n: usize) -> usize {
let start = self.bars.len().saturating_sub(n);
self.bars[start..].iter().filter(|b| b.is_bearish()).count()
}
pub fn count_inside_bars(&self) -> usize {
self.bars
.windows(2)
.filter(|w| w[1].is_inside_bar(&w[0]))
.count()
}
pub fn count_outside_bars(&self) -> usize {
self.bars
.windows(2)
.filter(|w| w[1].is_outside_bar(&w[0]))
.count()
}
pub fn pivot_highs(&self, n: usize) -> Vec<usize> {
if n == 0 || self.bars.len() < 2 * n + 1 {
return vec![];
}
let mut pivots = Vec::new();
for i in n..self.bars.len() - n {
let h = self.bars[i].high.value();
let is_pivot = (1..=n).all(|j| {
h > self.bars[i - j].high.value() && h > self.bars[i + j].high.value()
});
if is_pivot {
pivots.push(i);
}
}
pivots
}
pub fn pivot_lows(&self, n: usize) -> Vec<usize> {
if n == 0 || self.bars.len() < 2 * n + 1 {
return vec![];
}
let mut pivots = Vec::new();
for i in n..self.bars.len() - n {
let l = self.bars[i].low.value();
let is_pivot = (1..=n).all(|j| {
l < self.bars[i - j].low.value() && l < self.bars[i + j].low.value()
});
if is_pivot {
pivots.push(i);
}
}
pivots
}
#[allow(clippy::cast_possible_truncation)]
pub fn above_sma(&self, period: usize, n: usize) -> usize {
if self.bars.len() < period || period == 0 {
return 0;
}
let start = self.bars.len().saturating_sub(n);
let window_start = start.saturating_sub(period - 1);
let mut count = 0usize;
for i in start..self.bars.len() {
if i + 1 < period {
continue;
}
let sma_start = i + 1 - period;
let sma: Decimal = self.bars[sma_start..=i]
.iter()
.map(|b| b.close.value())
.sum::<Decimal>()
/ Decimal::from(period as u32);
if self.bars[i].close.value() > sma {
count += 1;
}
}
let _ = window_start; count
}
#[allow(clippy::cast_possible_truncation)]
pub fn below_sma(&self, period: usize, n: usize) -> usize {
if self.bars.len() < period || period == 0 {
return 0;
}
let start = self.bars.len().saturating_sub(n);
let mut count = 0usize;
for i in start..self.bars.len() {
if i + 1 < period {
continue;
}
let sma_start = i + 1 - period;
let sma: Decimal = self.bars[sma_start..=i]
.iter()
.map(|b| b.close.value())
.sum::<Decimal>()
/ Decimal::from(period as u32);
if self.bars[i].close.value() < sma {
count += 1;
}
}
count
}
#[allow(clippy::cast_possible_truncation)]
pub fn above_ema(&self, period: usize) -> bool {
if period == 0 || self.bars.len() < period {
return false;
}
let k = Decimal::TWO / Decimal::from((period + 1) as u32);
let seed: Decimal = self.bars[..period].iter().map(|b| b.close.value()).sum::<Decimal>()
/ Decimal::from(period as u32);
let mut ema = seed;
for bar in &self.bars[period..] {
ema = bar.close.value() * k + ema * (Decimal::ONE - k);
}
self.bars.last().map_or(false, |b| b.close.value() > ema)
}
pub fn bullish_engulfing_count(&self, n: usize) -> usize {
if self.bars.len() < 2 {
return 0;
}
let start = self.bars.len().saturating_sub(n).max(1);
self.bars[start..].iter().enumerate().filter(|(i, bar)| {
let prev = &self.bars[start + i - 1];
bar.is_bullish_engulfing(prev)
}).count()
}
pub fn range_expansion(&self, n: usize) -> Option<Decimal> {
let last = self.bars.last()?;
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let avg_range: Decimal = self.bars[start..].iter().map(|b| b.range()).sum::<Decimal>();
#[allow(clippy::cast_possible_truncation)]
let avg_range = avg_range / Decimal::from(n as u32);
if avg_range == Decimal::ZERO {
return None;
}
Some(last.range() / avg_range)
}
pub fn bearish_engulfing_count(&self, n: usize) -> usize {
if self.bars.len() < 2 {
return 0;
}
let start = self.bars.len().saturating_sub(n).max(1);
self.bars[start..].iter().enumerate().filter(|(i, bar)| {
let prev = &self.bars[start + i - 1];
let p_o = prev.open.value();
let p_c = prev.close.value();
let s_o = bar.open.value();
let s_c = bar.close.value();
p_c > p_o && s_c < s_o && s_o >= p_c && s_c <= p_o
}).count()
}
pub fn trend_strength(&self, n: usize) -> Option<Decimal> {
if n < 2 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let window = &self.bars[start..];
let net = (window.last()?.close.value() - window[0].close.value()).abs();
let total: Decimal = window.windows(2)
.map(|w| (w[1].close.value() - w[0].close.value()).abs())
.sum();
if total == Decimal::ZERO {
return None;
}
Some(net / total)
}
pub fn open_to_close_return(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
for b in &self.bars[start..] {
let o = b.open.value();
if o == Decimal::ZERO {
return None;
}
sum += (b.close.value() - o) / o;
}
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(n as u32))
}
pub fn gap_up_count(&self, n: usize) -> usize {
if self.bars.len() < 2 {
return 0;
}
let start = self.bars.len().saturating_sub(n).max(1);
self.bars[start..].iter().enumerate().filter(|(i, bar)| {
bar.open.value() > self.bars[start + i - 1].close.value()
}).count()
}
pub fn gap_down_count(&self, n: usize) -> usize {
if self.bars.len() < 2 {
return 0;
}
let start = self.bars.len().saturating_sub(n).max(1);
self.bars[start..].iter().enumerate().filter(|(i, bar)| {
bar.open.value() < self.bars[start + i - 1].close.value()
}).count()
}
pub fn overnight_gap_pct(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < 2 {
return None;
}
let start = self.bars.len().saturating_sub(n).max(1);
let window_len = self.bars.len() - start;
if window_len == 0 {
return None;
}
let mut sum = Decimal::ZERO;
for i in start..self.bars.len() {
let pc = self.bars[i - 1].close.value();
if pc == Decimal::ZERO {
return None;
}
sum += (self.bars[i].open.value() - pc) / pc * Decimal::ONE_HUNDRED;
}
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(window_len as u32))
}
pub fn close_rank(&self, n: usize) -> Option<Decimal> {
if n < 2 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let current = self.bars.last()?.close.value();
let below = self.bars[start..self.bars.len() - 1]
.iter()
.filter(|b| b.close.value() < current)
.count();
#[allow(clippy::cast_possible_truncation)]
Some(Decimal::from(below as u32) / Decimal::from((n - 1) as u32) * Decimal::ONE_HUNDRED)
}
pub fn high_low_ratio(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let hh = self.highest_high(n)?;
let ll = self.lowest_low(n)?;
if ll == Decimal::ZERO {
return None;
}
Some(hh / ll)
}
#[allow(clippy::cast_possible_truncation)]
pub fn average_volume(&self, n: usize) -> Option<Decimal> {
let start = self.bars.len().saturating_sub(n);
let slice = &self.bars[start..];
if slice.is_empty() {
return None;
}
let sum: Decimal = slice.iter().map(|b| b.volume.value()).sum();
Some(sum / Decimal::from(slice.len() as u32))
}
pub fn last_n_closes(&self, n: usize) -> Vec<Decimal> {
let start = self.bars.len().saturating_sub(n);
self.bars[start..].iter().map(|b| b.close.value()).collect()
}
pub fn volume_spike(&self, n: usize, multiplier: Decimal) -> bool {
if self.bars.len() < 2 || multiplier.is_zero() {
return false;
}
let last_vol = self.bars.last().unwrap().volume.value();
let prior_count = self.bars.len() - 1;
let start = prior_count.saturating_sub(n);
let prior = &self.bars[start..prior_count];
if prior.is_empty() {
return false;
}
let avg: Decimal = prior.iter().map(|b| b.volume.value()).sum::<Decimal>()
/ Decimal::from(prior.len() as u32);
last_vol > avg * multiplier
}
#[allow(clippy::cast_possible_truncation)]
pub fn average_range(&self, n: usize) -> Option<Decimal> {
let start = self.bars.len().saturating_sub(n);
let slice = &self.bars[start..];
if slice.is_empty() {
return None;
}
let sum: Decimal = slice.iter().map(|b| b.range()).sum();
Some(sum / Decimal::from(slice.len() as u32))
}
#[allow(clippy::cast_possible_truncation)]
pub fn typical_price_mean(&self, n: usize) -> Option<Decimal> {
let start = self.bars.len().saturating_sub(n);
let slice = &self.bars[start..];
if slice.is_empty() {
return None;
}
let sum: Decimal = slice.iter().map(|b| b.typical_price()).sum();
Some(sum / Decimal::from(slice.len() as u32))
}
pub fn sortino_ratio(&self, risk_free_rate: f64, bars_per_year: f64) -> Option<f64> {
let log_rets = self.log_returns();
if log_rets.len() < 2 {
return None;
}
let mean_ret = log_rets.iter().copied().sum::<f64>() / log_rets.len() as f64;
let downside: Vec<f64> = log_rets.iter().map(|&r| if r < 0.0 { r * r } else { 0.0 }).collect();
let downside_var = downside.iter().copied().sum::<f64>() / downside.len() as f64;
let downside_dev = downside_var.sqrt();
if downside_dev == 0.0 {
return None;
}
let rf_per_bar = risk_free_rate / bars_per_year;
Some((mean_ret - rf_per_bar) / downside_dev * bars_per_year.sqrt())
}
pub fn close_above_open_streak(&self) -> usize {
self.bars
.iter()
.rev()
.take_while(|b| b.is_bullish())
.count()
}
pub fn max_drawdown_pct(&self, n: usize) -> Option<f64> {
let window: Vec<f64> = self
.bars
.iter()
.rev()
.take(n)
.map(|b| { use rust_decimal::prelude::ToPrimitive; b.close.value().to_f64().unwrap_or(0.0) })
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
if window.len() < 2 {
return None;
}
let mut max_dd = 0.0f64;
let mut peak = window[0];
for &price in &window[1..] {
if price > peak {
peak = price;
}
if peak > 0.0 {
let dd = (peak - price) / peak * 100.0;
if dd > max_dd {
max_dd = dd;
}
}
}
Some(max_dd)
}
#[allow(clippy::cast_possible_truncation)]
pub fn atr_series(&self, period: usize) -> Vec<Option<Decimal>> {
let n = self.bars.len();
let mut result = vec![None; n];
if period == 0 || n == 0 {
return result;
}
let trs: Vec<Decimal> = self
.bars
.iter()
.enumerate()
.map(|(i, b)| {
let prev = if i == 0 { None } else { Some(&self.bars[i - 1]) };
b.true_range(prev)
})
.collect();
for i in (period - 1)..n {
let sum: Decimal = trs[i + 1 - period..=i].iter().copied().sum();
result[i] = Some(sum / Decimal::from(period as u32));
}
result
}
pub fn up_days(&self, n: usize) -> usize {
if self.bars.len() < 2 {
return 0;
}
let start = self.bars.len().saturating_sub(n).max(1);
self.bars[start..]
.iter()
.enumerate()
.filter(|(i, b)| b.close.value() > self.bars[start + i - 1].close.value())
.count()
}
pub fn down_days(&self, n: usize) -> usize {
if self.bars.len() < 2 {
return 0;
}
let start = self.bars.len().saturating_sub(n).max(1);
self.bars[start..]
.iter()
.enumerate()
.filter(|(i, b)| b.close.value() < self.bars[start + i - 1].close.value())
.count()
}
pub fn range_series(&self) -> Vec<Decimal> {
self.bars.iter().map(|b| b.range()).collect()
}
pub fn close_to_close_changes(&self) -> Vec<Decimal> {
if self.bars.len() < 2 {
return vec![];
}
self.bars
.windows(2)
.map(|w| (w[1].close.value() - w[0].close.value()).abs())
.collect()
}
pub fn volatility_ratio(&self, short: usize, long: usize) -> Option<Decimal> {
let n = self.bars.len();
if short == 0 || long == 0 || n == 0 {
return None;
}
let short_atr = *self.atr_series(short).last()?;
let long_atr = *self.atr_series(long).last()?;
let s = short_atr?;
let l = long_atr?;
if l.is_zero() {
return None;
}
Some(s / l)
}
pub fn streak(&self) -> i32 {
let n = self.bars.len();
if n < 2 {
return 0;
}
let mut count: i32 = 0;
for i in (1..n).rev() {
let prev = self.bars[i - 1].close.value();
let curr = self.bars[i].close.value();
if curr > prev {
if count < 0 {
break;
}
count += 1;
} else if curr < prev {
if count > 0 {
break;
}
count -= 1;
} else {
break;
}
}
count
}
pub fn calmar_ratio(&self, bars_per_year: f64) -> Option<f64> {
let lr = self.log_returns();
if lr.len() < 2 {
return None;
}
let ann_return = (lr.iter().sum::<f64>() / lr.len() as f64) * bars_per_year;
let dd = self.max_drawdown()?;
use rust_decimal::prelude::ToPrimitive;
let dd_f64 = dd.to_f64()?;
if dd_f64 == 0.0_f64 {
return None;
}
Some(ann_return / dd_f64)
}
pub fn session_high_low(&self, n: usize) -> Option<(Decimal, Decimal)> {
let start = self.bars.len().saturating_sub(n);
let slice = &self.bars[start..];
if slice.is_empty() {
return None;
}
let h = slice.iter().map(|b| b.high.value()).fold(Decimal::MIN, Decimal::max);
let l = slice.iter().map(|b| b.low.value()).fold(Decimal::MAX, Decimal::min);
Some((h, l))
}
pub fn percentage_change_series(&self) -> Vec<Option<Decimal>> {
if self.bars.len() < 2 {
return vec![];
}
self.bars
.windows(2)
.map(|w| {
let prev_c = w[0].close.value();
if prev_c.is_zero() {
None
} else {
Some((w[1].close.value() - prev_c) / prev_c * Decimal::ONE_HUNDRED)
}
})
.collect()
}
pub fn realized_volatility(&self, n: usize, bars_per_year: f64) -> Option<f64> {
if n == 0 || self.bars.len() < n + 1 {
return None;
}
let start = self.bars.len() - n - 1;
let lr: Vec<f64> = self.bars[start..]
.windows(2)
.filter_map(|w| {
let prev = w[0].close.value();
if prev.is_zero() {
return None;
}
use rust_decimal::prelude::ToPrimitive;
let ratio = (w[1].close.value() / prev).to_f64()?;
Some(ratio.ln())
})
.collect();
if lr.len() < 2 {
return None;
}
let mean = lr.iter().sum::<f64>() / lr.len() as f64;
let variance = lr.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / lr.len() as f64;
Some(variance.sqrt() * bars_per_year.sqrt())
}
pub fn rolling_correlation(&self, other: &OhlcvSeries, n: usize) -> Option<f64> {
if n < 2 || self.bars.len() < n || other.bars.len() < n {
return None;
}
use rust_decimal::prelude::ToPrimitive;
let xs: Vec<f64> = self.bars[self.bars.len() - n..]
.iter()
.filter_map(|b| b.close.value().to_f64())
.collect();
let ys: Vec<f64> = other.bars[other.bars.len() - n..]
.iter()
.filter_map(|b| b.close.value().to_f64())
.collect();
if xs.len() != n || ys.len() != n {
return None;
}
let n_f = n as f64;
let mx = xs.iter().sum::<f64>() / n_f;
let my = ys.iter().sum::<f64>() / n_f;
let cov = xs.iter().zip(ys.iter()).map(|(x, y)| (x - mx) * (y - my)).sum::<f64>() / n_f;
let sx = (xs.iter().map(|x| (x - mx).powi(2)).sum::<f64>() / n_f).sqrt();
let sy = (ys.iter().map(|y| (y - my).powi(2)).sum::<f64>() / n_f).sqrt();
if sx == 0.0 || sy == 0.0 {
return None;
}
Some(cov / (sx * sy))
}
pub fn beta(&self, market: &OhlcvSeries, n: usize) -> Option<f64> {
if n < 2 || self.bars.len() < n + 1 || market.bars.len() < n + 1 {
return None;
}
use rust_decimal::prelude::ToPrimitive;
let asset_lr: Vec<f64> = self.bars[self.bars.len() - n - 1..]
.windows(2)
.filter_map(|w| {
let prev = w[0].close.value();
if prev.is_zero() { return None; }
(w[1].close.value() / prev).to_f64().map(|r| r.ln())
})
.collect();
let mkt_lr: Vec<f64> = market.bars[market.bars.len() - n - 1..]
.windows(2)
.filter_map(|w| {
let prev = w[0].close.value();
if prev.is_zero() { return None; }
(w[1].close.value() / prev).to_f64().map(|r| r.ln())
})
.collect();
let len = asset_lr.len().min(mkt_lr.len());
if len < 2 {
return None;
}
let n_f = len as f64;
let ma = asset_lr[..len].iter().sum::<f64>() / n_f;
let mm = mkt_lr[..len].iter().sum::<f64>() / n_f;
let cov = asset_lr[..len].iter().zip(mkt_lr[..len].iter())
.map(|(a, m)| (a - ma) * (m - mm))
.sum::<f64>() / n_f;
let var_m = mkt_lr[..len].iter().map(|m| (m - mm).powi(2)).sum::<f64>() / n_f;
if var_m == 0.0 { return None; }
Some(cov / var_m)
}
pub fn information_ratio(&self, benchmark: &OhlcvSeries, n: usize) -> Option<f64> {
if n < 2 || self.bars.len() < n + 1 || benchmark.bars.len() < n + 1 {
return None;
}
use rust_decimal::prelude::ToPrimitive;
let excess: Vec<f64> = self.bars[self.bars.len() - n - 1..]
.windows(2)
.zip(benchmark.bars[benchmark.bars.len() - n - 1..].windows(2))
.filter_map(|(aw, bw)| {
let ap = aw[0].close.value();
let bp = bw[0].close.value();
if ap.is_zero() || bp.is_zero() { return None; }
let ar = (aw[1].close.value() / ap).to_f64()?.ln();
let br = (bw[1].close.value() / bp).to_f64()?.ln();
Some(ar - br)
})
.collect();
if excess.len() < 2 { return None; }
let n_f = excess.len() as f64;
let mean = excess.iter().sum::<f64>() / n_f;
let te = (excess.iter().map(|e| (e - mean).powi(2)).sum::<f64>() / n_f).sqrt();
if te == 0.0 { return None; }
Some(mean / te)
}
pub fn drawdown_series(&self) -> Vec<Decimal> {
if self.bars.is_empty() {
return vec![];
}
let mut peak = Decimal::MIN;
self.bars
.iter()
.map(|b| {
let close = b.close.value();
if close > peak {
peak = close;
}
if peak.is_zero() {
Decimal::ZERO
} else {
(peak - close) / peak
}
})
.collect()
}
pub fn above_moving_average(&self, period: usize) -> Option<bool> {
if period == 0 || self.bars.len() < period {
return None;
}
let start = self.bars.len() - period;
#[allow(clippy::cast_possible_truncation)]
let sma: Decimal = self.bars[start..].iter().map(|b| b.close.value()).sum::<Decimal>()
/ Decimal::from(period as u32);
Some(self.bars.last()?.close.value() > sma)
}
pub fn consecutive_higher_highs(&self, n: usize) -> usize {
if n == 0 || self.bars.len() < 2 {
return 0;
}
let start = self.bars.len().saturating_sub(n).max(1);
self.bars[start..]
.iter()
.enumerate()
.filter(|(i, b)| b.high.value() > self.bars[start + i - 1].high.value())
.count()
}
pub fn consecutive_lower_lows(&self, n: usize) -> usize {
if n == 0 || self.bars.len() < 2 {
return 0;
}
let start = self.bars.len().saturating_sub(n).max(1);
self.bars[start..]
.iter()
.enumerate()
.filter(|(i, b)| b.low.value() < self.bars[start + i - 1].low.value())
.count()
}
pub fn vwap_deviation(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len().saturating_sub(n);
let slice = &self.bars[start..];
let total_vol: Decimal = slice.iter().map(|b| b.volume.value()).sum();
if total_vol.is_zero() {
return None;
}
let vwap: Decimal = slice.iter()
.map(|b| {
let tp = b.typical_price();
tp * b.volume.value()
})
.sum::<Decimal>() / total_vol;
if vwap.is_zero() {
return None;
}
let last_close = self.bars.last()?.close.value();
Some((last_close - vwap) / vwap * Decimal::ONE_HUNDRED)
}
pub fn average_true_range_pct(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n == 0 || self.bars.len() < n {
return None;
}
let atrs = self.atr_series(n);
let last_close = self.bars.last()?.close.value();
if last_close.is_zero() {
return None;
}
let atr = (*atrs.last()?.as_ref()?).to_f64()?;
let close_f64 = last_close.to_f64()?;
Some(atr / close_f64 * 100.0)
}
pub fn count_doji(&self, n: usize, threshold: Decimal) -> usize {
if n == 0 {
return 0;
}
let start = self.bars.len().saturating_sub(n);
self.bars[start..].iter().filter(|b| b.is_doji(threshold)).count()
}
pub fn gap_up_bars(&self, n: usize) -> usize {
if n == 0 || self.bars.len() < 2 {
return 0;
}
let start = self.bars.len().saturating_sub(n + 1);
self.bars[start..].windows(2).filter(|w| w[1].gap_up_from(&w[0])).count()
}
pub fn gap_down_bars(&self, n: usize) -> usize {
if n == 0 || self.bars.len() < 2 {
return 0;
}
let start = self.bars.len().saturating_sub(n + 1);
self.bars[start..].windows(2).filter(|w| w[1].gap_down_from(&w[0])).count()
}
pub fn cum_volume(&self, n: usize) -> Decimal {
if n == 0 {
return Decimal::ZERO;
}
let start = self.bars.len().saturating_sub(n);
self.bars[start..].iter().map(|b| b.volume.value()).sum()
}
pub fn momentum_score(&self, short: usize, long: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if short == 0 || long == 0 || short >= long || self.bars.len() < long {
return None;
}
#[allow(clippy::cast_possible_truncation)]
let sma = |n: usize| -> Option<Decimal> {
let start = self.bars.len().saturating_sub(n);
let s: Decimal = self.bars[start..].iter().map(|b| b.close.value()).sum();
Some(s / Decimal::from(n as u32))
};
let sma_s = sma(short)?;
let sma_l = sma(long)?;
if sma_l.is_zero() {
return None;
}
((sma_s - sma_l) / sma_l * Decimal::ONE_HUNDRED).to_f64()
}
pub fn first_bar(&self) -> Option<&OhlcvBar> {
self.bars.first()
}
pub fn volume_weighted_close(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let vol_sum: Decimal = self.bars[start..].iter().map(|b| b.volume.value()).sum();
if vol_sum.is_zero() {
return None;
}
let pv_sum: Decimal = self.bars[start..]
.iter()
.map(|b| b.close.value() * b.volume.value())
.sum();
Some(pv_sum / vol_sum)
}
pub fn range_expansion_ratio(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n == 0 || self.bars.len() < n {
return None;
}
let last_range = self.bars.last()?.range();
let start = self.bars.len() - n;
let avg_range = self.bars[start..]
.iter()
.map(|b| b.range())
.sum::<Decimal>();
#[allow(clippy::cast_possible_truncation)]
let avg = avg_range / Decimal::from(n as u32);
if avg.is_zero() {
return None;
}
(last_range / avg).to_f64()
}
pub fn efficiency_ratio(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() <= n {
return None;
}
let start = self.bars.len() - n - 1;
let closes: Vec<Decimal> = self.bars[start..].iter().map(|b| b.close.value()).collect();
let direction = (closes[n] - closes[0]).abs();
let path: Decimal = closes.windows(2).map(|w| (w[1] - w[0]).abs()).sum();
if path.is_zero() {
return None;
}
Some(direction / path)
}
pub fn body_pct_series(&self, n: usize) -> Vec<Option<Decimal>> {
let start = self.bars.len().saturating_sub(n);
self.bars[start..]
.iter()
.map(|b| {
let range = b.range();
if range.is_zero() {
None
} else {
let body = b.body_size();
Some(body / range * Decimal::ONE_HUNDRED)
}
})
.collect()
}
pub fn candle_color_changes(&self, n: usize) -> usize {
let start = self.bars.len().saturating_sub(n);
let slice = &self.bars[start..];
if slice.len() < 2 {
return 0;
}
slice.windows(2)
.filter(|w| {
let prev_bull = w[0].close.value() >= w[0].open.value();
let curr_bull = w[1].close.value() >= w[1].open.value();
prev_bull != curr_bull
})
.count()
}
pub fn typical_price_series(&self, n: usize) -> Vec<Decimal> {
let start = self.bars.len().saturating_sub(n);
self.bars[start..]
.iter()
.map(|b| b.typical_price())
.collect()
}
pub fn open_gap_series(&self) -> Vec<Decimal> {
if self.bars.len() < 2 {
return Vec::new();
}
self.bars
.windows(2)
.filter_map(|w| {
let prev_close = w[0].close.value();
if prev_close.is_zero() {
return None;
}
Some((w[1].open.value() - prev_close) / prev_close * Decimal::ONE_HUNDRED)
})
.collect()
}
pub fn intraday_range_pct(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.is_empty() {
return None;
}
let start = self.bars.len().saturating_sub(n);
let slice = &self.bars[start..];
let count = slice.len();
if count == 0 {
return None;
}
let sum: Option<Decimal> = slice.iter().try_fold(Decimal::ZERO, |acc, b| {
let o = b.open.value();
if o.is_zero() { return None; }
Some(acc + (b.range()) / o * Decimal::ONE_HUNDRED)
});
#[allow(clippy::cast_possible_truncation)]
Some(sum? / Decimal::from(count as u32))
}
pub fn close_above_prior_high(&self, n: usize) -> usize {
if n == 0 || self.bars.len() < 2 {
return 0;
}
let start = self.bars.len().saturating_sub(n + 1);
self.bars[start..].windows(2).filter(|w| w[1].close.value() > w[0].high.value()).count()
}
pub fn skewness(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 3 || self.bars.len() < n {
return None;
}
let start = self.bars.len().saturating_sub(n);
let vals: Vec<f64> = self.bars[start..]
.iter()
.filter_map(|b| b.close.value().to_f64())
.collect();
if vals.len() < 3 {
return None;
}
let n_f = vals.len() as f64;
let mean = vals.iter().sum::<f64>() / n_f;
let variance = vals.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n_f;
let std_dev = variance.sqrt();
if std_dev == 0.0 {
return None;
}
let skew = vals.iter().map(|x| ((x - mean) / std_dev).powi(3)).sum::<f64>() / n_f;
Some(skew)
}
pub fn kurtosis(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 4 || self.bars.len() < n {
return None;
}
let start = self.bars.len().saturating_sub(n);
let vals: Vec<f64> = self.bars[start..]
.iter()
.filter_map(|b| b.close.value().to_f64())
.collect();
if vals.len() < 4 {
return None;
}
let n_f = vals.len() as f64;
let mean = vals.iter().sum::<f64>() / n_f;
let variance = vals.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n_f;
if variance == 0.0 {
return None;
}
let kurt = vals.iter().map(|x| ((x - mean) / variance.sqrt()).powi(4)).sum::<f64>() / n_f - 3.0;
Some(kurt)
}
pub fn sma_crossover(&self, fast_period: usize, slow_period: usize) -> bool {
if fast_period == 0 || slow_period == 0 || fast_period >= slow_period {
return false;
}
if self.bars.len() < slow_period {
return false;
}
let fast_start = self.bars.len() - fast_period;
let slow_start = self.bars.len() - slow_period;
let fast_avg: Decimal = self.bars[fast_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
/ Decimal::from(fast_period as u32);
let slow_avg: Decimal = self.bars[slow_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
/ Decimal::from(slow_period as u32);
fast_avg > slow_avg
}
pub fn price_percentile(&self, price: Decimal, n: usize) -> Option<f64> {
if n == 0 || self.bars.is_empty() {
return None;
}
let start = self.bars.len().saturating_sub(n);
let slice = &self.bars[start..];
let count = slice.iter().filter(|b| b.close.value() <= price).count();
Some(count as f64 / slice.len() as f64)
}
pub fn intraday_range_mean(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let sum: Decimal = self.bars[start..].iter().map(|b| b.range()).sum();
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(n as u32))
}
pub fn range_to_atr_ratio(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 {
return None;
}
let start = self.bars.len() - n - 1;
let slice = &self.bars[start..];
let mut tr_sum = Decimal::ZERO;
for w in slice.windows(2) {
let prev_close = w[0].close.value();
let high = w[1].high.value();
let low = w[1].low.value();
let tr = (high - low)
.max((high - prev_close).abs())
.max((low - prev_close).abs());
tr_sum += tr;
}
#[allow(clippy::cast_possible_truncation)]
let atr = tr_sum / Decimal::from(n as u32);
if atr.is_zero() {
return None;
}
let last = self.bars.last()?;
let current_range = last.range();
Some(current_range / atr * Decimal::ONE_HUNDRED)
}
pub fn close_momentum(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 {
return None;
}
let ref_close = self.bars[self.bars.len() - n - 1].close.value();
if ref_close.is_zero() {
return None;
}
let current = self.bars.last()?.close.value();
Some((current - ref_close) / ref_close * Decimal::ONE_HUNDRED)
}
pub fn average_gap_pct(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() <= n {
return None;
}
let start = self.bars.len() - n - 1;
let slice = &self.bars[start..];
let mut count = 0;
let mut sum = Decimal::ZERO;
for pair in slice.windows(2) {
let pc = pair[0].close.value();
if pc.is_zero() {
continue;
}
sum += (pair[1].open.value() - pc).abs() / pc * Decimal::ONE_HUNDRED;
count += 1;
}
if count == 0 {
None
} else {
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(count as u32))
}
}
pub fn returns_series(&self, n: usize) -> Vec<Decimal> {
if n == 0 || self.bars.len() < 2 {
return vec![];
}
use rust_decimal::prelude::ToPrimitive;
let start = self.bars.len().saturating_sub(n + 1);
let slice = &self.bars[start..];
slice
.windows(2)
.map(|w| {
let prev = w[0].close.value();
let curr = w[1].close.value();
if prev.is_zero() {
Decimal::ZERO
} else {
let ratio = (curr / prev).to_f64().unwrap_or(1.0);
Decimal::try_from(ratio.ln()).unwrap_or(Decimal::ZERO)
}
})
.collect()
}
pub fn max_consecutive_up(&self) -> usize {
if self.bars.len() < 2 {
return 0;
}
let mut max_run = 0usize;
let mut current = 0usize;
for w in self.bars.windows(2) {
if w[1].close.value() > w[0].close.value() {
current += 1;
if current > max_run {
max_run = current;
}
} else {
current = 0;
}
}
max_run
}
pub fn max_consecutive_down(&self) -> usize {
if self.bars.len() < 2 {
return 0;
}
let mut max_run = 0usize;
let mut current = 0usize;
for w in self.bars.windows(2) {
if w[1].close.value() < w[0].close.value() {
current += 1;
if current > max_run {
max_run = current;
}
} else {
current = 0;
}
}
max_run
}
pub fn typical_price_sma(&self, period: usize) -> Option<Decimal> {
if period == 0 || self.bars.len() < period {
return None;
}
let start = self.bars.len() - period;
let sum: Decimal = self.bars[start..]
.iter()
.map(|b| b.typical_price())
.sum();
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(period as u32))
}
pub fn bar_at_index(&self, i: usize) -> Option<&OhlcvBar> {
self.bars.get(i)
}
#[allow(clippy::cast_possible_truncation)]
pub fn rolling_close_std(&self, n: usize) -> Option<Decimal> {
if n < 2 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let closes: Vec<Decimal> = self.bars[start..].iter().map(|b| b.close.value()).collect();
let mean = closes.iter().copied().sum::<Decimal>() / Decimal::from(n as u32);
let variance = closes
.iter()
.map(|c| { let d = *c - mean; d * d })
.sum::<Decimal>()
/ Decimal::from((n - 1) as u32);
use rust_decimal::prelude::ToPrimitive;
let std = variance.to_f64()?.sqrt();
Decimal::try_from(std).ok()
}
pub fn gap_direction_series(&self, n: usize) -> Vec<i8> {
if n < 2 || self.bars.len() < 2 {
return vec![];
}
let start = self.bars.len().saturating_sub(n);
self.bars[start..]
.windows(2)
.map(|w| {
let gap = w[1].open.value() - w[0].close.value();
if gap > Decimal::ZERO {
1i8
} else if gap < Decimal::ZERO {
-1i8
} else {
0i8
}
})
.collect()
}
pub fn volume_trend(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 2 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let vols: Vec<f64> = self.bars[start..]
.iter()
.filter_map(|b| b.volume.value().to_f64())
.collect();
if vols.len() < 2 {
return None;
}
let n_f = vols.len() as f64;
let sum_x: f64 = (0..vols.len()).map(|i| i as f64).sum();
let sum_y: f64 = vols.iter().sum();
let sum_xy: f64 = vols.iter().enumerate().map(|(i, &v)| i as f64 * v).sum();
let sum_xx: f64 = (0..vols.len()).map(|i| (i as f64).powi(2)).sum();
let denom = n_f * sum_xx - sum_x * sum_x;
if denom == 0.0 { return None; }
Some((n_f * sum_xy - sum_x * sum_y) / denom)
}
pub fn wick_body_ratio(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let mut sum = 0.0f64;
let mut count = 0usize;
for b in &self.bars[start..] {
let body = b.body_size().to_f64()?;
if body == 0.0 { continue; }
let range = (b.range()).to_f64()?;
let wick = (range - body).max(0.0);
sum += wick / body;
count += 1;
}
if count == 0 { return None; }
Some(sum / count as f64)
}
pub fn volume_price_correlation(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 2 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let xs: Vec<f64> = self.bars[start..]
.iter()
.filter_map(|b| b.volume.value().to_f64())
.collect();
let ys: Vec<f64> = self.bars[start..]
.iter()
.filter_map(|b| b.close.value().to_f64())
.collect();
if xs.len() < 2 { return None; }
let n_f = xs.len() as f64;
let mx = xs.iter().sum::<f64>() / n_f;
let my = ys.iter().sum::<f64>() / n_f;
let num: f64 = xs.iter().zip(ys.iter()).map(|(x, y)| (x - mx) * (y - my)).sum();
let sx = (xs.iter().map(|x| (x - mx).powi(2)).sum::<f64>() / n_f).sqrt();
let sy = (ys.iter().map(|y| (y - my).powi(2)).sum::<f64>() / n_f).sqrt();
if sx == 0.0 || sy == 0.0 { return None; }
Some(num / (n_f * sx * sy))
}
pub fn bar_range_pct(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
let mut count = 0u32;
for b in &self.bars[start..] {
let c = b.close.value();
if c.is_zero() { continue; }
sum += (b.range()) / c * Decimal::ONE_HUNDRED;
count += 1;
}
if count == 0 { return None; }
Some(sum / Decimal::from(count))
}
pub fn close_vs_prior_range_count(&self, n: usize) -> usize {
if n < 2 || self.bars.len() < 2 {
return 0;
}
let start = self.bars.len().saturating_sub(n);
let slice = &self.bars[start..];
slice.windows(2)
.filter(|w| {
let mid = (w[0].high.value() + w[0].low.value()) / Decimal::TWO;
w[1].close.value() > mid
})
.count()
}
pub fn rolling_sharpe(&self, n: usize, risk_free_rate: Decimal) -> Option<Decimal> {
if n == 0 || self.bars.len() < 2 {
return None;
}
use rust_decimal::prelude::ToPrimitive;
let returns = self.returns_series(n);
if returns.len() < 2 {
return None;
}
#[allow(clippy::cast_possible_truncation)]
let len_d = Decimal::from(returns.len() as u32);
let mean: Decimal = returns.iter().copied().sum::<Decimal>() / len_d;
let rf_daily = risk_free_rate / Decimal::from(252u32);
let excess_mean = mean - rf_daily;
let variance = returns
.iter()
.map(|r| { let d = *r - mean; d * d })
.sum::<Decimal>()
/ len_d;
let std_f64 = variance.to_f64()?.sqrt();
if std_f64 == 0.0 {
return None;
}
let sharpe = excess_mean.to_f64()? / std_f64 * 252.0f64.sqrt();
Decimal::try_from(sharpe).ok()
}
pub fn close_range_position(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let highest = slice.iter().map(|b| b.high.value()).fold(Decimal::MIN, Decimal::max);
let lowest = slice.iter().map(|b| b.low.value()).fold(Decimal::MAX, Decimal::min);
let range = highest - lowest;
if range.is_zero() {
return None;
}
let close = self.bars.last()?.close.value();
Some((close - lowest) / range * Decimal::ONE_HUNDRED)
}
pub fn bar_count_since_high(&self, n: usize) -> usize {
if n == 0 || self.bars.len() < n {
return 0;
}
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let mut max_val = Decimal::MIN;
let mut max_idx = 0;
for (i, b) in slice.iter().enumerate() {
let c = b.close.value();
if c > max_val {
max_val = c;
max_idx = i;
}
}
slice.len() - 1 - max_idx
}
pub fn close_to_open_ratio(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
let mut count = 0usize;
for b in &self.bars[start..] {
let o = b.open.value();
if o.is_zero() {
continue;
}
sum += (b.close.value() / o - Decimal::ONE) * Decimal::ONE_HUNDRED;
count += 1;
}
if count == 0 {
return None;
}
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(count as u32))
}
pub fn autocorrelation(&self, n: usize, lag: usize) -> Option<f64> {
if n == 0 || lag == 0 || self.bars.len() < n + lag + 1 {
return None;
}
use rust_decimal::prelude::ToPrimitive;
let returns = self.returns_series(n + lag);
if returns.len() <= lag {
return None;
}
let x: Vec<f64> = returns[..returns.len() - lag].iter().map(|r| r.to_f64().unwrap_or(0.0)).collect();
let y: Vec<f64> = returns[lag..].iter().map(|r| r.to_f64().unwrap_or(0.0)).collect();
let n_f = x.len() as f64;
let mean_x = x.iter().sum::<f64>() / n_f;
let mean_y = y.iter().sum::<f64>() / n_f;
let cov: f64 = x.iter().zip(y.iter()).map(|(xi, yi)| (xi - mean_x) * (yi - mean_y)).sum::<f64>() / n_f;
let std_x = (x.iter().map(|xi| (xi - mean_x).powi(2)).sum::<f64>() / n_f).sqrt();
let std_y = (y.iter().map(|yi| (yi - mean_y).powi(2)).sum::<f64>() / n_f).sqrt();
if std_x == 0.0 || std_y == 0.0 {
return None;
}
Some(cov / (std_x * std_y))
}
pub fn hurst_exponent(&self, n: usize) -> Option<f64> {
if n < 8 || self.bars.len() < n + 1 {
return None;
}
use rust_decimal::prelude::ToPrimitive;
let returns: Vec<f64> = self
.returns_series(n)
.iter()
.map(|r| r.to_f64().unwrap_or(0.0))
.collect();
if returns.is_empty() {
return None;
}
let mean = returns.iter().sum::<f64>() / returns.len() as f64;
let cum: Vec<f64> = returns.iter().scan(0.0f64, |acc, &r| { *acc += r - mean; Some(*acc) }).collect();
let r = cum.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
- cum.iter().cloned().fold(f64::INFINITY, f64::min);
let s = (returns.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / returns.len() as f64).sqrt();
if s == 0.0 || r <= 0.0 {
return None;
}
Some((r / s).ln() / (returns.len() as f64).ln())
}
pub fn ulcer_index(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
use rust_decimal::prelude::ToPrimitive;
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let mut peak = Decimal::ZERO;
let mut sum_sq = 0.0f64;
for b in slice {
let c = b.close.value();
if c > peak { peak = c; }
if peak.is_zero() { continue; }
let dd_pct = ((c - peak) / peak * Decimal::ONE_HUNDRED).to_f64().unwrap_or(0.0);
sum_sq += dd_pct * dd_pct;
}
let ui = (sum_sq / n as f64).sqrt();
Decimal::try_from(ui).ok()
}
pub fn cvar(&self, n: usize, confidence_pct: Decimal) -> Option<Decimal> {
use rust_decimal::prelude::ToPrimitive;
if n < 2 || confidence_pct <= Decimal::ZERO || confidence_pct >= Decimal::ONE_HUNDRED {
return None;
}
let mut returns = self.returns_series(n);
if returns.len() < 2 {
return None;
}
returns.sort_unstable_by(|a, b| a.cmp(b));
let cutoff = ((Decimal::ONE - confidence_pct / Decimal::ONE_HUNDRED)
.to_f64()
.unwrap_or(0.05)
* returns.len() as f64)
.ceil() as usize;
let tail = &returns[..cutoff.min(returns.len())];
if tail.is_empty() {
return None;
}
#[allow(clippy::cast_possible_truncation)]
let avg = tail.iter().copied().sum::<Decimal>() / Decimal::from(tail.len() as u32);
Some(avg)
}
pub fn close_change_pct(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() <= n {
return None;
}
let recent = self.bars.last()?.close.value();
let earlier = self.bars[self.bars.len() - 1 - n].close.value();
if earlier.is_zero() {
return None;
}
Some((recent - earlier) / earlier * Decimal::ONE_HUNDRED)
}
pub fn close_above_vwap_pct(&self, n: usize) -> Option<f64> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let window = &self.bars[start..];
let total_vol: Decimal = window.iter().map(|b| b.volume.value()).sum();
if total_vol.is_zero() { return None; }
let vwap = window.iter()
.map(|b| b.typical_price() * b.volume.value())
.sum::<Decimal>() / total_vol;
let above = window.iter().filter(|b| b.close.value() > vwap).count();
Some(above as f64 / n as f64 * 100.0)
}
pub fn reversal_count(&self, n: usize) -> usize {
if n < 2 || self.bars.len() < n { return 0; }
let start = self.bars.len() - n;
self.bars[start..].windows(3)
.filter(|w| {
let prev_dir = w[1].close.value() > w[0].close.value();
let curr_dir = w[2].close.value() > w[1].close.value();
prev_dir != curr_dir
})
.count()
}
pub fn open_gap_fill_rate(&self, n: usize) -> Option<f64> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let start = self.bars.len() - n;
let mut gap_count = 0usize;
let mut filled = 0usize;
for i in start..self.bars.len() {
let prior_close = self.bars[i - 1].close.value();
let bar = &self.bars[i];
let open = bar.open.value();
if open == prior_close { continue; }
gap_count += 1;
let gap_up = open > prior_close;
if gap_up && bar.low.value() <= prior_close {
filled += 1;
} else if !gap_up && bar.high.value() >= prior_close {
filled += 1;
}
}
if gap_count == 0 { return None; }
Some(filled as f64 / gap_count as f64 * 100.0)
}
pub fn candle_symmetry(&self, n: usize) -> Option<f64> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut ratios = Vec::new();
for bar in &self.bars[start..] {
let body_top = bar.close.value().max(bar.open.value());
let body_bot = bar.close.value().min(bar.open.value());
let upper = bar.high.value() - body_top;
let lower = body_bot - bar.low.value();
if upper.is_zero() && lower.is_zero() { continue; }
let total = upper + lower;
if total.is_zero() { continue; }
use rust_decimal::prelude::ToPrimitive;
let ratio: f64 = lower.to_f64().unwrap_or(0.0)
/ total.to_f64().unwrap_or(1.0);
ratios.push(ratio);
}
if ratios.is_empty() { return None; }
Some(ratios.iter().sum::<f64>() / ratios.len() as f64)
}
}
impl Default for OhlcvSeries {
fn default() -> Self {
Self::new()
}
}
impl<'a> IntoIterator for &'a OhlcvSeries {
type Item = &'a OhlcvBar;
type IntoIter = std::slice::Iter<'a, OhlcvBar>;
fn into_iter(self) -> Self::IntoIter {
self.bars.iter()
}
}
fn decimal_sqrt(n: Decimal) -> Result<Decimal, FinError> {
if n.is_zero() {
return Ok(Decimal::ZERO);
}
if n.is_sign_negative() {
return Err(FinError::ArithmeticOverflow);
}
let mut x = n;
for _ in 0..20 {
let next = (x + n / x) / Decimal::TWO;
let diff = if next > x { next - x } else { x - next };
x = next;
if diff < Decimal::new(1, 10) {
break;
}
}
Ok(x)
}
impl OhlcvSeries {
pub fn max_drawdown_duration(&self) -> usize {
if self.bars.len() < 2 {
return 0;
}
let mut max_run = 0usize;
let mut current = 0usize;
for i in 1..self.bars.len() {
if self.bars[i].close.value() < self.bars[i - 1].close.value() {
current += 1;
if current > max_run {
max_run = current;
}
} else {
current = 0;
}
}
max_run
}
pub fn close_above_open_pct(&self, n: usize) -> Option<f64> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let count = self.bars[start..]
.iter()
.filter(|b| b.is_bullish())
.count();
Some(count as f64 / n as f64 * 100.0)
}
pub fn avg_wick_ratio(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let mut sum = 0.0f64;
let mut count = 0usize;
for b in &self.bars[start..] {
let range = b.range();
if !range.is_zero() {
let wick = b.upper_shadow() + b.lower_shadow();
if let Some(ratio) = (wick / range).to_f64() {
sum += ratio;
count += 1;
}
}
}
if count == 0 {
return None;
}
Some(sum / count as f64)
}
pub fn gain_loss_ratio(&self, n: usize) -> Option<f64> {
if n == 0 || self.bars.len() < n + 1 {
return None;
}
use rust_decimal::prelude::ToPrimitive;
let start = self.bars.len() - n - 1;
let slice = &self.bars[start..];
let mut gains = 0.0f64;
let mut losses = 0.0f64;
let mut gain_count = 0usize;
let mut loss_count = 0usize;
for w in slice.windows(2) {
let pc = w[0].close.value().to_f64()?;
let cc = w[1].close.value().to_f64()?;
if pc <= 0.0 { continue; }
let r = (cc / pc).ln();
if r > 0.0 {
gains += r;
gain_count += 1;
} else if r < 0.0 {
losses += r.abs();
loss_count += 1;
}
}
if loss_count == 0 || losses == 0.0 {
return None;
}
let avg_gain = gains / gain_count.max(1) as f64;
let avg_loss = losses / loss_count as f64;
Some(avg_gain / avg_loss)
}
pub fn bars_above_sma(&self, n: usize, sma_period: usize) -> Option<usize> {
if n == 0 || sma_period == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let mut count = 0usize;
for i in start..self.bars.len() {
if i + 1 < sma_period {
continue;
}
let sma_start = i + 1 - sma_period;
let sum: Decimal = self.bars[sma_start..=i]
.iter()
.map(|b| b.close.value())
.sum();
let sma = sum / Decimal::from(sma_period as u32);
if self.bars[i].close.value() > sma {
count += 1;
}
}
Some(count)
}
pub fn close_distance_from_low(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let min_low = self.bars[start..]
.iter()
.map(|b| b.low.value())
.reduce(Decimal::min)?;
let last_close = self.bars.last()?.close.value();
Some(last_close - min_low)
}
pub fn volume_ratio(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let sum: Decimal = self.bars[start..].iter().map(|b| b.volume.value()).sum();
let avg = sum.checked_div(Decimal::from(n as u32))?;
if avg.is_zero() {
return None;
}
let last_vol = self.bars.last()?.volume.value();
last_vol.checked_div(avg)
}
pub fn momentum_quality(&self, n: usize) -> Option<f64> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let avg_vol: Decimal = {
let s: Decimal = slice.iter().map(|b| b.volume.value()).sum();
s.checked_div(Decimal::from(n as u32))?
};
let mut high_vol_bars = 0usize;
let mut high_vol_up = 0usize;
for b in slice {
if b.volume.value() > avg_vol {
high_vol_bars += 1;
if b.close > b.open {
high_vol_up += 1;
}
}
}
if high_vol_bars == 0 {
return None;
}
Some(high_vol_up as f64 / high_vol_bars as f64)
}
pub fn bullish_candle_pct(&self, n: usize) -> Option<f64> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let bullish = self.bars[start..].iter().filter(|b| b.close > b.open).count();
Some(bullish as f64 / n as f64)
}
pub fn price_above_ma_pct(&self, n: usize, period: usize) -> Option<f64> {
if n == 0 || period == 0 || self.bars.len() < n + period - 1 {
return None;
}
let total = self.bars.len();
let mut above = 0usize;
for i in (total - n)..total {
let sma_start = i + 1 - period;
let sma: Decimal = self.bars[sma_start..=i]
.iter()
.map(|b| b.close.value())
.sum::<Decimal>()
/ Decimal::from(period as u32);
if self.bars[i].close.value() > sma {
above += 1;
}
}
Some(above as f64 / n as f64)
}
pub fn true_range_series(&self, n: usize) -> Option<Vec<Decimal>> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let trs: Vec<Decimal> = self.bars[start..]
.iter()
.enumerate()
.map(|(i, bar)| {
let abs_i = start + i;
if abs_i == 0 {
bar.range()
} else {
let prev_close = self.bars[abs_i - 1].close.value();
let high = bar.high.value().max(prev_close);
let low = bar.low.value().min(prev_close);
high - low
}
})
.collect();
Some(trs)
}
pub fn intraday_return_pct(&self) -> Option<Decimal> {
if self.bars.is_empty() {
return None;
}
let first_open = self.bars.first()?.open.value();
if first_open.is_zero() {
return None;
}
let last_close = self.bars.last()?.close.value();
Some((last_close - first_open) / first_open * Decimal::ONE_HUNDRED)
}
pub fn bearish_bar_count(&self, n: usize) -> Option<usize> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
Some(self.bars[start..].iter().filter(|b| b.close < b.open).count())
}
pub fn avg_body_size(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let sum: Decimal = self.bars[start..]
.iter()
.map(|b| b.body_size())
.sum();
Some(sum / Decimal::from(n as u32))
}
pub fn hl_midpoint(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let sum: Decimal = self.bars[start..]
.iter()
.map(|b| b.midpoint())
.sum();
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(n as u32))
}
pub fn up_volume_ratio(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let total_vol: Decimal = self.bars[start..].iter().map(|b| b.volume.value()).sum();
if total_vol.is_zero() {
return None;
}
let up_vol: Decimal = self.bars[start..]
.iter()
.filter(|b| b.close > b.open)
.map(|b| b.volume.value())
.sum();
up_vol.checked_div(total_vol)
}
pub fn price_efficiency(&self, n: usize) -> Option<Decimal> {
if n < 2 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let net = (self.bars.last()?.close.value() - self.bars[start].close.value()).abs();
let path: Decimal = self.bars[start..]
.windows(2)
.map(|w| (w[1].close.value() - w[0].close.value()).abs())
.sum();
if path.is_zero() {
return None;
}
net.checked_div(path)
}
pub fn avg_gap(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 {
return None;
}
let start = self.bars.len() - n;
let sum: Decimal = (start..self.bars.len())
.map(|i| (self.bars[i].open.value() - self.bars[i - 1].close.value()).abs())
.sum();
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(n as u32))
}
pub fn realized_variance(&self, n: usize) -> Option<f64> {
if n < 2 || self.bars.len() < n + 1 {
return None;
}
let start = self.bars.len() - (n + 1);
let mut rets = Vec::with_capacity(n);
for i in (start + 1)..=(start + n) {
let prev = self.bars[i - 1].close.value();
let curr = self.bars[i].close.value();
use rust_decimal::prelude::ToPrimitive;
let r = prev.to_f64()?;
let c = curr.to_f64()?;
if r <= 0.0 { return None; }
rets.push((c / r).ln());
}
let mean = rets.iter().sum::<f64>() / rets.len() as f64;
let var = rets.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / rets.len() as f64;
Some(var)
}
pub fn close_velocity(&self, n: usize) -> Option<Decimal> {
if n < 2 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let delta = self.bars.last()?.close.value() - self.bars[start].close.value();
#[allow(clippy::cast_possible_truncation)]
delta.checked_div(Decimal::from(n as u32))
}
pub fn avg_upper_wick(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let sum: Decimal = self.bars[start..]
.iter()
.map(|b| b.upper_shadow())
.sum();
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(n as u32))
}
pub fn median_price(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let mut mids: Vec<Decimal> = self.bars[start..]
.iter()
.map(|b| b.midpoint())
.collect();
mids.sort();
let mid = n / 2;
if n % 2 == 0 {
Some((mids[mid - 1] + mids[mid]) / Decimal::TWO)
} else {
Some(mids[mid])
}
}
pub fn upper_shadow_ratio(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let sum: Decimal = self.bars[start..]
.iter()
.map(|b| {
let range = b.range();
if range.is_zero() {
Decimal::ZERO
} else {
(b.upper_shadow()) / range
}
})
.sum();
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(n as u32))
}
pub fn percent_gap_up_bars(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 {
return None;
}
let start = self.bars.len() - n;
let count = (start..self.bars.len())
.filter(|&i| self.bars[i].open > self.bars[i - 1].close)
.count();
#[allow(clippy::cast_possible_truncation)]
Decimal::from(count as u32).checked_div(Decimal::from(n as u32))
}
pub fn consecutive_higher_closes(&self, n: usize) -> Option<usize> {
if n < 2 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let mut max_run = 0usize;
let mut cur_run = 0usize;
for i in (start + 1)..self.bars.len() {
if self.bars[i].close > self.bars[i - 1].close {
cur_run += 1;
if cur_run > max_run { max_run = cur_run; }
} else {
cur_run = 0;
}
}
Some(max_run)
}
pub fn volume_weighted_return(&self, n: usize) -> Option<Decimal> {
if n < 2 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let mut vol_return_sum = Decimal::ZERO;
let mut vol_sum = Decimal::ZERO;
for i in (start + 1)..self.bars.len() {
let prev_close = self.bars[i - 1].close.value();
if prev_close.is_zero() { continue; }
let ret = (self.bars[i].close.value() - prev_close) / prev_close;
let vol = self.bars[i].volume.value();
vol_return_sum += ret * vol;
vol_sum += vol;
}
if vol_sum.is_zero() {
return None;
}
Some(vol_return_sum / vol_sum)
}
pub fn close_returns(&self, n: usize) -> Option<Vec<Decimal>> {
if n < 2 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let mut returns = Vec::with_capacity(n - 1);
for i in (start + 1)..self.bars.len() {
let prev = self.bars[i - 1].close.value();
if prev.is_zero() {
returns.push(Decimal::ZERO);
} else {
returns.push((self.bars[i].close.value() - prev) / prev);
}
}
Some(returns)
}
pub fn volatility_regime(&self, atr_period: usize, lookback: usize) -> Option<&'static str> {
if atr_period == 0 || lookback == 0 {
return None;
}
let needed = lookback + atr_period;
if self.bars.len() < needed {
return None;
}
let atr_series = self.atr_series(atr_period);
let recent_atrs: Vec<Decimal> = atr_series
.iter()
.rev()
.take(lookback)
.filter_map(|v| *v)
.collect();
if recent_atrs.is_empty() {
return None;
}
let mean: Decimal = recent_atrs.iter().copied().sum::<Decimal>()
/ Decimal::from(recent_atrs.len() as u32);
if mean.is_zero() {
return Some("medium");
}
let latest = *recent_atrs.first()?;
let ratio = latest / mean;
if ratio < Decimal::new(80, 2) {
Some("low")
} else if ratio > Decimal::new(120, 2) {
Some("high")
} else {
Some("medium")
}
}
pub fn up_down_volume_ratio(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let mut up_vol = Decimal::ZERO;
let mut dn_vol = Decimal::ZERO;
for b in &self.bars[start..] {
let vol = b.volume.value();
if b.close > b.open { up_vol += vol; }
else if b.close < b.open { dn_vol += vol; }
}
if dn_vol.is_zero() { return None; }
Some(up_vol / dn_vol)
}
pub fn avg_range_pct(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
let mut count = 0usize;
let hundred = Decimal::from(100u32);
for b in &self.bars[start..] {
let tp = b.typical_price();
if tp.is_zero() { continue; }
sum += (b.range()) / tp * hundred;
count += 1;
}
if count == 0 { return None; }
Some(sum / Decimal::from(count as u32))
}
pub fn bar_efficiency(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 2 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let net = (self.bars.last().unwrap().close.value()
- self.bars[start].close.value())
.abs()
.to_f64()
.unwrap_or(0.0);
let path: f64 = (start + 1..self.bars.len())
.map(|i| {
(self.bars[i].close.value() - self.bars[i - 1].close.value())
.abs()
.to_f64()
.unwrap_or(0.0)
})
.sum();
if path == 0.0 { return None; }
Some(net / path)
}
pub fn avg_bars_between_highs(&self, n: usize, m: usize) -> Option<f64> {
if n == 0 || m <= n || self.bars.len() < m {
return None;
}
let start = self.bars.len() - m;
let mut high_indices: Vec<usize> = Vec::new();
for i in (start + n)..self.bars.len() {
let prev_max = self.bars[(i - n)..i]
.iter()
.map(|b| b.close.value())
.max()
.unwrap_or(Decimal::ZERO);
if self.bars[i].close.value() > prev_max {
high_indices.push(i);
}
}
if high_indices.len() < 2 { return None; }
let gaps: Vec<usize> = high_indices.windows(2).map(|w| w[1] - w[0]).collect();
Some(gaps.iter().sum::<usize>() as f64 / gaps.len() as f64)
}
pub fn breakout_bars(&self, n: usize) -> Option<usize> {
if n == 0 || self.bars.len() <= n {
return None;
}
let mut streak = 0usize;
for i in (n..self.bars.len()).rev() {
let prior_max = self.bars[(i - n)..i]
.iter()
.map(|b| b.close.value())
.max()
.unwrap_or(Decimal::ZERO);
if self.bars[i].close.value() > prior_max {
streak += 1;
} else {
break;
}
}
Some(streak)
}
pub fn doji_count(&self, n: usize, threshold: f64) -> Option<usize> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
use rust_decimal::prelude::ToPrimitive;
let count = self.bars[start..]
.iter()
.filter(|b| {
let range = (b.range()).to_f64().unwrap_or(0.0);
if range == 0.0 {
return true; }
let body = (b.close.value() - b.open.value())
.abs()
.to_f64()
.unwrap_or(0.0);
body / range < threshold
})
.count();
Some(count)
}
pub fn close_dispersion(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 2 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let vals: Vec<f64> = self.bars[start..]
.iter()
.map(|b| b.close.value().to_f64().unwrap_or(0.0))
.collect();
let mean = vals.iter().sum::<f64>() / n as f64;
if mean == 0.0 { return None; }
let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64;
Some(variance.sqrt() / mean)
}
pub fn relative_volume(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let avg_vol: Decimal = self.bars[start..]
.iter()
.map(|b| b.volume.value())
.sum::<Decimal>()
/ Decimal::from(n as u32);
if avg_vol.is_zero() { return None; }
let last_vol = self.bars.last()?.volume.value();
Some(last_vol / avg_vol * Decimal::from(100u32))
}
pub fn avg_oc_midpoint(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let sum: Decimal = self.bars[start..]
.iter()
.map(|b| (b.open.value() + b.close.value()) / Decimal::TWO)
.sum();
Some(sum / Decimal::from(n as u32))
}
pub fn volume_spike_count(&self, n: usize, threshold: Decimal) -> Option<usize> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let avg_vol: Decimal = self.bars[start..]
.iter()
.map(|b| b.volume.value())
.sum::<Decimal>()
/ Decimal::from(n as u32);
if avg_vol.is_zero() { return None; }
let limit = avg_vol * threshold;
let count = self.bars[start..].iter().filter(|b| b.volume.value() > limit).count();
Some(count)
}
pub fn close_acceleration(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 2 {
return None;
}
let total = self.bars.len();
let last_mom = self.bars[total - 1].close.value() - self.bars[total - 2].close.value();
let first_idx = total - n - 1;
let first_mom = self.bars[first_idx + 1].close.value() - self.bars[first_idx].close.value();
Some(last_mom - first_mom)
}
pub fn up_down_ratio(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let ups = self.bars[start..].iter().filter(|b| b.is_bullish()).count();
let downs = self.bars[start..].iter().filter(|b| b.is_bearish()).count();
if downs == 0 {
return None;
}
Some(Decimal::from(ups as u32) / Decimal::from(downs as u32))
}
pub fn consecutive_up_bars(&self, n: usize) -> Option<usize> {
if n == 0 || self.bars.is_empty() {
return None;
}
let window_start = self.bars.len().saturating_sub(n);
let count = self.bars[window_start..]
.iter()
.rev()
.take_while(|b| b.is_bullish())
.count();
Some(count)
}
pub fn normalized_close(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 2 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let vals: Vec<f64> = self.bars[start..]
.iter()
.map(|b| b.close.value().to_f64().unwrap_or(0.0))
.collect();
let mean = vals.iter().sum::<f64>() / n as f64;
let std = (vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64).sqrt();
if std == 0.0 { return None; }
let last = *vals.last()?;
Some((last - mean) / std)
}
pub fn gap_counts(&self, n: usize) -> Option<(usize, usize)> {
if n < 2 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let mut ups = 0usize;
let mut downs = 0usize;
for i in (start + 1)..self.bars.len() {
let prior_close = self.bars[i - 1].close.value();
let cur_open = self.bars[i].open.value();
if cur_open > prior_close { ups += 1; }
else if cur_open < prior_close { downs += 1; }
}
Some((ups, downs))
}
pub fn consecutive_volume_surge(&self, period: usize, factor: f64) -> Option<usize> {
use rust_decimal::prelude::ToPrimitive;
if period == 0 || factor <= 0.0 || self.bars.len() <= period {
return None;
}
let mut streak = 0usize;
let last = self.bars.len() - 1;
let mut i = last;
loop {
if i < period {
break;
}
let avg_vol: f64 = self.bars[(i - period)..i]
.iter()
.map(|b| b.volume.value().to_f64().unwrap_or(0.0))
.sum::<f64>()
/ period as f64;
let bar_vol = self.bars[i].volume.value().to_f64().unwrap_or(0.0);
if avg_vol > 0.0 && bar_vol >= avg_vol * factor {
streak += 1;
} else {
break;
}
if i == 0 { break; }
i -= 1;
}
Some(streak)
}
pub fn intrabar_range_expansion(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let avg_range: f64 = self.bars[start..]
.iter()
.map(|b| (b.range()).to_f64().unwrap_or(0.0))
.sum::<f64>()
/ n as f64;
if avg_range == 0.0 {
return None;
}
let current = self.bars.last()?;
let cur_range = (current.high.value() - current.low.value())
.to_f64()
.unwrap_or(0.0);
Some(cur_range / avg_range)
}
pub fn price_range_ratio(&self, n: usize) -> Option<Decimal> {
if n < 2 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let closes: Vec<Decimal> = self.bars[start..]
.iter()
.map(|b| b.close.value())
.collect();
let hi = closes.iter().copied().max()?;
let lo = closes.iter().copied().min()?;
let avg = closes.iter().sum::<Decimal>() / Decimal::from(n as u32);
if avg.is_zero() { return None; }
Some((hi - lo) / avg)
}
pub fn close_volume_correlation(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 2 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let closes: Vec<f64> = self.bars[start..].iter()
.map(|b| b.close.value().to_f64().unwrap_or(0.0))
.collect();
let vols: Vec<f64> = self.bars[start..].iter()
.map(|b| b.volume.value().to_f64().unwrap_or(0.0))
.collect();
let n_f = n as f64;
let mean_c = closes.iter().sum::<f64>() / n_f;
let mean_v = vols.iter().sum::<f64>() / n_f;
let cov: f64 = closes.iter().zip(vols.iter())
.map(|(c, v)| (c - mean_c) * (v - mean_v))
.sum::<f64>() / n_f;
let std_c = (closes.iter().map(|c| (c - mean_c).powi(2)).sum::<f64>() / n_f).sqrt();
let std_v = (vols.iter().map(|v| (v - mean_v).powi(2)).sum::<f64>() / n_f).sqrt();
if std_c == 0.0 || std_v == 0.0 { return None; }
Some(cov / (std_c * std_v))
}
pub fn close_relative_to_range(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let high = slice.iter().map(|b| b.high.value()).fold(Decimal::MIN, Decimal::max);
let low = slice.iter().map(|b| b.low.value()).fold(Decimal::MAX, Decimal::min);
let range = high - low;
if range.is_zero() {
return None;
}
let close = self.bars.last()?.close.value();
Some((close - low) / range)
}
pub fn volume_sma(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
#[allow(clippy::cast_possible_truncation)]
let avg = self.bars[start..].iter().map(|b| b.volume.value()).sum::<Decimal>()
/ Decimal::from(n as u32);
Some(avg)
}
pub fn compression_ratio(&self, fast: usize, slow: usize) -> Option<Decimal> {
if fast == 0 || slow == 0 || fast >= slow || self.bars.len() < slow + 1 {
return None;
}
let atr_avg = |n: usize| -> Option<Decimal> {
let start = self.bars.len() - n;
let trs: Decimal = self.bars[start..].iter().enumerate().map(|(i, b)| {
let prev = if i == 0 { &self.bars[start - 1] } else { &self.bars[start + i - 1] };
let hl = b.range();
let hpc = (b.high.value() - prev.close.value()).abs();
let lpc = (b.low.value() - prev.close.value()).abs();
hl.max(hpc).max(lpc)
}).sum();
#[allow(clippy::cast_possible_truncation)]
Some(trs / Decimal::from(n as u32))
};
let atr_fast = atr_avg(fast)?;
let atr_slow = atr_avg(slow)?;
if atr_slow.is_zero() { return None; }
atr_fast.checked_div(atr_slow)
}
pub fn typical_price_avg(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
#[allow(clippy::cast_possible_truncation)]
let avg = self.bars[start..]
.iter()
.map(|b| b.typical_price())
.sum::<Decimal>()
/ Decimal::from(n as u32);
Some(avg)
}
pub fn avg_body_to_range(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
let mut count = 0u32;
for b in &self.bars[start..] {
let range = b.range();
if range.is_zero() { continue; }
let body = b.body_size();
sum += body
.checked_div(range)
.unwrap_or(Decimal::ZERO);
count += 1;
}
if count == 0 { return None; }
Some(sum / Decimal::from(count))
}
pub fn avg_tick_count(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let sum: u64 = self.bars[start..].iter().map(|b| b.tick_count).sum();
Some(Decimal::from(sum) / Decimal::from(n as u32))
}
pub fn range_compression(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let max_range = self.bars[start..]
.iter()
.map(|b| b.range())
.max()?;
if max_range.is_zero() {
return None;
}
let last = self.bars.last()?;
let last_range = last.range();
last_range.checked_div(max_range)
}
pub fn largest_gap_pct(&self, n: usize) -> Option<Decimal> {
if n < 2 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let mut max_gap = Decimal::ZERO;
for i in start + 1..self.bars.len() {
let prev_close = self.bars[i - 1].close.value();
if prev_close.is_zero() { return None; }
let gap = (self.bars[i].open.value() - prev_close).abs()
/ prev_close
* Decimal::from(100u32);
if gap > max_gap { max_gap = gap; }
}
Some(max_gap)
}
pub fn close_sma_crossover(&self, n: usize) -> Option<i8> {
if n == 0 || self.bars.len() < n + 1 {
return None;
}
let total = self.bars.len();
#[allow(clippy::cast_possible_truncation)]
let sma_now: Decimal = self.bars[total - n..]
.iter()
.map(|b| b.close.value())
.sum::<Decimal>() / Decimal::from(n as u32);
let sma_prev: Decimal = self.bars[total - n - 1..total - 1]
.iter()
.map(|b| b.close.value())
.sum::<Decimal>() / Decimal::from(n as u32);
let close_now = self.bars[total - 1].close.value();
let close_prev = self.bars[total - 2].close.value();
if close_prev <= sma_prev && close_now > sma_now {
Some(1)
} else if close_prev >= sma_prev && close_now < sma_now {
Some(-1)
} else {
Some(0)
}
}
pub fn max_volume_bar_idx(&self, n: usize) -> Option<usize> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
self.bars[start..]
.iter()
.enumerate()
.max_by(|a, b| a.1.volume.value().cmp(&b.1.volume.value()))
.map(|(i, _)| i)
}
pub fn range_pct_of_atr(&self, n: usize) -> Option<Decimal> {
let atr = self.avg_true_range(n)?;
if atr.is_zero() { return None; }
let last = self.bars.last()?;
let range = last.range();
range.checked_div(atr).map(|r| r * Decimal::ONE_HUNDRED)
}
pub fn max_close_drawdown(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let mut peak = self.bars[start].close.value();
let mut max_dd = Decimal::ZERO;
for b in &self.bars[start..] {
let c = b.close.value();
if c > peak { peak = c; }
if !peak.is_zero() {
let dd = (c - peak) / peak * Decimal::ONE_HUNDRED;
if dd < max_dd { max_dd = dd; }
}
}
Some(max_dd)
}
pub fn close_above_sma_pct(&self, n: usize, sma_period: usize) -> Option<Decimal> {
if n == 0 || sma_period == 0 || self.bars.len() < n + sma_period - 1 {
return None;
}
let window_start = self.bars.len() - n;
let mut above = 0u32;
for (offset, b) in self.bars[window_start..].iter().enumerate() {
let abs_idx = window_start + offset;
if abs_idx + 1 < sma_period { continue; }
let sma_start = abs_idx + 1 - sma_period;
let sma = self.bars[sma_start..=abs_idx]
.iter()
.map(|x| x.close.value())
.sum::<Decimal>()
/ Decimal::from(sma_period as u32);
if b.close.value() > sma { above += 1; }
}
Some(Decimal::from(above) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
}
pub fn swing_high_count(&self, n: usize, lookback: usize) -> Option<usize> {
if n == 0 || lookback == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let len = slice.len();
let mut count = 0usize;
for i in lookback..len.saturating_sub(lookback) {
let peak = slice[i].high.value();
let is_high = (0..lookback).all(|k| peak > slice[i - 1 - k].high.value())
&& (0..lookback).all(|k| peak > slice[i + 1 + k].high.value());
if is_high { count += 1; }
}
Some(count)
}
pub fn open_gap_pct(&self, n: usize) -> Option<Decimal> {
if n < 2 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
for i in start..self.bars.len() {
let prev_close = self.bars[i - 1].close.value();
if prev_close.is_zero() { return None; }
let gap = (self.bars[i].open.value() - prev_close).abs();
sum += gap / prev_close * Decimal::ONE_HUNDRED;
}
Some(sum / Decimal::from((n - 1) as u32))
}
pub fn volume_trend_ratio(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n {
return None;
}
let start = self.bars.len() - n;
let mut up_sum = Decimal::ZERO;
let mut up_count = 0u32;
let mut down_sum = Decimal::ZERO;
let mut down_count = 0u32;
for b in &self.bars[start..] {
let v = b.volume.value();
if b.is_bullish() {
up_sum += v;
up_count += 1;
} else if b.is_bearish() {
down_sum += v;
down_count += 1;
}
}
if up_count == 0 || down_count == 0 { return None; }
let avg_up = up_sum / Decimal::from(up_count);
let avg_down = down_sum / Decimal::from(down_count);
if avg_down.is_zero() { return None; }
avg_up.checked_div(avg_down)
}
pub fn avg_wick_pct(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
for b in &self.bars[start..] {
let range = b.range();
if range.is_zero() { return None; }
let upper_wick = b.high.value() - b.close.value().max(b.open.value());
let lower_wick = b.close.value().min(b.open.value()) - b.low.value();
sum += (upper_wick + lower_wick) / range * Decimal::from(100u32);
}
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(n as u32))
}
pub fn trend_continuation_pct(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let start = self.bars.len() - n - 1;
let mut continuing = 0u32;
for i in 0..n {
let prev_dir = self.bars[start + i].close.value()
.cmp(&self.bars[start + i].open.value());
let curr_dir = self.bars[start + i + 1].close.value()
.cmp(&self.bars[start + i + 1].open.value());
if prev_dir == curr_dir && prev_dir != std::cmp::Ordering::Equal {
continuing += 1;
}
}
#[allow(clippy::cast_possible_truncation)]
Some(Decimal::from(continuing) / Decimal::from(n as u32) * Decimal::from(100u32))
}
pub fn inside_bar_count(&self, n: usize) -> Option<usize> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut count = 0usize;
for i in start..self.bars.len() {
if i == 0 { continue; }
let prev = &self.bars[i - 1];
let cur = &self.bars[i];
if cur.high <= prev.high && cur.low >= prev.low { count += 1; }
}
Some(count)
}
pub fn outside_bar_count(&self, n: usize) -> Option<usize> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut count = 0usize;
for i in start..self.bars.len() {
if i == 0 { continue; }
let prev = &self.bars[i - 1];
let cur = &self.bars[i];
if cur.high > prev.high && cur.low < prev.low { count += 1; }
}
Some(count)
}
pub fn high_volume_price(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
self.bars[start..].iter()
.max_by_key(|b| b.volume.value())
.map(|b| b.close.value())
}
pub fn avg_close_minus_open(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let sum: Decimal = self.bars[start..]
.iter()
.map(|b| b.close.value() - b.open.value())
.sum();
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(n as u32))
}
pub fn avg_upper_shadow_pct(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let sum: Decimal = self.bars[start..].iter().map(|b| {
let body_top = b.open.value().max(b.close.value());
let shadow = b.high.value() - body_top;
let close = b.close.value();
if close.is_zero() { Decimal::ZERO } else { shadow / close * Decimal::from(100u32) }
}).sum();
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(n as u32))
}
pub fn avg_lower_shadow_pct(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let sum: Decimal = self.bars[start..].iter().map(|b| {
let body_bottom = b.open.value().min(b.close.value());
let shadow = body_bottom - b.low.value();
let close = b.close.value();
if close.is_zero() { Decimal::ZERO } else { shadow / close * Decimal::from(100u32) }
}).sum();
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(n as u32))
}
pub fn percent_doji(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let threshold = rust_decimal_macros::dec!(0.1);
let mut doji_count = 0u32;
for b in &self.bars[start..] {
let range = b.range();
let body = b.body_size();
if range.is_zero() || body / range < threshold {
doji_count += 1;
}
}
#[allow(clippy::cast_possible_truncation)]
Some(Decimal::from(doji_count) / Decimal::from(n as u32) * Decimal::from(100u32))
}
pub fn avg_close_range_pct(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
for b in &self.bars[start..] {
let range = b.range();
if range.is_zero() { return None; }
sum += (b.close.value() - b.low.value()) / range * Decimal::from(100u32);
}
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(n as u32))
}
pub fn price_channel_width(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let max_high = slice.iter().map(|b| b.high.value()).max()?;
let min_low = slice.iter().map(|b| b.low.value()).min()?;
if min_low.is_zero() { return None; }
Some((max_high - min_low) / min_low * Decimal::ONE_HUNDRED)
}
pub fn avg_candle_efficiency(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
for b in &self.bars[start..] {
let range = b.range();
if range.is_zero() { return None; }
sum += b.body_size() / range;
}
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(n as u32))
}
pub fn volume_at_high(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let mut running_high = slice[0].high.value();
let mut total = slice[0].volume.value();
for b in &slice[1..] {
if b.high.value() > running_high {
running_high = b.high.value();
total += b.volume.value();
}
}
Some(total)
}
pub fn close_momentum_consistency(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let start = self.bars.len() - n - 1;
let mut up = 0u32;
for i in 0..n {
if self.bars[start + i + 1].close > self.bars[start + i].close {
up += 1;
}
}
#[allow(clippy::cast_possible_truncation)]
Some(Decimal::from(up) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
}
pub fn price_gap_pct(&self) -> Option<Decimal> {
let n = self.bars.len();
if n < 2 { return None; }
let prev_close = self.bars[n - 2].close.value();
if prev_close.is_zero() { return None; }
Some((self.bars[n - 1].open.value() - prev_close) / prev_close * Decimal::ONE_HUNDRED)
}
pub fn longest_winning_streak(&self) -> usize {
if self.bars.len() < 2 { return 0; }
let mut max_streak = 0usize;
let mut streak = 0usize;
for i in 1..self.bars.len() {
if self.bars[i].close > self.bars[i - 1].close {
streak += 1;
if streak > max_streak { max_streak = streak; }
} else {
streak = 0;
}
}
max_streak
}
pub fn avg_gap_pct(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
for i in start..self.bars.len() {
let prev_close = self.bars[i - 1].close.value();
if prev_close.is_zero() { continue; }
sum += (self.bars[i].open.value() - prev_close).abs() / prev_close * Decimal::ONE_HUNDRED;
}
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(n as u32))
}
pub fn intrabar_momentum(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
let mut count = 0u32;
for bar in &self.bars[start..] {
let range = bar.range();
if range.is_zero() { continue; }
sum += (bar.close.value() - bar.open.value()) / range;
count += 1;
}
if count == 0 { return None; }
Some(sum / Decimal::from(count))
}
pub fn volume_per_bar(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let sum: Decimal = self.bars[start..].iter().map(|b| b.volume.value()).sum();
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(n as u32))
}
pub fn pct_bars_near_high(&self, n: usize, threshold_pct: Decimal) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut near = 0u32;
for bar in &self.bars[start..] {
let high = bar.high.value();
if high.is_zero() { continue; }
let dist_pct = (high - bar.close.value()) / high * Decimal::ONE_HUNDRED;
if dist_pct <= threshold_pct {
near += 1;
}
}
#[allow(clippy::cast_possible_truncation)]
Some(Decimal::from(near) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
}
pub fn avg_body_pct(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
let mut count = 0u32;
for bar in &self.bars[start..] {
let range = bar.range();
if range.is_zero() { continue; }
sum += bar.body_size() / range * Decimal::ONE_HUNDRED;
count += 1;
}
if count == 0 { return None; }
Some(sum / Decimal::from(count))
}
pub fn tail_ratio(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
let mut count = 0u32;
for bar in &self.bars[start..] {
let body_top = bar.open.value().max(bar.close.value());
let body_bot = bar.open.value().min(bar.close.value());
let upper = bar.high.value() - body_top;
let lower = body_bot - bar.low.value();
if lower.is_zero() { continue; }
sum += upper / lower;
count += 1;
}
if count == 0 { return None; }
Some(sum / Decimal::from(count))
}
pub fn avg_volume_ratio(&self, n: usize, m: usize) -> Option<Decimal> {
let len = self.bars.len();
if n == 0 || m == 0 || len < n.max(m) { return None; }
#[allow(clippy::cast_possible_truncation)]
let avg_n: Decimal = self.bars[len - n..].iter().map(|b| b.volume.value()).sum::<Decimal>()
/ Decimal::from(n as u32);
#[allow(clippy::cast_possible_truncation)]
let avg_m: Decimal = self.bars[len - m..].iter().map(|b| b.volume.value()).sum::<Decimal>()
/ Decimal::from(m as u32);
if avg_m.is_zero() { return None; }
Some(avg_n / avg_m)
}
pub fn open_close_correlation(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 2 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let opens: Vec<f64> = self.bars[start..].iter().filter_map(|b| b.open.value().to_f64()).collect();
let closes: Vec<f64> = self.bars[start..].iter().filter_map(|b| b.close.value().to_f64()).collect();
if opens.len() < 2 { return None; }
let nf = opens.len() as f64;
let mean_o = opens.iter().sum::<f64>() / nf;
let mean_c = closes.iter().sum::<f64>() / nf;
let cov: f64 = opens.iter().zip(closes.iter()).map(|(o, c)| (o - mean_o) * (c - mean_c)).sum::<f64>() / nf;
let std_o = (opens.iter().map(|o| (o - mean_o).powi(2)).sum::<f64>() / nf).sqrt();
let std_c = (closes.iter().map(|c| (c - mean_c).powi(2)).sum::<f64>() / nf).sqrt();
if std_o == 0.0 || std_c == 0.0 { return None; }
Some(cov / (std_o * std_c))
}
pub fn price_acceleration(&self, n: usize) -> Option<Decimal> {
if n < 4 || self.bars.len() < n + 1 { return None; }
let start = self.bars.len() - n - 1;
let half = n / 2;
let changes: Vec<Decimal> = (start..self.bars.len() - 1)
.map(|i| self.bars[i + 1].close.value() - self.bars[i].close.value())
.collect();
#[allow(clippy::cast_possible_truncation)]
let avg_first = changes[..half].iter().sum::<Decimal>() / Decimal::from(half as u32);
#[allow(clippy::cast_possible_truncation)]
let avg_second = changes[half..].iter().sum::<Decimal>() / Decimal::from((changes.len() - half) as u32);
Some(avg_second - avg_first)
}
pub fn returns_skewness(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n == 0 || self.bars.len() < n + 1 { return None; }
let start = self.bars.len() - n - 1;
let returns: Vec<f64> = (start..self.bars.len() - 1)
.filter_map(|i| {
let prev = self.bars[i].close.value().to_f64()?;
let curr = self.bars[i + 1].close.value().to_f64()?;
if prev == 0.0 { return None; }
Some((curr / prev).ln())
})
.collect();
if returns.len() < 3 { return None; }
let m = returns.len() as f64;
let mean = returns.iter().sum::<f64>() / m;
let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / m;
let std = variance.sqrt();
if std == 0.0 { return None; }
Some(returns.iter().map(|r| ((r - mean) / std).powi(3)).sum::<f64>() / m)
}
pub fn volume_zscore(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 2 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let vols: Vec<f64> = self.bars[start..].iter()
.filter_map(|b| b.volume.value().to_f64())
.collect();
if vols.len() < 2 { return None; }
let m = vols.len() as f64;
let mean = vols.iter().sum::<f64>() / m;
let variance = vols.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (m - 1.0);
let std = variance.sqrt();
if std == 0.0 { return None; }
let last_vol = self.bars.last()?.volume.value().to_f64()?;
Some((last_vol - mean) / std)
}
pub fn upper_lower_shadow_ratio(&self) -> Option<Decimal> {
let bar = self.bars.last()?;
let body_top = bar.open.value().max(bar.close.value());
let body_bot = bar.open.value().min(bar.close.value());
let upper = bar.high.value() - body_top;
let lower = body_bot - bar.low.value();
if lower.is_zero() { return None; }
Some(upper / lower)
}
fn sma(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let sum: Decimal = self.bars[start..].iter().map(|b| b.close.value()).sum();
Some(sum / Decimal::from(n as u32))
}
fn atr(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let start = self.bars.len() - n - 1;
let mut sum = Decimal::ZERO;
for i in start..self.bars.len() - 1 {
let pc = self.bars[i].close.value();
let h = self.bars[i + 1].high.value();
let l = self.bars[i + 1].low.value();
let tr = (h - l).max((h - pc).abs()).max((l - pc).abs());
sum += tr;
}
Some(sum / Decimal::from(n as u32))
}
fn ema(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let seed: Decimal = self.bars[start..start + n.min(self.bars.len() - start)]
.iter().map(|b| b.close.value()).sum::<Decimal>()
/ Decimal::from(n as u32);
let k = Decimal::from(2u32) / Decimal::from((n + 1) as u32);
let mut e = seed;
for bar in &self.bars[start + n..] {
e = e * (Decimal::ONE - k) + bar.close.value() * k;
}
Some(e)
}
pub fn mean_reversion_score(&self, n: usize) -> Option<Decimal> {
let close = self.bars.last()?.close.value();
let sma = self.sma(n)?;
let atr = self.atr(n)?;
if atr.is_zero() { return None; }
Some((close - sma).abs() / atr)
}
pub fn volume_price_trend(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let start = self.bars.len() - n - 1;
let mut vpt = Decimal::ZERO;
for i in start..self.bars.len() - 1 {
let prev_close = self.bars[i].close.value();
if prev_close.is_zero() { continue; }
let pct_chg = (self.bars[i + 1].close.value() - prev_close) / prev_close;
vpt += pct_chg * self.bars[i + 1].volume.value();
}
Some(vpt)
}
pub fn bear_run_length(&self) -> usize {
let n = self.bars.len();
if n < 2 { return 0; }
let mut count = 0;
let mut i = n - 1;
while i > 0 && self.bars[i].close.value() < self.bars[i - 1].close.value() {
count += 1;
i -= 1;
}
count
}
pub fn avg_true_range_pct(&self, n: usize) -> Option<Decimal> {
let atr = self.atr(n)?;
let close = self.bars.last()?.close.value();
if close.is_zero() { return None; }
Some(atr / close * Decimal::ONE_HUNDRED)
}
pub fn close_vs_ema(&self, n: usize) -> Option<Decimal> {
let ema = self.ema(n)?;
let close = self.bars.last()?.close.value();
if close.is_zero() { return None; }
Some((close - ema) / close * Decimal::ONE_HUNDRED)
}
pub fn volume_momentum(&self, n: usize) -> Option<Decimal> {
if n < 2 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let changes: Vec<Decimal> = (start..self.bars.len() - 1)
.map(|i| self.bars[i + 1].volume.value() - self.bars[i].volume.value())
.collect();
if changes.is_empty() { return None; }
#[allow(clippy::cast_possible_truncation)]
Some(changes.iter().sum::<Decimal>() / Decimal::from(changes.len() as u32))
}
pub fn max_volume_bar(&self, n: usize) -> Option<usize> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let (rel_idx, _) = self.bars[start..]
.iter()
.enumerate()
.max_by_key(|(_, b)| b.volume.value())?;
Some(n - 1 - rel_idx)
}
pub fn gap_count(&self, n: usize, min_pct: Decimal) -> Option<usize> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let start = self.bars.len() - n;
let count = (start..self.bars.len()).filter(|&i| {
let prev_close = self.bars[i - 1].close.value();
if prev_close.is_zero() { return false; }
let gap = (self.bars[i].open.value() - prev_close).abs() / prev_close * Decimal::ONE_HUNDRED;
gap >= min_pct
}).count();
Some(count)
}
pub fn avg_close_pct_change(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let start = self.bars.len() - n - 1;
let mut sum = Decimal::ZERO;
for i in start..self.bars.len() - 1 {
let prev = self.bars[i].close.value();
if prev.is_zero() { return None; }
sum += (self.bars[i + 1].close.value() - prev) / prev * Decimal::ONE_HUNDRED;
}
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(n as u32))
}
pub fn bollinger_width(&self, n: usize, multiplier: Decimal) -> Option<Decimal> {
let sma = self.sma(n)?;
if sma.is_zero() { return None; }
let std = self.std_dev(n)?;
let upper = sma + multiplier * std;
let lower = sma - multiplier * std;
Some((upper - lower) / sma)
}
pub fn close_above_ma_streak(&self, period: usize) -> usize {
if self.bars.len() < period { return 0; }
let mut streak = 0usize;
for i in (period - 1..self.bars.len()).rev() {
let sum: Decimal = (0..period).map(|j| self.bars[i + 1 - period + j].close.value()).sum();
#[allow(clippy::cast_possible_truncation)]
let sma = sum / Decimal::from(period as u32);
if self.bars[i].close.value() >= sma {
streak += 1;
} else {
break;
}
}
streak
}
pub fn avg_body_to_range_ratio(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
let mut count = 0u32;
for bar in &self.bars[start..] {
let range = bar.range();
if range.is_zero() { continue; }
sum += bar.body_size() / range;
count += 1;
}
if count == 0 { return None; }
Some(sum / Decimal::from(count))
}
pub fn net_volume(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut net = Decimal::ZERO;
for bar in &self.bars[start..] {
let range = bar.range();
let vol = bar.volume.value();
if range.is_zero() { continue; }
let buy_frac = (bar.close.value() - bar.low.value()) / range;
let buy_vol = vol * buy_frac;
let sell_vol = vol - buy_vol;
net += buy_vol - sell_vol;
}
Some(net)
}
pub fn avg_high_minus_open(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
#[allow(clippy::cast_possible_truncation)]
let sum: Decimal = self.bars[start..].iter()
.map(|b| b.high.value() - b.open.value())
.sum();
Some(sum / Decimal::from(n as u32))
}
pub fn close_consistency(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let upper = self.bars[start..].iter().filter(|b| {
let mid = b.midpoint();
b.close.value() >= mid
}).count();
#[allow(clippy::cast_possible_truncation)]
Some(Decimal::from(upper as u32) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
}
pub fn momentum_divergence(&self, fast: usize, slow: usize) -> Option<Decimal> {
if fast == 0 || slow == 0 || fast >= slow { return None; }
if self.bars.len() <= slow { return None; }
let n = self.bars.len();
let current = self.bars[n - 1].close.value();
let fast_prev = self.bars[n - 1 - fast].close.value();
let slow_prev = self.bars[n - 1 - slow].close.value();
Some((current - fast_prev) - (current - slow_prev))
}
pub fn price_range_pct(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
let low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
if low.is_zero() { return None; }
Some((high - low) / low * Decimal::ONE_HUNDRED)
}
pub fn avg_open_to_close(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
#[allow(clippy::cast_possible_truncation)]
let sum: Decimal = self.bars[start..].iter()
.map(|b| b.close.value() - b.open.value())
.sum();
Some(sum / Decimal::from(n as u32))
}
pub fn price_range_expansion(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < 2 * n { return None; }
let len = self.bars.len();
#[allow(clippy::cast_possible_truncation)]
let n_dec = Decimal::from(n as u32);
let recent_sum: Decimal = self.bars[len - n..].iter()
.map(|b| b.range())
.sum();
let prior_sum: Decimal = self.bars[len - 2 * n..len - n].iter()
.map(|b| b.range())
.sum();
Some((recent_sum - prior_sum) / n_dec)
}
pub fn up_volume_fraction(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut up_vol = Decimal::ZERO;
let mut total_vol = Decimal::ZERO;
for bar in &self.bars[start..] {
let v = bar.volume.value();
total_vol += v;
if bar.close.value() > bar.open.value() {
up_vol += v;
}
}
if total_vol.is_zero() { return None; }
Some(up_vol / total_vol)
}
pub fn std_volume(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 2 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let vols: Vec<f64> = self.bars[start..].iter()
.filter_map(|b| b.volume.value().to_f64())
.collect();
if vols.len() < 2 { return None; }
let nf = vols.len() as f64;
let mean = vols.iter().sum::<f64>() / nf;
let var = vols.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (nf - 1.0);
Some(var.sqrt())
}
pub fn longest_losing_streak(&self) -> usize {
if self.bars.len() < 2 { return 0; }
let mut max_streak = 0usize;
let mut current = 0usize;
for i in 1..self.bars.len() {
if self.bars[i].close.value() < self.bars[i - 1].close.value() {
current += 1;
if current > max_streak { max_streak = current; }
} else {
current = 0;
}
}
max_streak
}
pub fn recent_max_close(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
self.bars[start..].iter().map(|b| b.close.value()).max()
}
pub fn recent_min_close(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
self.bars[start..].iter().map(|b| b.close.value()).min()
}
pub fn chaikin_oscillator(&self, fast: usize, slow: usize) -> Option<Decimal> {
if fast == 0 || slow == 0 || fast >= slow || self.bars.len() <= slow { return None; }
let n = self.bars.len();
let alpha_fast = Decimal::TWO / Decimal::from(fast + 1);
let alpha_slow = Decimal::TWO / Decimal::from(slow + 1);
let start = n - slow;
let mut ema_fast = self.bars[start].close.value() * self.bars[start].volume.value();
let mut ema_slow = ema_fast;
for bar in &self.bars[start + 1..] {
let adv = bar.close.value() * bar.volume.value();
ema_fast = alpha_fast * adv + (Decimal::ONE - alpha_fast) * ema_fast;
ema_slow = alpha_slow * adv + (Decimal::ONE - alpha_slow) * ema_slow;
}
Some(ema_fast - ema_slow)
}
pub fn candle_body_trend(&self, n: usize) -> Option<i64> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let bull = self.bars[start..].iter()
.filter(|b| b.is_bullish()).count() as i64;
let bear = self.bars[start..].iter()
.filter(|b| b.is_bearish()).count() as i64;
Some(bull - bear)
}
pub fn pct_doji(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let doji_count = self.bars[start..].iter().filter(|b| {
let range = b.range();
if range.is_zero() { return true; }
let body = b.body_size();
body / range <= Decimal::new(1, 1)
}).count() as u32;
Some(Decimal::from(doji_count) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
}
pub fn recent_close_trend(&self, n: usize) -> Option<i64> {
if n < 2 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let closes: Vec<f64> = self.bars[start..]
.iter()
.map(|b| { use rust_decimal::prelude::ToPrimitive; b.close.value().to_f64().unwrap_or(0.0) })
.collect();
let m = closes.len() as f64;
let x_mean = (m - 1.0) / 2.0;
let y_mean: f64 = closes.iter().sum::<f64>() / m;
let mut num = 0.0f64;
let mut den = 0.0f64;
for (i, &y) in closes.iter().enumerate() {
let dx = i as f64 - x_mean;
num += dx * (y - y_mean);
den += dx * dx;
}
if den == 0.0 { return Some(0); }
let slope = num / den;
if slope > 1e-10 { Some(1) } else if slope < -1e-10 { Some(-1) } else { Some(0) }
}
pub fn high_low_range(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let max_high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
let min_low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
Some(max_high - min_low)
}
pub fn volume_above_avg_count(&self, n: usize) -> Option<usize> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let vols: Vec<Decimal> = self.bars[start..].iter().map(|b| b.volume.value()).collect();
let avg = vols.iter().sum::<Decimal>() / Decimal::from(n);
Some(vols.iter().filter(|&&v| v > avg).count())
}
pub fn range_vs_atr_ratio(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let avg_range = self.bars[start..].iter()
.map(|b| b.range())
.sum::<Decimal>() / Decimal::from(n);
if avg_range.is_zero() { return None; }
let last = self.bars.last()?;
Some((last.range()) / avg_range)
}
pub fn avg_volume_on_up_bars(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let up_vols: Vec<Decimal> = self.bars[start..].iter()
.filter(|b| b.is_bullish())
.map(|b| b.volume.value())
.collect();
if up_vols.is_empty() { return None; }
Some(up_vols.iter().sum::<Decimal>() / Decimal::from(up_vols.len() as u32))
}
pub fn avg_volume_on_down_bars(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let down_vols: Vec<Decimal> = self.bars[start..].iter()
.filter(|b| b.is_bearish())
.map(|b| b.volume.value())
.collect();
if down_vols.is_empty() { return None; }
Some(down_vols.iter().sum::<Decimal>() / Decimal::from(down_vols.len() as u32))
}
pub fn pct_bars_close_above_open(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let bull = self.bars[start..].iter()
.filter(|b| b.is_bullish())
.count() as u32;
Some(Decimal::from(bull) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
}
pub fn open_range_position(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let max_high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
let min_low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
let range = max_high - min_low;
if range.is_zero() { return None; }
let last_open = self.bars.last()?.open.value();
Some((last_open - min_low) / range)
}
pub fn overnight_gap_count(&self, n: usize, threshold_pct: Decimal) -> Option<usize> {
if n < 2 || self.bars.len() <= n { return None; }
let start = self.bars.len() - n;
let threshold = threshold_pct / Decimal::ONE_HUNDRED;
let count = self.bars[start..].iter().enumerate().filter(|(i, b)| {
let prev_close = self.bars[start + i - 1].close.value();
if prev_close.is_zero() { return false; }
let gap = (b.open.value() - prev_close).abs() / prev_close;
gap >= threshold
}).count();
Some(count)
}
pub fn trend_consistency(&self, n: usize) -> Option<Decimal> {
if n < 2 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let first_close = self.bars[start].close.value();
let last_close = self.bars.last()?.close.value();
if first_close == last_close { return Some(Decimal::ZERO); }
let up_trend = last_close > first_close;
let consistent: usize = self.bars[start + 1..].iter().enumerate()
.filter(|(i, b)| {
let prev = self.bars[start + i].close.value();
if up_trend { b.close.value() > prev } else { b.close.value() < prev }
})
.count();
Some(Decimal::from(consistent) / Decimal::from(n - 1))
}
pub fn last_close(&self) -> Option<Decimal> {
self.bars.last().map(|b| b.close.value())
}
pub fn first_close(&self) -> Option<Decimal> {
self.bars.first().map(|b| b.close.value())
}
pub fn close_change_n(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() <= n { return None; }
let prev = self.bars[self.bars.len() - 1 - n].close.value();
let last = self.bars.last()?.close.value();
Some(last - prev)
}
pub fn pct_change_n(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() <= n { return None; }
let prev = self.bars[self.bars.len() - 1 - n].close.value();
if prev.is_zero() { return None; }
let last = self.bars.last()?.close.value();
Some((last - prev) / prev * Decimal::ONE_HUNDRED)
}
pub fn close_to_high_ratio(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
let mut count = 0u32;
for b in &self.bars[start..] {
let range = b.range();
if range.is_zero() { continue; }
sum += (b.high.value() - b.close.value()) / range;
count += 1;
}
if count == 0 { None } else { Some(sum / Decimal::from(count)) }
}
pub fn close_to_low_ratio(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
let mut count = 0u32;
for b in &self.bars[start..] {
let range = b.range();
if range.is_zero() { continue; }
sum += (b.close.value() - b.low.value()) / range;
count += 1;
}
if count == 0 { None } else { Some(sum / Decimal::from(count)) }
}
pub fn volume_coefficient_of_variation(&self, n: usize) -> Option<f64> {
if n < 2 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let vols: Vec<f64> = self.bars[start..]
.iter()
.map(|b| { use rust_decimal::prelude::ToPrimitive; b.volume.value().to_f64().unwrap_or(0.0) })
.collect();
let mean = vols.iter().sum::<f64>() / vols.len() as f64;
if mean == 0.0 { return None; }
let variance = vols.iter().map(|&v| { let d = v - mean; d * d }).sum::<f64>() / vols.len() as f64;
Some(variance.sqrt() / mean)
}
pub fn close_wick_ratio(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
let mut count = 0u32;
for b in &self.bars[start..] {
let range = b.range();
if range.is_zero() { continue; }
let body_top = b.open.value().max(b.close.value());
let upper_wick = b.high.value() - body_top;
sum += upper_wick / range;
count += 1;
}
if count == 0 { None } else { Some(sum / Decimal::from(count)) }
}
pub fn wick_imbalance(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut upper_sum = Decimal::ZERO;
let mut lower_sum = Decimal::ZERO;
let mut range_sum = Decimal::ZERO;
for b in &self.bars[start..] {
let range = b.range();
if range.is_zero() { continue; }
let body_top = b.open.value().max(b.close.value());
let body_bot = b.open.value().min(b.close.value());
upper_sum += b.high.value() - body_top;
lower_sum += body_bot - b.low.value();
range_sum += range;
}
if range_sum.is_zero() { return None; }
Some((upper_sum - lower_sum) / range_sum)
}
pub fn avg_candle_size(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
Some(self.bars[start..].iter().map(|b| b.range()).sum::<Decimal>()
/ Decimal::from(n))
}
pub fn bull_strength(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
let mut count = 0u32;
for b in &self.bars[start..] {
if b.close.value() <= b.open.value() { continue; }
let range = b.range();
if range.is_zero() { continue; }
sum += (b.close.value() - b.open.value()) / range;
count += 1;
}
if count == 0 { None } else { Some(sum / Decimal::from(count)) }
}
pub fn bear_strength(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
let mut count = 0u32;
for b in &self.bars[start..] {
if b.is_bullish() { continue; }
let range = b.range();
if range.is_zero() { continue; }
sum += (b.open.value() - b.close.value()) / range;
count += 1;
}
if count == 0 { None } else { Some(sum / Decimal::from(count)) }
}
pub fn last_open(&self) -> Option<Decimal> {
self.bars.last().map(|b| b.open.value())
}
pub fn last_high(&self) -> Option<Decimal> {
self.bars.last().map(|b| b.high.value())
}
pub fn last_low(&self) -> Option<Decimal> {
self.bars.last().map(|b| b.low.value())
}
pub fn last_volume(&self) -> Option<Decimal> {
self.bars.last().map(|b| b.volume.value())
}
pub fn close_above_prev_high(&self, n: usize) -> Option<usize> {
if n == 0 || self.bars.len() <= n { return None; }
let start = self.bars.len() - n;
let count = self.bars[start..].iter().enumerate()
.filter(|(i, b)| b.close.value() > self.bars[start - 1 + i].high.value())
.count();
Some(count)
}
pub fn price_entropy(&self, n: usize) -> Option<f64> {
if n < 2 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut ups = 0usize;
for i in start + 1..self.bars.len() {
if self.bars[i].close.value() > self.bars[i - 1].close.value() { ups += 1; }
}
let total = n - 1;
if ups == 0 || ups == total { return None; }
let p = ups as f64 / total as f64;
let q = 1.0 - p;
Some(-(p * p.log2() + q * q.log2()))
}
pub fn avg_spread_pct(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
for b in &self.bars[start..] {
let close = b.close.value();
if close.is_zero() { return None; }
sum += (b.range()) / close * Decimal::ONE_HUNDRED;
}
Some(sum / Decimal::from(n))
}
pub fn close_momentum_ratio(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() <= n { return None; }
let prev = self.bars[self.bars.len() - 1 - n].close.value();
if prev.is_zero() { return None; }
Some(self.bars.last()?.close.value() / prev)
}
pub fn price_velocity(&self, fast: usize, slow: usize) -> Option<Decimal> {
let fast_chg = self.pct_change_n(fast)?;
let slow_chg = self.pct_change_n(slow)?;
Some(fast_chg - slow_chg)
}
pub fn longest_flat_streak(&self) -> usize {
let mut max_run = 0usize;
let mut run = 0usize;
for b in &self.bars {
if b.close.value() == b.open.value() {
run += 1;
max_run = max_run.max(run);
} else {
run = 0;
}
}
max_run
}
pub fn bars_since_new_high(&self) -> Option<usize> {
if self.bars.is_empty() { return None; }
let mut last_high_idx = 0;
let mut peak = self.bars[0].close.value();
for (i, b) in self.bars.iter().enumerate() {
if b.close.value() >= peak {
peak = b.close.value();
last_high_idx = i;
}
}
Some(self.bars.len() - 1 - last_high_idx)
}
pub fn drawdown_from_peak(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let peak = self.bars[start..].iter().map(|b| b.high.value()).max()?;
if peak.is_zero() { return None; }
let current = self.bars.last()?.close.value();
Some((current - peak) / peak * Decimal::ONE_HUNDRED)
}
pub fn price_oscillator(&self, fast: usize, slow: usize) -> Option<Decimal> {
if fast == 0 || slow == 0 || fast >= slow || self.bars.len() < slow { return None; }
let n = self.bars.len();
let fast_start = n - fast;
let slow_start = n - slow;
let fast_sma = self.bars[fast_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
/ Decimal::from(fast);
let slow_sma = self.bars[slow_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
/ Decimal::from(slow);
if slow_sma.is_zero() { return None; }
Some((fast_sma - slow_sma) / slow_sma * Decimal::ONE_HUNDRED)
}
pub fn close_below_prev_low(&self, n: usize) -> Option<usize> {
if n == 0 || self.bars.len() <= n { return None; }
let start = self.bars.len() - n;
let count = self.bars[start..].iter().enumerate()
.filter(|(i, b)| b.close.value() < self.bars[start - 1 + i].low.value())
.count();
Some(count)
}
pub fn bars_above_ma(&self, n: usize, period: usize) -> Option<usize> {
if n == 0 || period == 0 || self.bars.len() < n.max(period) { return None; }
let sma_start = self.bars.len() - period;
let sma = self.bars[sma_start..].iter().map(|b| b.close.value()).sum::<Decimal>()
/ Decimal::from(period);
let bar_start = self.bars.len() - n;
let count = self.bars[bar_start..].iter()
.filter(|b| b.close.value() > sma)
.count();
Some(count)
}
pub fn price_contraction(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < 2 * n { return None; }
let len = self.bars.len();
let recent_high = self.bars[len - n..].iter().map(|b| b.high.value()).max()?;
let recent_low = self.bars[len - n..].iter().map(|b| b.low.value()).min()?;
let prior_high = self.bars[len - 2 * n..len - n].iter().map(|b| b.high.value()).max()?;
let prior_low = self.bars[len - 2 * n..len - n].iter().map(|b| b.low.value()).min()?;
let recent_range = recent_high - recent_low;
let prior_range = prior_high - prior_low;
if prior_range.is_zero() { return None; }
Some(recent_range / prior_range)
}
pub fn bars_since_new_low(&self) -> Option<usize> {
if self.bars.is_empty() { return None; }
let mut last_low_idx = 0;
let mut trough = self.bars[0].close.value();
for (i, b) in self.bars.iter().enumerate() {
if b.close.value() <= trough {
trough = b.close.value();
last_low_idx = i;
}
}
Some(self.bars.len() - 1 - last_low_idx)
}
pub fn volume_per_range(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
let mut count = 0u32;
for b in &self.bars[start..] {
let range = b.range();
if range.is_zero() { continue; }
sum += b.volume.value() / range;
count += 1;
}
if count == 0 { None } else { Some(sum / Decimal::from(count)) }
}
pub fn price_volatility_ratio(&self, fast: usize, slow: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if fast < 2 || slow < 2 || fast >= slow || self.bars.len() < slow { return None; }
let n = self.bars.len();
let std_dev = |bars: &[crate::ohlcv::OhlcvBar]| -> Option<f64> {
let m = bars.len() as f64;
let vals: Vec<f64> = bars.iter().filter_map(|b| b.close.value().to_f64()).collect();
if vals.len() < 2 { return None; }
let mean = vals.iter().sum::<f64>() / m;
let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (m - 1.0);
Some(var.sqrt())
};
let fast_vol = std_dev(&self.bars[n - fast..])?;
let slow_vol = std_dev(&self.bars[n - slow..])?;
if slow_vol == 0.0 { return None; }
Some(fast_vol / slow_vol)
}
pub fn last_bar(&self) -> Option<&OhlcvBar> {
self.bars.last()
}
pub fn close_distance_from_high(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let max_high = self.bars[start..].iter().map(|b| b.high.value()).max()?;
Some((max_high - self.bars.last()?.close.value()).abs())
}
pub fn pct_from_low(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let min_low = self.bars[start..].iter().map(|b| b.low.value()).min()?;
if min_low.is_zero() { return None; }
Some((self.bars.last()?.close.value() - min_low) / min_low * Decimal::ONE_HUNDRED)
}
pub fn is_breakout_up(&self, n: usize) -> bool {
if n == 0 || self.bars.len() <= n { return false; }
let len = self.bars.len();
let prior_high = self.bars[len - 1 - n..len - 1].iter().map(|b| b.close.value()).max();
match (prior_high, self.bars.last()) {
(Some(ph), Some(last)) => last.close.value() > ph,
_ => false,
}
}
pub fn consecutive_closes_above(&self, price: Decimal) -> usize {
self.bars.iter().rev().take_while(|b| b.close.value() > price).count()
}
pub fn open_range_pct(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let vals: Vec<f64> = self.bars[start..].iter().filter_map(|b| {
let range = b.range();
if range.is_zero() { return None; }
let num = (b.open.value() - b.low.value()).to_f64()?;
let den = range.to_f64()?;
Some(num / den * 100.0)
}).collect();
if vals.is_empty() { return None; }
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
pub fn skewness_of_returns(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 3 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let returns: Vec<f64> = slice.windows(2).filter_map(|w| {
let prev_c = w[0].close.value().to_f64()?;
if prev_c == 0.0 { return None; }
let curr_c = w[1].close.value().to_f64()?;
Some((curr_c - prev_c) / prev_c)
}).collect();
let m = returns.len();
if m < 2 { return None; }
let mean = returns.iter().sum::<f64>() / m as f64;
let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / m as f64;
let std_dev = variance.sqrt();
if std_dev == 0.0 { return None; }
let skew = returns.iter().map(|r| ((r - mean) / std_dev).powi(3)).sum::<f64>() / m as f64;
Some(skew)
}
pub fn kurtosis_of_returns(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 4 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let returns: Vec<f64> = slice.windows(2).filter_map(|w| {
let prev_c = w[0].close.value().to_f64()?;
if prev_c == 0.0 { return None; }
let curr_c = w[1].close.value().to_f64()?;
Some((curr_c - prev_c) / prev_c)
}).collect();
let m = returns.len();
if m < 3 { return None; }
let mean = returns.iter().sum::<f64>() / m as f64;
let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / m as f64;
let std_dev = variance.sqrt();
if std_dev == 0.0 { return None; }
let kurt = returns.iter().map(|r| ((r - mean) / std_dev).powi(4)).sum::<f64>() / m as f64 - 3.0;
Some(kurt)
}
pub fn autocorrelation_of_returns(&self, n: usize, lag: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if lag == 0 || n < lag + 2 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let returns: Vec<f64> = slice.windows(2).filter_map(|w| {
let prev_c = w[0].close.value().to_f64()?;
if prev_c == 0.0 { return None; }
let curr_c = w[1].close.value().to_f64()?;
Some((curr_c - prev_c) / prev_c)
}).collect();
if returns.len() <= lag { return None; }
let x = &returns[..returns.len() - lag];
let y = &returns[lag..];
let m = x.len();
if m == 0 { return None; }
let mean_x = x.iter().sum::<f64>() / m as f64;
let mean_y = y.iter().sum::<f64>() / m as f64;
let cov: f64 = x.iter().zip(y.iter()).map(|(a, b)| (a - mean_x) * (b - mean_y)).sum::<f64>() / m as f64;
let std_x = (x.iter().map(|a| (a - mean_x).powi(2)).sum::<f64>() / m as f64).sqrt();
let std_y = (y.iter().map(|b| (b - mean_y).powi(2)).sum::<f64>() / m as f64).sqrt();
if std_x == 0.0 || std_y == 0.0 { return None; }
Some(cov / (std_x * std_y))
}
pub fn median_volume(&self, n: usize) -> Option<Decimal> {
use rust_decimal::prelude::ToPrimitive;
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut vols: Vec<f64> = self.bars[start..]
.iter()
.filter_map(|b| b.volume.value().to_f64())
.collect();
if vols.is_empty() { return None; }
vols.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mid = vols.len() / 2;
let median = if vols.len() % 2 == 0 {
(vols[mid - 1] + vols[mid]) / 2.0
} else {
vols[mid]
};
Decimal::try_from(median).ok()
}
pub fn avg_true_range(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let mut sum = Decimal::ZERO;
for (i, bar) in slice.iter().enumerate() {
let prev = if i == 0 { None } else { Some(&slice[i - 1]) };
sum += bar.true_range(prev);
}
sum.checked_div(Decimal::from(n as u32))
}
pub fn omega_ratio(&self, n: usize, threshold: Decimal) -> Option<Decimal> {
if n < 2 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let mut gain_sum = Decimal::ZERO;
let mut loss_sum = Decimal::ZERO;
let mut count = 0u32;
for w in slice.windows(2) {
let prev_c = w[0].close.value();
if prev_c.is_zero() { continue; }
let ret = (w[1].close.value() - prev_c) / prev_c;
gain_sum += (ret - threshold).max(Decimal::ZERO);
loss_sum += (threshold - ret).max(Decimal::ZERO);
count += 1;
}
if count == 0 || loss_sum.is_zero() { return None; }
gain_sum.checked_div(loss_sum)
}
pub fn kelly_fraction(&self, n: usize) -> Option<Decimal> {
if n < 2 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let mut gains = Vec::new();
let mut losses = Vec::new();
for w in slice.windows(2) {
let prev_c = w[0].close.value();
if prev_c.is_zero() { continue; }
let ret = (w[1].close.value() - prev_c) / prev_c;
if ret > Decimal::ZERO { gains.push(ret); } else if ret < Decimal::ZERO { losses.push(-ret); }
}
let total = gains.len() + losses.len();
if gains.is_empty() || losses.is_empty() || total == 0 { return None; }
#[allow(clippy::cast_possible_truncation)]
let win_rate = Decimal::from(gains.len() as u32) / Decimal::from(total as u32);
let avg_gain: Decimal = gains.iter().copied().sum::<Decimal>() / Decimal::from(gains.len() as u32);
let avg_loss: Decimal = losses.iter().copied().sum::<Decimal>() / Decimal::from(losses.len() as u32);
if avg_loss.is_zero() || avg_gain.is_zero() { return None; }
let kelly = win_rate / avg_loss - (Decimal::ONE - win_rate) / avg_gain;
Some(kelly.clamp(Decimal::NEGATIVE_ONE, Decimal::ONE))
}
pub fn profit_factor(&self, n: usize) -> Option<Decimal> {
if n < 2 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let mut gross_gain = Decimal::ZERO;
let mut gross_loss = Decimal::ZERO;
for w in slice.windows(2) {
let prev_c = w[0].close.value();
if prev_c.is_zero() { continue; }
let ret = w[1].close.value() - prev_c;
if ret > Decimal::ZERO { gross_gain += ret; } else { gross_loss += -ret; }
}
if gross_loss.is_zero() { return None; }
gross_gain.checked_div(gross_loss)
}
pub fn recovery_factor(&self, n: usize) -> Option<Decimal> {
if n < 2 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let first_close = slice.first()?.close.value();
let last_close = slice.last()?.close.value();
if first_close.is_zero() { return None; }
let net_return = (last_close - first_close) / first_close;
let mut peak = Decimal::MIN;
let mut max_dd = Decimal::ZERO;
for bar in slice {
let c = bar.close.value();
if c > peak { peak = c; }
let dd = if peak.is_zero() { Decimal::ZERO } else { (peak - c) / peak };
if dd > max_dd { max_dd = dd; }
}
if max_dd.is_zero() { return None; }
net_return.checked_div(max_dd)
}
pub fn avg_max_adverse_excursion(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let mut sum = Decimal::ZERO;
let mut count = 0u32;
for bar in slice {
let o = bar.open.value();
if o.is_zero() { continue; }
let mae = if bar.is_bullish() {
(o - bar.low.value()).abs() / o
} else {
(bar.high.value() - o).abs() / o
};
sum += mae;
count += 1;
}
if count == 0 { return None; }
sum.checked_div(Decimal::from(count))
}
pub fn half_life_of_mean_reversion(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 3 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let prices: Vec<f64> = slice.iter().filter_map(|b| b.close.value().to_f64()).collect();
let m = prices.len();
if m < 3 { return None; }
let lagged: Vec<f64> = prices[..m - 1].to_vec();
let delta: Vec<f64> = prices[1..].iter().zip(prices[..m-1].iter()).map(|(a, b)| a - b).collect();
let n_obs = lagged.len() as f64;
let mean_x = lagged.iter().sum::<f64>() / n_obs;
let mean_y = delta.iter().sum::<f64>() / n_obs;
let cov_xy = lagged.iter().zip(delta.iter()).map(|(x, y)| (x - mean_x) * (y - mean_y)).sum::<f64>();
let var_x = lagged.iter().map(|x| (x - mean_x).powi(2)).sum::<f64>();
if var_x == 0.0 { return None; }
let lambda = cov_xy / var_x;
if lambda >= 0.0 { return None; } Some(-std::f64::consts::LN_2 / lambda)
}
pub fn treynor_ratio(&self, market: &OhlcvSeries, n: usize, risk_free_rate: f64) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 3 || self.bars.len() < n || market.bars.len() < n { return None; }
let beta = self.beta(market, n)?;
if beta == 0.0 { return None; }
let start = self.bars.len() - n;
let first_c = self.bars[start].close.value().to_f64()?;
let last_c = self.bars.last()?.close.value().to_f64()?;
if first_c == 0.0 { return None; }
let total_return = (last_c - first_c) / first_c;
Some((total_return - risk_free_rate) / beta)
}
pub fn tracking_error(&self, benchmark: &OhlcvSeries, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 2 || self.bars.len() < n || benchmark.bars.len() < n { return None; }
let p_start = self.bars.len() - n;
let b_start = benchmark.bars.len() - n;
let p_slice = &self.bars[p_start..];
let b_slice = &benchmark.bars[b_start..];
let diffs: Vec<f64> = p_slice.windows(2).zip(b_slice.windows(2)).filter_map(|(pw, bw)| {
let pc0 = pw[0].close.value().to_f64()?;
let bc0 = bw[0].close.value().to_f64()?;
if pc0 == 0.0 || bc0 == 0.0 { return None; }
let pr = (pw[1].close.value().to_f64()? - pc0) / pc0;
let br = (bw[1].close.value().to_f64()? - bc0) / bc0;
Some(pr - br)
}).collect();
let m = diffs.len() as f64;
if m < 1.0 { return None; }
let mean = diffs.iter().sum::<f64>() / m;
let var = diffs.iter().map(|d| (d - mean).powi(2)).sum::<f64>() / m;
Some(var.sqrt())
}
pub fn up_capture(&self, benchmark: &OhlcvSeries, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 2 || self.bars.len() < n || benchmark.bars.len() < n { return None; }
let p_start = self.bars.len() - n;
let b_start = benchmark.bars.len() - n;
let p_slice = &self.bars[p_start..];
let b_slice = &benchmark.bars[b_start..];
let mut p_up_sum = 0.0f64;
let mut b_up_sum = 0.0f64;
let mut count = 0u32;
for (pw, bw) in p_slice.windows(2).zip(b_slice.windows(2)) {
let bc0 = bw[0].close.value().to_f64()?;
let pc0 = pw[0].close.value().to_f64()?;
if bc0 == 0.0 || pc0 == 0.0 { continue; }
let br = (bw[1].close.value().to_f64()? - bc0) / bc0;
if br <= 0.0 { continue; }
let pr = (pw[1].close.value().to_f64()? - pc0) / pc0;
p_up_sum += pr;
b_up_sum += br;
count += 1;
}
if count == 0 || b_up_sum == 0.0 { return None; }
Some(p_up_sum / b_up_sum)
}
pub fn down_capture(&self, benchmark: &OhlcvSeries, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 2 || self.bars.len() < n || benchmark.bars.len() < n { return None; }
let p_start = self.bars.len() - n;
let b_start = benchmark.bars.len() - n;
let p_slice = &self.bars[p_start..];
let b_slice = &benchmark.bars[b_start..];
let mut p_dn_sum = 0.0f64;
let mut b_dn_sum = 0.0f64;
let mut count = 0u32;
for (pw, bw) in p_slice.windows(2).zip(b_slice.windows(2)) {
let bc0 = bw[0].close.value().to_f64()?;
let pc0 = pw[0].close.value().to_f64()?;
if bc0 == 0.0 || pc0 == 0.0 { continue; }
let br = (bw[1].close.value().to_f64()? - bc0) / bc0;
if br >= 0.0 { continue; }
let pr = (pw[1].close.value().to_f64()? - pc0) / pc0;
p_dn_sum += pr;
b_dn_sum += br;
count += 1;
}
if count == 0 || b_dn_sum == 0.0 { return None; }
Some(p_dn_sum / b_dn_sum)
}
pub fn payoff_ratio(&self, n: usize) -> Option<Decimal> {
if n < 2 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let mut win_sum = Decimal::ZERO;
let mut loss_sum = Decimal::ZERO;
let mut win_cnt = 0u32;
let mut loss_cnt = 0u32;
for w in slice.windows(2) {
let pc = w[0].close.value();
if pc.is_zero() { continue; }
let ret = (w[1].close.value() - pc) / pc;
if ret > Decimal::ZERO { win_sum += ret; win_cnt += 1; }
else if ret < Decimal::ZERO { loss_sum += -ret; loss_cnt += 1; }
}
if win_cnt == 0 || loss_cnt == 0 { return None; }
let avg_win = win_sum / Decimal::from(win_cnt);
let avg_loss = loss_sum / Decimal::from(loss_cnt);
avg_win.checked_div(avg_loss)
}
pub fn expected_value(&self, n: usize) -> Option<Decimal> {
if n < 2 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let mut wins = Vec::new();
let mut losses = Vec::new();
for w in slice.windows(2) {
let pc = w[0].close.value();
if pc.is_zero() { continue; }
let ret = (w[1].close.value() - pc) / pc;
if ret > Decimal::ZERO { wins.push(ret); }
else if ret < Decimal::ZERO { losses.push(-ret); }
}
let total = wins.len() + losses.len();
if total == 0 { return None; }
#[allow(clippy::cast_possible_truncation)]
let total_d = Decimal::from(total as u32);
let win_rate = Decimal::from(wins.len() as u32) / total_d;
let loss_rate = Decimal::ONE - win_rate;
let avg_win = if wins.is_empty() { Decimal::ZERO } else { wins.iter().copied().sum::<Decimal>() / Decimal::from(wins.len() as u32) };
let avg_loss = if losses.is_empty() { Decimal::ZERO } else { losses.iter().copied().sum::<Decimal>() / Decimal::from(losses.len() as u32) };
Some(win_rate * avg_win - loss_rate * avg_loss)
}
pub fn breakout_count(&self, n: usize, lookback: usize) -> Option<usize> {
if n == 0 || lookback == 0 { return None; }
let required = n + lookback;
if self.bars.len() < required { return None; }
let slice = &self.bars[self.bars.len() - required..];
let count = (lookback..slice.len()).filter(|&i| {
let current_close = slice[i].close.value();
let prior_high = slice[i - lookback..i].iter().map(|b| b.close.value()).fold(Decimal::MIN, Decimal::max);
current_close > prior_high
}).count();
Some(count)
}
pub fn pct_close_above_ema(&self, n: usize, period: usize) -> Option<Decimal> {
if n == 0 || period == 0 { return None; }
let required = n + period - 1;
if self.bars.len() < required { return None; }
let slice = &self.bars[self.bars.len() - required..];
#[allow(clippy::cast_possible_truncation)]
let k = Decimal::TWO / Decimal::from((period + 1) as u32);
let seed_sum: Decimal = slice[..period].iter().map(|b| b.close.value()).sum();
let seed_avg = seed_sum / Decimal::from(period as u32);
let mut ema = seed_avg;
let mut above = 0u32;
for bar in &slice[period..] {
let c = bar.close.value();
ema = c * k + ema * (Decimal::ONE - k);
if c > ema { above += 1; }
}
let n_d = Decimal::from(n as u32);
Some(Decimal::from(above) / n_d * Decimal::ONE_HUNDRED)
}
pub fn avg_volume_imbalance(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
let mut count = 0u32;
for bar in &self.bars[start..] {
let h = bar.high.value();
let l = bar.low.value();
let c = bar.close.value();
let range = h - l;
if range.is_zero() { continue; }
let clv = ((c - l) - (h - c)) / range;
sum += clv * bar.volume.value();
count += 1;
}
if count == 0 { return None; }
sum.checked_div(Decimal::from(count))
}
pub fn avg_clv(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
let mut count = 0u32;
for bar in &self.bars[start..] {
let h = bar.high.value();
let l = bar.low.value();
let c = bar.close.value();
let range = h - l;
if range.is_zero() { continue; }
sum += ((c - l) - (h - c)) / range;
count += 1;
}
if count == 0 { return None; }
sum.checked_div(Decimal::from(count))
}
pub fn bars_in_drawdown(&self, n: usize) -> Option<usize> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let mut peak = Decimal::MIN;
let mut count = 0usize;
for bar in slice {
let c = bar.close.value();
if c > peak { peak = c; } else { count += 1; }
}
Some(count)
}
pub fn resistance_breakout_pct(&self, n: usize, lookback: usize) -> Option<Decimal> {
if n == 0 || lookback == 0 { return None; }
let count = self.breakout_count(n, lookback)?;
#[allow(clippy::cast_possible_truncation)]
Some(Decimal::from(count as u32) / Decimal::from(n as u32) * Decimal::ONE_HUNDRED)
}
pub fn avg_abs_open_gap(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start - 1..]; let mut sum = Decimal::ZERO;
for w in slice.windows(2) {
let gap = (w[1].open.value() - w[0].close.value()).abs();
sum += gap;
}
sum.checked_div(Decimal::from(n as u32))
}
pub fn avg_wicks_to_body(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let mut sum = Decimal::ZERO;
let mut count = 0u32;
for bar in &self.bars[start..] {
let body = bar.body_size();
if body.is_zero() { continue; }
sum += (bar.upper_shadow() + bar.lower_shadow()) / body;
count += 1;
}
if count == 0 { return None; }
sum.checked_div(Decimal::from(count))
}
pub fn volume_trend_correlation(&self, n: usize) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if n < 2 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let vols: Vec<f64> = slice.iter().filter_map(|b| b.volume.value().to_f64()).collect();
let dirs: Vec<f64> = slice.iter().map(|b| {
if b.is_bullish() { 1.0 } else if b.is_bearish() { -1.0 } else { 0.0 }
}).collect();
let m = vols.len().min(dirs.len()) as f64;
if m < 2.0 { return None; }
let mv = vols.iter().sum::<f64>() / m;
let md = dirs.iter().sum::<f64>() / m;
let cov = vols.iter().zip(dirs.iter()).map(|(v, d)| (v - mv) * (d - md)).sum::<f64>() / m;
let sv = (vols.iter().map(|v| (v - mv).powi(2)).sum::<f64>() / m).sqrt();
let sd = (dirs.iter().map(|d| (d - md).powi(2)).sum::<f64>() / m).sqrt();
if sv == 0.0 || sd == 0.0 { return None; }
Some(cov / (sv * sd))
}
pub fn candle_consistency(&self, n: usize) -> Option<Decimal> {
if n < 2 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let slice = &self.bars[start..];
let dirs: Vec<i8> = slice.iter().map(|b| {
if b.is_bullish() { 1 } else if b.is_bearish() { -1 } else { 0 }
}).collect();
let consistent = dirs.windows(2).filter(|w| w[0] != 0 && w[1] != 0 && w[0] == w[1]).count();
let total = dirs.windows(2).filter(|w| w[0] != 0 && w[1] != 0).count();
if total == 0 { return None; }
#[allow(clippy::cast_possible_truncation)]
Some(Decimal::from(consistent as u32) / Decimal::from(total as u32) * Decimal::ONE_HUNDRED)
}
pub fn avg_open_close_spread(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let start = self.bars.len() - n;
let sum: Decimal = self.bars[start..].iter()
.map(|b| b.body_size())
.sum();
sum.checked_div(Decimal::from(n as u32))
}
pub fn avg_range_to_prev_close(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let start = self.bars.len() - n - 1;
let slice = &self.bars[start..];
let mut sum = Decimal::ZERO;
let mut count = 0u32;
for w in slice.windows(2) {
let pc = w[0].close.value();
if pc.is_zero() { continue; }
let range = w[1].range();
sum += range / pc;
count += 1;
}
if count == 0 { return None; }
sum.checked_div(Decimal::from(count))
}
pub fn volume_weighted_std_dev(&self, n: usize) -> Option<Decimal> {
use rust_decimal::prelude::{FromPrimitive, ToPrimitive};
if n == 0 || self.bars.len() < n { return None; }
let slice = &self.bars[self.bars.len() - n..];
let total_vol: Decimal = slice.iter().map(|b| b.volume.value()).sum();
if total_vol.is_zero() { return None; }
let vwap: Decimal = slice.iter()
.map(|b| b.close.value() * b.volume.value())
.sum::<Decimal>() / total_vol;
let vw_var: Decimal = slice.iter()
.map(|b| { let d = b.close.value() - vwap; b.volume.value() * d * d })
.sum::<Decimal>() / total_vol;
let vf = vw_var.to_f64()?;
Decimal::from_f64(vf.sqrt())
}
pub fn pct_inside_bars(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let slice = &self.bars[self.bars.len() - n - 1..];
let count = slice.windows(2)
.filter(|w| w[1].high.value() < w[0].high.value() && w[1].low.value() > w[0].low.value())
.count();
Decimal::from(count as u32).checked_div(Decimal::from(n as u32))
}
pub fn avg_bar_polarity(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let slice = &self.bars[self.bars.len() - n..];
let sum: Decimal = slice.iter().map(|b| {
let c = b.close.value();
let o = b.open.value();
if c > o { Decimal::ONE } else if c < o { Decimal::NEGATIVE_ONE } else { Decimal::ZERO }
}).sum();
sum.checked_div(Decimal::from(n as u32))
}
pub fn return_tail_ratio(&self, n: usize) -> Option<Decimal> {
if n < 2 || self.bars.len() < n + 1 { return None; }
let slice = &self.bars[self.bars.len() - n - 1..];
let mut returns: Vec<Decimal> = slice.windows(2)
.filter_map(|w| {
let pc = w[0].close.value();
if pc.is_zero() { return None; }
Some((w[1].close.value() - pc) / pc)
})
.collect();
if returns.is_empty() { return None; }
returns.sort();
let len = returns.len();
let p95_idx = ((len as f64 * 0.95) as usize).min(len - 1);
let p05_idx = ((len as f64 * 0.05) as usize).min(len - 1);
let p95 = returns[p95_idx];
let p05 = returns[p05_idx].abs();
if p05.is_zero() { return None; }
p95.checked_div(p05)
}
pub fn signed_gap_sum(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let slice = &self.bars[self.bars.len() - n - 1..];
let sum: Decimal = slice.windows(2)
.map(|w| w[1].open.value() - w[0].close.value())
.sum();
Some(sum)
}
pub fn bull_bar_fraction(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let slice = &self.bars[self.bars.len() - n..];
let count = slice.iter().filter(|b| b.is_bullish()).count();
#[allow(clippy::cast_possible_truncation)]
Decimal::from(count as u32).checked_div(Decimal::from(n as u32))
}
pub fn cumulative_delta(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let sum: Decimal = self.bars[self.bars.len() - n..]
.iter()
.map(|b| b.close.value() - b.open.value())
.sum();
Some(sum)
}
pub fn avg_body_to_atr(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let slice = &self.bars[self.bars.len() - n - 1..];
let trs: Vec<Decimal> = slice.windows(2).map(|w| {
let h = w[1].high.value();
let l = w[1].low.value();
let pc = w[0].close.value();
(h - l).max((h - pc).abs()).max((l - pc).abs())
}).collect();
let atr: Decimal = trs.iter().sum::<Decimal>();
if atr.is_zero() { return None; }
#[allow(clippy::cast_possible_truncation)]
let n_d = Decimal::from(n as u32);
let avg_atr = atr / n_d;
let avg_body: Decimal = slice[1..].iter()
.map(|b| b.body_size())
.sum::<Decimal>() / n_d;
avg_body.checked_div(avg_atr)
}
pub fn candle_direction_changes(&self, n: usize) -> Option<usize> {
if n < 2 || self.bars.len() < n { return None; }
let slice = &self.bars[self.bars.len() - n..];
let directions: Vec<i32> = slice.iter()
.map(|b| {
let d = b.close.value() - b.open.value();
if d > Decimal::ZERO { 1 } else if d < Decimal::ZERO { -1 } else { 0 }
})
.filter(|d| *d != 0)
.collect();
let changes = directions.windows(2).filter(|w| w[0] != w[1]).count();
Some(changes)
}
pub fn win_rate(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let slice = &self.bars[self.bars.len() - n - 1..];
let wins = slice.windows(2)
.filter(|w| w[1].close.value() > w[0].close.value())
.count();
#[allow(clippy::cast_possible_truncation)]
Decimal::from(wins as u32).checked_div(Decimal::from(n as u32))
}
pub fn best_return(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let slice = &self.bars[self.bars.len() - n - 1..];
slice.windows(2).filter_map(|w| {
let pc = w[0].close.value();
if pc.is_zero() { return None; }
Some((w[1].close.value() - pc) / pc)
}).reduce(|a, b| if a > b { a } else { b })
}
pub fn worst_return(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let slice = &self.bars[self.bars.len() - n - 1..];
slice.windows(2).filter_map(|w| {
let pc = w[0].close.value();
if pc.is_zero() { return None; }
Some((w[1].close.value() - pc) / pc)
}).reduce(|a, b| if a < b { a } else { b })
}
pub fn median_return(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let slice = &self.bars[self.bars.len() - n - 1..];
let mut returns: Vec<Decimal> = slice.windows(2).filter_map(|w| {
let pc = w[0].close.value();
if pc.is_zero() { return None; }
Some((w[1].close.value() - pc) / pc)
}).collect();
if returns.is_empty() { return None; }
returns.sort();
let m = returns.len();
if m % 2 == 1 { Some(returns[m / 2]) }
else { (returns[m / 2 - 1] + returns[m / 2]).checked_div(Decimal::TWO) }
}
pub fn price_vs_median(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let slice = &self.bars[self.bars.len() - n..];
let mut closes: Vec<Decimal> = slice.iter().map(|b| b.close.value()).collect();
closes.sort();
let m = closes.len();
let median = if m % 2 == 1 { closes[m / 2] }
else { (closes[m / 2 - 1] + closes[m / 2]) / Decimal::TWO };
if median.is_zero() { return None; }
let current = self.bars.last()?.close.value();
(current - median).checked_div(median).map(|r| r * Decimal::ONE_HUNDRED)
}
pub fn close_win_rate(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let slice = &self.bars[self.bars.len() - n - 1..];
let wins = slice.windows(2).filter(|w| w[1].close.value() > w[0].close.value()).count();
#[allow(clippy::cast_possible_truncation)]
Decimal::from(wins as u32).checked_div(Decimal::from(n as u32))
}
pub fn rolling_vwap(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let slice = &self.bars[self.bars.len() - n..];
let total_vol: Decimal = slice.iter().map(|b| b.volume.value()).sum();
if total_vol.is_zero() { return None; }
let vwap = slice.iter().map(|b| b.close.value() * b.volume.value()).sum::<Decimal>() / total_vol;
Some(vwap)
}
pub fn engulfing_count(&self, n: usize) -> Option<usize> {
if n == 0 || self.bars.len() < n + 1 { return None; }
let slice = &self.bars[self.bars.len() - n - 1..];
let count = slice.windows(2).filter(|w| {
let (po, pc) = (w[0].open.value(), w[0].close.value());
let (co, cc) = (w[1].open.value(), w[1].close.value());
let prev_lo = po.min(pc);
let prev_hi = po.max(pc);
let curr_lo = co.min(cc);
let curr_hi = co.max(cc);
curr_lo <= prev_lo && curr_hi >= prev_hi && prev_lo != prev_hi
}).count();
Some(count)
}
pub fn rolling_velocity(&self, n: usize) -> Option<Decimal> {
let len = self.bars.len();
if n == 0 || len < n + 1 { return None; }
Some(self.bars[len - 1].close.value() - self.bars[len - 1 - n].close.value())
}
pub fn avg_body_ratio(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let slice = &self.bars[self.bars.len() - n..];
let (sum, count) = slice.iter().fold((Decimal::ZERO, 0u32), |(s, c), b| {
let range = b.range();
if range.is_zero() { (s, c) }
else { (s + b.body_size() / range, c + 1) }
});
if count == 0 { return None; }
sum.checked_div(Decimal::from(count))
}
pub fn avg_upper_shadow_fraction(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let bars = &self.bars[self.bars.len() - n..];
let sum: Decimal = bars.iter().map(|b| {
let range = b.range();
if range.is_zero() { Decimal::ZERO }
else {
let body_hi = b.close.value().max(b.open.value());
(b.high.value() - body_hi) / range
}
}).sum();
Some(sum / Decimal::from(n as u32))
}
pub fn avg_lower_shadow_fraction(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let bars = &self.bars[self.bars.len() - n..];
let sum: Decimal = bars.iter().map(|b| {
let range = b.range();
if range.is_zero() { Decimal::ZERO }
else {
let body_lo = b.close.value().min(b.open.value());
(body_lo - b.low.value()) / range
}
}).sum();
Some(sum / Decimal::from(n as u32))
}
pub fn avg_intrabar_return(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let bars = &self.bars[self.bars.len() - n..];
let vals: Vec<Decimal> = bars.iter().filter_map(|b| {
if b.open.value().is_zero() { None }
else {
Some((b.close.value() - b.open.value()) / b.open.value() * Decimal::ONE_HUNDRED)
}
}).collect();
if vals.is_empty() { return None; }
let sum: Decimal = vals.iter().sum();
Some(sum / Decimal::from(vals.len() as u32))
}
pub fn avg_close_position(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let bars = &self.bars[self.bars.len() - n..];
let half = Decimal::new(5, 1);
let sum: Decimal = bars.iter().map(|b| {
let range = b.range();
if range.is_zero() { half }
else { (b.close.value() - b.low.value()) / range }
}).sum();
Some(sum / Decimal::from(n as u32))
}
pub fn avg_shadow_imbalance(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let bars = &self.bars[self.bars.len() - n..];
let sum: Decimal = bars.iter().map(|b| {
let range = b.range();
if range.is_zero() { Decimal::ZERO }
else {
let body_hi = b.close.value().max(b.open.value());
let body_lo = b.close.value().min(b.open.value());
let upper = b.high.value() - body_hi;
let lower = body_lo - b.low.value();
(upper - lower) / range
}
}).sum();
Some(sum / Decimal::from(n as u32))
}
pub fn avg_normalized_range(&self, n: usize) -> Option<Decimal> {
if n == 0 || self.bars.len() < n { return None; }
let bars = &self.bars[self.bars.len() - n..];
let vals: Vec<Decimal> = bars.iter().filter_map(|b| {
if b.close.value().is_zero() { None }
else { Some((b.range()) / b.close.value()) }
}).collect();
if vals.is_empty() { return None; }
let sum: Decimal = vals.iter().sum();
Some(sum / Decimal::from(vals.len() as u32))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Side;
use rust_decimal_macros::dec;
fn make_price(s: &str) -> Price {
Price::new(s.parse().unwrap()).unwrap()
}
fn make_qty(s: &str) -> Quantity {
Quantity::new(s.parse().unwrap()).unwrap()
}
fn make_bar(o: &str, h: &str, l: &str, c: &str) -> OhlcvBar {
OhlcvBar {
symbol: Symbol::new("X").unwrap(),
open: make_price(o),
high: make_price(h),
low: make_price(l),
close: make_price(c),
volume: make_qty("100"),
ts_open: NanoTimestamp::new(0),
ts_close: NanoTimestamp::new(1),
tick_count: 1,
}
}
fn bar(close: &str) -> OhlcvBar {
make_bar(close, close, close, close)
}
fn make_tick(sym: &str, price: &str, qty: &str, ts: i64) -> Tick {
Tick::new(
Symbol::new(sym).unwrap(),
make_price(price),
make_qty(qty),
Side::Ask,
NanoTimestamp::new(ts),
)
}
#[test]
fn test_ohlcv_bar_validate_ok() {
let bar = make_bar("100", "110", "90", "105");
assert!(bar.validate().is_ok());
}
#[test]
fn test_ohlcv_bar_validate_high_less_than_close_fails() {
let bar = make_bar("100", "104", "90", "110");
assert!(matches!(bar.validate(), Err(FinError::BarInvariant(_))));
}
#[test]
fn test_ohlcv_bar_validate_low_greater_than_open_fails() {
let bar = make_bar("80", "110", "90", "105");
assert!(matches!(bar.validate(), Err(FinError::BarInvariant(_))));
}
#[test]
fn test_ohlcv_bar_validate_high_less_than_open_fails() {
let bar = make_bar("115", "110", "90", "105");
assert!(matches!(bar.validate(), Err(FinError::BarInvariant(_))));
}
#[test]
fn test_ohlcv_bar_typical_price() {
let bar = make_bar("100", "120", "80", "110");
let expected = dec!(310) / Decimal::from(3u32);
assert_eq!(bar.typical_price(), expected);
}
#[test]
fn test_ohlcv_bar_range() {
let bar = make_bar("100", "120", "80", "110");
assert_eq!(bar.range(), dec!(40));
}
#[test]
fn test_ohlcv_bar_is_bullish_true() {
let bar = make_bar("100", "110", "95", "105");
assert!(bar.is_bullish());
}
#[test]
fn test_ohlcv_bar_is_bullish_false() {
let bar = make_bar("105", "110", "95", "100");
assert!(!bar.is_bullish());
}
#[test]
fn test_ohlcv_bar_midpoint() {
let bar = make_bar("100", "120", "80", "110");
assert_eq!(bar.midpoint(), dec!(100)); }
#[test]
fn test_ohlcv_bar_body_size_bullish() {
let bar = make_bar("100", "120", "80", "110");
assert_eq!(bar.body_size(), dec!(10)); }
#[test]
fn test_ohlcv_bar_body_size_bearish() {
let bar = make_bar("110", "120", "80", "100");
assert_eq!(bar.body_size(), dec!(10)); }
#[test]
fn test_ohlcv_bar_is_long_candle_flat() {
let bar = make_bar("100", "100", "100", "100");
assert!(!bar.is_long_candle(dec!(0.7)));
}
#[test]
fn test_ohlcv_bar_is_long_candle_true() {
let bar = make_bar("100", "112", "98", "110");
assert!(bar.is_long_candle(dec!(0.7)));
}
#[test]
fn test_ohlcv_bar_is_long_candle_false() {
let bar = make_bar("100", "110", "90", "101");
assert!(!bar.is_long_candle(dec!(0.7)));
}
#[test]
fn test_ohlcv_bar_is_doji_flat_range() {
let bar = make_bar("100", "100", "100", "100");
assert!(bar.is_doji(dec!(0.1)));
assert!(!bar.is_doji(dec!(0)));
}
#[test]
fn test_ohlcv_bar_is_doji_small_body() {
let bar = make_bar("100", "110", "90", "101");
assert!(bar.is_doji(dec!(0.1)));
assert!(!bar.is_doji(dec!(0.04)));
}
#[test]
fn test_ohlcv_bar_partial_eq() {
let a = make_bar("100", "110", "90", "105");
let b = make_bar("100", "110", "90", "105");
assert_eq!(a, b);
let c = make_bar("100", "110", "90", "106");
assert_ne!(a, c);
}
#[test]
fn test_timeframe_seconds_to_nanos() {
let tf = Timeframe::Seconds(5);
assert_eq!(tf.to_nanos().unwrap(), 5_000_000_000);
}
#[test]
fn test_timeframe_minutes_to_nanos() {
let tf = Timeframe::Minutes(1);
assert_eq!(tf.to_nanos().unwrap(), 60_000_000_000);
}
#[test]
fn test_timeframe_zero_seconds_fails() {
let tf = Timeframe::Seconds(0);
assert!(matches!(tf.to_nanos(), Err(FinError::InvalidTimeframe)));
}
#[test]
fn test_timeframe_weeks_to_nanos() {
let tf = Timeframe::Weeks(1);
assert_eq!(tf.to_nanos().unwrap(), 7 * 86_400 * 1_000_000_000_i64);
}
#[test]
fn test_timeframe_bucket_start() {
let tf = Timeframe::Seconds(60);
let nanos_per_min = 60_000_000_000_i64;
let ts = NanoTimestamp::new(nanos_per_min + 500_000_000);
let bucket = tf.bucket_start(ts).unwrap();
assert_eq!(bucket.nanos(), nanos_per_min);
}
#[test]
fn test_ohlcv_aggregator_new_invalid_timeframe_fails() {
let sym = Symbol::new("X").unwrap();
let result = OhlcvAggregator::new(sym, Timeframe::Seconds(0));
assert!(matches!(result, Err(FinError::InvalidTimeframe)));
}
#[test]
fn test_ohlcv_aggregator_completes_bar_on_boundary() {
let sym = Symbol::new("X").unwrap();
let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
let nanos_per_min = 60_000_000_000_i64;
let t1 = make_tick("X", "100", "1", 0);
let t2 = make_tick("X", "105", "2", nanos_per_min / 2);
let t3 = make_tick("X", "110", "1", nanos_per_min + 1);
let r1 = agg.push_tick(&t1).unwrap();
assert!(r1.is_empty());
let r2 = agg.push_tick(&t2).unwrap();
assert!(r2.is_empty());
let r3 = agg.push_tick(&t3).unwrap();
assert_eq!(r3.len(), 1);
let bar = &r3[0];
assert_eq!(bar.open.value(), dec!(100));
assert_eq!(bar.high.value(), dec!(105));
assert_eq!(bar.close.value(), dec!(105));
assert_eq!(bar.tick_count, 2);
}
#[test]
fn test_ohlcv_aggregator_gap_fills_empty_buckets() {
let sym = Symbol::new("X").unwrap();
let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
let nanos_per_min = 60_000_000_000_i64;
agg.push_tick(&make_tick("X", "100", "1", 0)).unwrap();
let out = agg
.push_tick(&make_tick("X", "200", "1", 3 * nanos_per_min + 1))
.unwrap();
assert_eq!(out.len(), 3, "expected 1 completed + 2 gap bars, got {}", out.len());
assert_eq!(out[0].tick_count, 1);
assert_eq!(out[1].tick_count, 0);
assert_eq!(out[1].volume.value(), dec!(0));
assert_eq!(out[2].tick_count, 0);
assert_eq!(out[1].close, out[0].close);
}
#[test]
fn test_ohlcv_aggregator_flush_returns_partial() {
let sym = Symbol::new("X").unwrap();
let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
let t1 = make_tick("X", "100", "1", 0);
agg.push_tick(&t1).unwrap();
let bar = agg.flush().unwrap();
assert_eq!(bar.open.value(), dec!(100));
assert!(agg.flush().is_none());
}
#[test]
fn test_ohlcv_aggregator_symbol_getter() {
let sym = Symbol::new("BTC").unwrap();
let agg = OhlcvAggregator::new(sym.clone(), Timeframe::Seconds(60)).unwrap();
assert_eq!(agg.symbol(), &sym);
}
#[test]
fn test_ohlcv_aggregator_ignores_different_symbol() {
let sym = Symbol::new("X").unwrap();
let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(60)).unwrap();
let t = make_tick("Y", "100", "1", 0);
let result = agg.push_tick(&t).unwrap();
assert!(result.is_empty());
assert!(agg.current_bar().is_none());
}
#[test]
fn test_ohlcv_series_push_valid() {
let mut series = OhlcvSeries::new();
let bar = make_bar("100", "110", "90", "105");
assert!(series.push(bar).is_ok());
assert_eq!(series.len(), 1);
}
#[test]
fn test_ohlcv_series_push_invalid_fails() {
let mut series = OhlcvSeries::new();
let bar = make_bar("100", "95", "90", "105");
assert!(matches!(series.push(bar), Err(FinError::BarInvariant(_))));
}
#[test]
fn test_ohlcv_series_window_returns_last_n() {
let mut series = OhlcvSeries::new();
for i in 1u32..=5 {
let p = format!("{}", 100 + i);
let h = format!("{}", 110 + i);
let l = format!("{}", 90 + i);
let c = format!("{}", 105 + i);
series.push(make_bar(&p, &h, &l, &c)).unwrap();
}
let w = series.window(3);
assert_eq!(w.len(), 3);
assert_eq!(w[0].open.value(), dec!(103));
}
#[test]
fn test_ohlcv_series_window_larger_than_len() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
let w = series.window(10);
assert_eq!(w.len(), 1);
}
#[test]
fn test_ohlcv_series_opens() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
series.push(make_bar("105", "115", "95", "110")).unwrap();
assert_eq!(series.opens(), vec![dec!(100), dec!(105)]);
}
#[test]
fn test_ohlcv_series_highs() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
series.push(make_bar("105", "115", "95", "110")).unwrap();
assert_eq!(series.highs(), vec![dec!(110), dec!(115)]);
}
#[test]
fn test_ohlcv_series_lows() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
series.push(make_bar("105", "115", "95", "110")).unwrap();
assert_eq!(series.lows(), vec![dec!(90), dec!(95)]);
}
#[test]
fn test_ohlcv_series_closes() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
series.push(make_bar("105", "115", "95", "110")).unwrap();
let closes = series.closes();
assert_eq!(closes, vec![dec!(105), dec!(110)]);
}
#[test]
fn test_ohlcv_series_is_empty() {
let series = OhlcvSeries::new();
assert!(series.is_empty());
}
#[test]
fn test_ohlcv_series_into_iterator() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
series.push(make_bar("105", "115", "95", "110")).unwrap();
let count = (&series).into_iter().count();
assert_eq!(count, 2);
}
#[test]
fn test_ohlcv_series_iter() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
let bar = series.iter().next().unwrap();
assert_eq!(bar.open.value(), dec!(100));
}
#[test]
fn test_ohlcv_bar_upper_shadow() {
let b = make_bar("100", "115", "90", "108");
assert_eq!(b.upper_shadow(), dec!(7));
}
#[test]
fn test_ohlcv_bar_lower_shadow() {
let b = make_bar("100", "115", "90", "108");
assert_eq!(b.lower_shadow(), dec!(10));
}
#[test]
fn test_ohlcv_bar_from_tick() {
let tick = make_tick("AAPL", "150", "5", 1_000);
let bar = OhlcvBar::from_tick(&tick);
assert_eq!(bar.open.value(), dec!(150));
assert_eq!(bar.high.value(), dec!(150));
assert_eq!(bar.low.value(), dec!(150));
assert_eq!(bar.close.value(), dec!(150));
assert_eq!(bar.volume.value(), dec!(5));
assert_eq!(bar.tick_count, 1);
assert_eq!(bar.ts_open.nanos(), 1_000);
}
#[test]
fn test_ohlcv_series_bars_slice() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
series.push(make_bar("105", "115", "95", "110")).unwrap();
assert_eq!(series.bars().len(), 2);
}
#[test]
fn test_ohlcv_series_max_high_min_low() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
series.push(make_bar("105", "120", "85", "110")).unwrap();
assert_eq!(series.max_high().unwrap(), dec!(120));
assert_eq!(series.min_low().unwrap(), dec!(85));
}
#[test]
fn test_ohlcv_series_max_high_empty() {
let series = OhlcvSeries::new();
assert!(series.max_high().is_none());
assert!(series.min_low().is_none());
}
#[test]
fn test_ohlcv_series_slice() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
series.push(make_bar("105", "115", "95", "110")).unwrap();
series.push(make_bar("110", "120", "100", "115")).unwrap();
let s = series.slice(1, 3).unwrap();
assert_eq!(s.len(), 2);
assert_eq!(s[0].open.value(), dec!(105));
}
#[test]
fn test_ohlcv_series_slice_out_of_bounds() {
let series = OhlcvSeries::new();
assert!(series.slice(0, 1).is_none());
}
#[test]
fn test_ohlcv_series_truncate_keeps_last_n() {
let mut series = OhlcvSeries::new();
for _ in 0..5 {
series.push(make_bar("100", "110", "90", "105")).unwrap();
}
series.truncate(3);
assert_eq!(series.len(), 3);
}
#[test]
fn test_ohlcv_series_truncate_noop_when_n_ge_len() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
series.push(make_bar("105", "115", "95", "110")).unwrap();
series.truncate(5);
assert_eq!(series.len(), 2);
}
#[test]
fn test_ohlcv_series_truncate_to_zero() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
series.push(make_bar("105", "115", "95", "110")).unwrap();
series.truncate(0);
assert!(series.is_empty());
}
#[test]
fn test_ohlcv_bar_serde_roundtrip() {
let bar = make_bar("100", "110", "90", "105");
let json = serde_json::to_string(&bar).unwrap();
let back: OhlcvBar = serde_json::from_str(&json).unwrap();
assert_eq!(back.open, bar.open);
assert_eq!(back.high, bar.high);
assert_eq!(back.low, bar.low);
assert_eq!(back.close, bar.close);
assert_eq!(back.tick_count, bar.tick_count);
}
#[test]
fn test_ohlcv_bar_duration_nanos() {
let mut bar = make_bar("100", "110", "90", "105");
bar.ts_open = NanoTimestamp::new(1_000_000_000);
bar.ts_close = NanoTimestamp::new(1_060_000_000_000);
assert_eq!(bar.duration_nanos(), 1_059_000_000_000);
}
#[test]
fn test_ohlcv_bar_duration_nanos_same_timestamps() {
let mut bar = make_bar("100", "110", "90", "100");
bar.ts_open = NanoTimestamp::new(5_000);
bar.ts_close = NanoTimestamp::new(5_000);
assert_eq!(bar.duration_nanos(), 0);
}
#[test]
fn test_ohlcv_series_extend_valid() {
let mut series = OhlcvSeries::new();
let bars = vec![
make_bar("100", "110", "90", "105"),
make_bar("105", "115", "95", "110"),
];
series.extend(bars).unwrap();
assert_eq!(series.len(), 2);
}
#[test]
fn test_ohlcv_series_extend_stops_on_invalid_bar() {
let mut series = OhlcvSeries::new();
let valid = make_bar("100", "110", "90", "105");
let mut invalid = make_bar("100", "110", "90", "105");
invalid.high = make_price("80");
invalid.low = make_price("110");
let result = series.extend([valid, invalid]);
assert!(result.is_err());
assert_eq!(series.len(), 1, "valid bar added before error");
}
#[test]
fn test_ohlcv_bar_to_bar_input_fields_match() {
let bar = make_bar("100", "110", "90", "105");
let input = bar.to_bar_input();
assert_eq!(input.open, bar.open.value());
assert_eq!(input.high, bar.high.value());
assert_eq!(input.low, bar.low.value());
assert_eq!(input.close, bar.close.value());
assert_eq!(input.volume, bar.volume.value());
}
#[test]
fn test_ohlcv_series_retain_removes_gap_fills() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
let mut gap = make_bar("105", "105", "105", "105");
gap.tick_count = 0;
series.push(gap).unwrap();
series.push(make_bar("105", "115", "95", "110")).unwrap();
series.retain(|b| !b.is_gap_fill());
assert_eq!(series.len(), 2);
}
#[test]
fn test_ohlcv_series_retain_keeps_all() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
series.push(make_bar("105", "115", "95", "110")).unwrap();
series.retain(|_| true);
assert_eq!(series.len(), 2);
}
#[test]
fn test_ohlcv_bar_is_bearish() {
let bar = make_bar("110", "115", "95", "100");
assert!(bar.is_bearish());
assert!(!bar.is_bullish());
}
#[test]
fn test_ohlcv_bar_is_hammer() {
let not_hammer = make_bar("100", "111", "80", "105");
assert!(!not_hammer.is_hammer());
let hammer = make_bar("95", "100", "75", "100");
assert!(hammer.is_hammer());
}
#[test]
fn test_ohlcv_bar_is_shooting_star() {
let star = make_bar("100", "125", "100", "105");
assert!(star.is_shooting_star());
let not_star = make_bar("100", "110", "80", "105");
assert!(!not_star.is_shooting_star());
}
#[test]
fn test_ohlcv_bar_bar_return_positive() {
let bar = make_bar("100", "110", "90", "110");
assert_eq!(bar.bar_return().unwrap(), dec!(10));
}
#[test]
fn test_ohlcv_bar_bar_return_negative() {
let bar = make_bar("100", "105", "85", "90");
assert_eq!(bar.bar_return().unwrap(), dec!(-10));
}
#[test]
fn test_ohlcv_series_highest_high() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "150", "90", "105")).unwrap();
series.push(make_bar("105", "130", "95", "110")).unwrap();
series.push(make_bar("110", "120", "100", "115")).unwrap();
assert_eq!(series.highest_high(2).unwrap(), dec!(130));
assert_eq!(series.highest_high(10).unwrap(), dec!(150));
}
#[test]
fn test_ohlcv_series_lowest_low() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "70", "105")).unwrap();
series.push(make_bar("105", "115", "85", "110")).unwrap();
series.push(make_bar("110", "120", "90", "115")).unwrap();
assert_eq!(series.lowest_low(2).unwrap(), dec!(85));
assert_eq!(series.lowest_low(10).unwrap(), dec!(70));
}
#[test]
fn test_ohlcv_series_extend_from_series() {
let mut a = OhlcvSeries::new();
a.push(make_bar("100", "110", "90", "105")).unwrap();
let mut b = OhlcvSeries::new();
b.push(make_bar("105", "115", "95", "110")).unwrap();
b.push(make_bar("110", "120", "100", "115")).unwrap();
a.extend_from_series(&b).unwrap();
assert_eq!(a.len(), 3);
}
#[test]
fn test_ohlcv_aggregator_bar_count() {
let sym = Symbol::new("AAPL").unwrap();
let mut agg = OhlcvAggregator::new(sym, Timeframe::Seconds(1)).unwrap();
assert_eq!(agg.bar_count(), 0);
agg.push_tick(&make_tick("AAPL", "100", "1", 0)).unwrap();
agg.push_tick(&make_tick("AAPL", "101", "1", 2_000_000_000))
.unwrap();
assert_eq!(agg.bar_count(), 2);
agg.flush();
assert_eq!(agg.bar_count(), 3);
agg.reset();
assert_eq!(agg.bar_count(), 0);
}
#[test]
fn test_ohlcv_series_vwap_empty_returns_none() {
assert!(OhlcvSeries::new().vwap().is_none());
}
#[test]
fn test_ohlcv_series_vwap_zero_volume_returns_none() {
let mut series = OhlcvSeries::new();
let mut bar = make_bar("100", "110", "90", "100");
bar.volume = Quantity::zero();
series.push(bar).unwrap();
assert!(series.vwap().is_none());
}
#[test]
fn test_ohlcv_series_vwap_constant_price() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "100", "100", "100")).unwrap();
series.push(make_bar("100", "100", "100", "100")).unwrap();
assert_eq!(series.vwap().unwrap(), dec!(100));
}
#[test]
fn test_ohlcv_series_sum_volume_empty() {
assert_eq!(OhlcvSeries::new().sum_volume(), dec!(0));
}
#[test]
fn test_ohlcv_series_sum_volume_multiple_bars() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
series.push(make_bar("105", "115", "95", "110")).unwrap();
series.push(make_bar("110", "120", "100", "115")).unwrap();
assert_eq!(series.sum_volume(), dec!(300));
}
#[test]
fn test_ohlcv_series_avg_volume_none_when_empty() {
assert!(OhlcvSeries::new().avg_volume(3).is_none());
}
#[test]
fn test_ohlcv_series_avg_volume_none_when_n_zero() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
assert!(series.avg_volume(0).is_none());
}
#[test]
fn test_ohlcv_series_avg_volume_correct() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
series.push(make_bar("105", "115", "95", "110")).unwrap();
series.push(make_bar("110", "120", "100", "115")).unwrap();
assert_eq!(series.avg_volume(3).unwrap(), dec!(100));
}
#[test]
fn test_ohlcv_series_avg_volume_partial_window() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
series.push(make_bar("105", "115", "95", "110")).unwrap();
assert!(series.avg_volume(5).is_none());
}
#[test]
fn test_ohlcv_series_price_range_none_when_insufficient() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
assert!(series.price_range(0).is_none());
assert!(series.price_range(2).is_none());
}
#[test]
fn test_ohlcv_series_price_range_correct() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "100")).unwrap();
series.push(make_bar("100", "120", "80", "100")).unwrap();
assert_eq!(series.price_range(2).unwrap(), dec!(40));
}
#[test]
fn test_ohlcv_series_above_ema_false_when_insufficient() {
assert!(!OhlcvSeries::new().above_ema(3));
}
#[test]
fn test_ohlcv_series_above_ema_rising_close() {
let mut series = OhlcvSeries::new();
for c in ["100", "100", "100", "100", "200"] {
series.push(make_bar(c, "210", "90", c)).unwrap();
}
assert!(series.above_ema(3));
}
#[test]
fn test_ohlcv_series_bullish_engulfing_count_zero_when_short() {
assert_eq!(OhlcvSeries::new().bullish_engulfing_count(5), 0);
}
#[test]
fn test_ohlcv_series_bullish_engulfing_count_detects_pattern() {
let mut series = OhlcvSeries::new();
series.push(make_bar("105", "110", "90", "95")).unwrap();
series.push(make_bar("90", "120", "88", "110")).unwrap();
assert_eq!(series.bullish_engulfing_count(2), 1);
}
#[test]
fn test_ohlcv_series_range_expansion_none_when_insufficient() {
assert!(OhlcvSeries::new().range_expansion(3).is_none());
}
#[test]
fn test_ohlcv_series_range_expansion_constant_returns_one() {
let mut series = OhlcvSeries::new();
for _ in 0..5 {
series.push(make_bar("100", "110", "90", "100")).unwrap();
}
assert_eq!(series.range_expansion(5).unwrap(), dec!(1));
}
#[test]
fn test_ohlcv_series_bearish_engulfing_count_zero_when_short() {
assert_eq!(OhlcvSeries::new().bearish_engulfing_count(5), 0);
}
#[test]
fn test_ohlcv_series_bearish_engulfing_count_detects_pattern() {
let mut series = OhlcvSeries::new();
series.push(make_bar("95", "110", "90", "105")).unwrap();
series.push(make_bar("110", "115", "88", "90")).unwrap();
assert_eq!(series.bearish_engulfing_count(2), 1);
}
#[test]
fn test_ohlcv_series_trend_strength_none_when_insufficient() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "100")).unwrap();
assert!(series.trend_strength(2).is_none());
}
#[test]
fn test_ohlcv_series_trend_strength_pure_trend_is_one() {
let mut series = OhlcvSeries::new();
for c in ["100", "110", "120", "130"] {
series.push(make_bar(c, "135", "95", c)).unwrap();
}
assert_eq!(series.trend_strength(4).unwrap(), dec!(1));
}
#[test]
fn test_ohlcv_series_close_location_value_none_when_insufficient() {
assert!(OhlcvSeries::new().close_location_value(1).is_none());
}
#[test]
fn test_ohlcv_series_close_location_value_close_at_high() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "110")).unwrap();
assert_eq!(series.close_location_value(1).unwrap(), dec!(1));
}
#[test]
fn test_ohlcv_series_close_location_value_close_at_midpoint() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "100")).unwrap();
assert_eq!(series.close_location_value(1).unwrap(), dec!(0));
}
#[test]
fn test_ohlcv_series_mean_close_empty_returns_none() {
assert!(OhlcvSeries::new().mean_close(5).is_none());
}
#[test]
fn test_ohlcv_series_mean_close_equal_prices() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "100")).unwrap();
series.push(make_bar("100", "110", "90", "100")).unwrap();
series.push(make_bar("100", "110", "90", "100")).unwrap();
assert_eq!(series.mean_close(3).unwrap(), dec!(100));
}
#[test]
fn test_ohlcv_series_mean_close_windowed() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "100", "100", "100")).unwrap();
series.push(make_bar("110", "110", "110", "110")).unwrap();
series.push(make_bar("120", "120", "120", "120")).unwrap();
assert_eq!(series.mean_close(2).unwrap(), dec!(115));
}
#[test]
fn test_ohlcv_series_std_dev_less_than_two_bars_returns_none() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "100")).unwrap();
assert!(series.std_dev(5).is_none());
}
#[test]
fn test_ohlcv_series_std_dev_constant_prices_is_zero() {
let mut series = OhlcvSeries::new();
for _ in 0..4 {
series.push(make_bar("100", "100", "100", "100")).unwrap();
}
assert_eq!(series.std_dev(4).unwrap(), dec!(0));
}
#[test]
fn test_ohlcv_bar_gap_pct_upward_gap() {
let prev = make_bar("100", "110", "90", "100");
let curr = make_bar("110", "120", "105", "115");
assert_eq!(curr.gap_pct(&prev).unwrap(), dec!(10));
}
#[test]
fn test_ohlcv_bar_gap_pct_downward_gap() {
let prev = make_bar("100", "110", "90", "100");
let curr = make_bar("90", "95", "85", "92");
assert_eq!(curr.gap_pct(&prev).unwrap(), dec!(-10));
}
#[test]
fn test_ohlcv_bar_gap_pct_no_gap() {
let prev = make_bar("100", "110", "90", "100");
let curr = make_bar("100", "110", "90", "105");
assert_eq!(curr.gap_pct(&prev).unwrap(), dec!(0));
}
#[test]
fn test_ohlcv_series_n_bars_ago_returns_correct_bar() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
series.push(make_bar("105", "115", "95", "110")).unwrap();
series.push(make_bar("110", "120", "100", "115")).unwrap();
assert_eq!(series.n_bars_ago(0).unwrap().close.value(), dec!(115));
assert_eq!(series.n_bars_ago(1).unwrap().close.value(), dec!(110));
assert_eq!(series.n_bars_ago(2).unwrap().close.value(), dec!(105));
}
#[test]
fn test_ohlcv_series_n_bars_ago_out_of_bounds() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
assert!(series.n_bars_ago(1).is_none());
assert!(OhlcvSeries::new().n_bars_ago(0).is_none());
}
#[test]
fn test_ohlcv_bar_is_outside_bar_true() {
let prev = make_bar("100", "110", "90", "105");
let outside = make_bar("100", "120", "80", "110");
assert!(outside.is_outside_bar(&prev));
}
#[test]
fn test_ohlcv_bar_is_outside_bar_false_for_inside() {
let prev = make_bar("100", "120", "80", "110");
let inside = make_bar("100", "110", "90", "105");
assert!(!inside.is_outside_bar(&prev));
}
#[test]
fn test_ohlcv_bar_is_outside_bar_false_partial() {
let prev = make_bar("100", "110", "90", "105");
let partial = make_bar("100", "115", "92", "110");
assert!(!partial.is_outside_bar(&prev));
}
#[test]
fn test_ohlcv_series_from_bars_valid() {
let bars = vec![
make_bar("100", "110", "90", "105"),
make_bar("105", "115", "95", "110"),
];
let series = OhlcvSeries::from_bars(bars).unwrap();
assert_eq!(series.len(), 2);
}
#[test]
fn test_ohlcv_series_from_bars_empty() {
let series = OhlcvSeries::from_bars(vec![]).unwrap();
assert!(series.is_empty());
}
#[test]
fn test_ohlcv_series_count_bullish() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap(); series.push(make_bar("105", "115", "95", "100")).unwrap(); series.push(make_bar("100", "110", "90", "108")).unwrap(); assert_eq!(series.count_bullish(3), 2);
assert_eq!(series.count_bullish(1), 1); }
#[test]
fn test_ohlcv_series_count_bearish() {
let mut series = OhlcvSeries::new();
series.push(make_bar("110", "115", "90", "100")).unwrap(); series.push(make_bar("105", "115", "95", "110")).unwrap(); assert_eq!(series.count_bearish(2), 1);
assert_eq!(series.count_bearish(1), 0); }
#[test]
fn test_ohlcv_series_count_bullish_exceeds_len() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
assert_eq!(series.count_bullish(100), 1);
}
#[test]
fn test_ohlcv_series_median_close_empty() {
assert!(OhlcvSeries::new().median_close(5).is_none());
}
#[test]
fn test_ohlcv_series_median_close_odd_count() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "100", "100", "100")).unwrap();
series.push(make_bar("110", "110", "110", "110")).unwrap();
series.push(make_bar("120", "120", "120", "120")).unwrap();
assert_eq!(series.median_close(3).unwrap(), dec!(110));
}
#[test]
fn test_ohlcv_series_median_close_even_count() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "100", "100", "100")).unwrap();
series.push(make_bar("110", "110", "110", "110")).unwrap();
assert_eq!(series.median_close(2).unwrap(), dec!(105));
}
#[test]
fn test_ohlcv_series_percentile_rank_empty() {
assert!(OhlcvSeries::new().percentile_rank(dec!(100), 5).is_none());
}
#[test]
fn test_ohlcv_series_percentile_rank_above_all() {
let mut series = OhlcvSeries::new();
for _ in 0..4 {
series.push(make_bar("100", "100", "100", "100")).unwrap();
}
assert_eq!(series.percentile_rank(dec!(101), 4).unwrap(), dec!(100));
}
#[test]
fn test_ohlcv_series_percentile_rank_below_all() {
let mut series = OhlcvSeries::new();
for _ in 0..4 {
series.push(make_bar("100", "100", "100", "100")).unwrap();
}
assert_eq!(series.percentile_rank(dec!(99), 4).unwrap(), dec!(0));
}
#[test]
fn test_ohlcv_series_consecutive_ups_empty() {
assert_eq!(OhlcvSeries::new().consecutive_ups(), 0);
}
#[test]
fn test_ohlcv_series_consecutive_ups_all_bullish() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap(); series.push(make_bar("105", "115", "95", "110")).unwrap(); assert_eq!(series.consecutive_ups(), 2);
}
#[test]
fn test_ohlcv_series_consecutive_ups_broken_by_bearish() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap(); series.push(make_bar("110", "115", "95", "108")).unwrap(); series.push(make_bar("108", "115", "100", "112")).unwrap(); assert_eq!(series.consecutive_ups(), 1);
}
#[test]
fn test_ohlcv_series_consecutive_downs_counts_bearish_tail() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap(); series.push(make_bar("105", "110", "90", "100")).unwrap(); series.push(make_bar("100", "105", "85", "95")).unwrap(); assert_eq!(series.consecutive_downs(), 2);
assert_eq!(series.consecutive_ups(), 0);
}
#[test]
fn test_ohlcv_bar_is_marubozu_full_body() {
let bar = make_bar("100", "110", "100", "110");
assert!(bar.is_marubozu());
}
#[test]
fn test_ohlcv_bar_is_marubozu_false_with_shadows() {
let bar = make_bar("100", "115", "95", "110");
assert!(!bar.is_marubozu());
}
#[test]
fn test_ohlcv_bar_is_spinning_top_true() {
let bar = make_bar("100", "120", "80", "102");
assert!(bar.is_spinning_top());
}
#[test]
fn test_ohlcv_bar_is_spinning_top_false_large_body() {
let bar = make_bar("100", "115", "95", "114");
assert!(!bar.is_spinning_top());
}
#[test]
fn test_ohlcv_series_average_volume_all_same() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
series.push(make_bar("105", "115", "95", "110")).unwrap();
assert_eq!(series.average_volume(2).unwrap(), dec!(100));
}
#[test]
fn test_ohlcv_series_average_range() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "120", "80", "110")).unwrap(); series.push(make_bar("110", "125", "100", "115")).unwrap(); assert_eq!(series.average_range(2).unwrap(), dec!(32.5));
}
#[test]
fn test_ohlcv_series_average_volume_empty_returns_none() {
let series = OhlcvSeries::new();
assert!(series.average_volume(5).is_none());
}
#[test]
fn test_ohlcv_series_typical_price_mean_single_bar() {
let mut series = OhlcvSeries::new();
let bar = make_bar("100", "120", "80", "110");
series.push(bar).unwrap();
let tp = series.typical_price_mean(1).unwrap();
let expected = (dec!(120) + dec!(80) + dec!(110)) / dec!(3);
assert_eq!(tp, expected);
}
#[test]
fn test_ohlcv_series_below_sma_zero_when_all_above() {
let mut series = OhlcvSeries::new();
for _ in 0..3 { series.push(make_bar("100", "110", "90", "100")).unwrap(); }
assert_eq!(series.below_sma(3, 3), 0);
}
#[test]
fn test_ohlcv_series_sortino_ratio_insufficient_data() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "105")).unwrap();
assert!(series.sortino_ratio(0.0, 252.0).is_none());
}
#[test]
fn test_ohlcv_bar_weighted_close_equals_hlcc4() {
let bar = make_bar("100", "120", "80", "110");
assert_eq!(bar.weighted_close(), bar.hlcc4());
}
#[test]
fn test_ohlcv_bar_weighted_close_value() {
let bar = make_bar("100", "120", "80", "110");
assert_eq!(bar.weighted_close(), dec!(105));
}
#[test]
fn test_close_above_open_streak_three_bullish() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "95")).unwrap(); series.push(make_bar("95", "110", "90", "105")).unwrap(); series.push(make_bar("105", "115", "100", "112")).unwrap(); series.push(make_bar("112", "120", "108", "118")).unwrap(); assert_eq!(series.close_above_open_streak(), 3);
}
#[test]
fn test_close_above_open_streak_last_bearish_returns_zero() {
let mut series = OhlcvSeries::new();
series.push(make_bar("105", "110", "100", "102")).unwrap(); series.push(make_bar("102", "108", "98", "99")).unwrap(); assert_eq!(series.close_above_open_streak(), 0);
}
#[test]
fn test_close_above_open_streak_empty_series_returns_zero() {
assert_eq!(OhlcvSeries::new().close_above_open_streak(), 0);
}
#[test]
fn test_max_drawdown_pct_declining_series() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "100")).unwrap();
series.push(make_bar("100", "105", "75", "80")).unwrap(); series.push(make_bar("80", "85", "75", "84")).unwrap();
let dd = series.max_drawdown_pct(10).unwrap();
assert!((dd - 20.0).abs() < 1e-6, "expected ~20, got {dd}");
}
#[test]
fn test_max_drawdown_pct_flat_returns_zero() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "100")).unwrap();
series.push(make_bar("100", "110", "90", "100")).unwrap();
assert_eq!(series.max_drawdown_pct(10).unwrap(), 0.0);
}
#[test]
fn test_max_drawdown_pct_single_bar_returns_none() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "100")).unwrap();
assert!(series.max_drawdown_pct(10).is_none());
}
#[test]
fn test_ohlcv_bar_gap_up_from_prev() {
let prev = make_bar("100", "105", "95", "103");
let curr = make_bar("107", "115", "106", "112"); assert!(curr.gap_up_from(&prev));
}
#[test]
fn test_ohlcv_bar_no_gap_up() {
let prev = make_bar("100", "110", "90", "105");
let curr = make_bar("105", "112", "104", "108"); assert!(!curr.gap_up_from(&prev));
}
#[test]
fn test_ohlcv_bar_gap_down_from_prev() {
let prev = make_bar("100", "105", "95", "97");
let curr = make_bar("93", "94", "88", "90"); assert!(curr.gap_down_from(&prev));
}
#[test]
fn test_ohlcv_bar_no_gap_down() {
let prev = make_bar("100", "110", "90", "95");
let curr = make_bar("96", "100", "92", "98"); assert!(!curr.gap_down_from(&prev));
}
#[test]
fn test_ohlcv_series_last_n_closes_returns_n() {
let mut series = OhlcvSeries::new();
for close in &["100", "102", "104", "106", "108"] {
series.push(make_bar(close, "115", "95", close)).unwrap();
}
let closes = series.last_n_closes(3);
assert_eq!(closes.len(), 3);
assert_eq!(closes[2], dec!(108));
}
#[test]
fn test_ohlcv_series_last_n_closes_fewer_than_n() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "100")).unwrap();
let closes = series.last_n_closes(5);
assert_eq!(closes.len(), 1);
}
#[test]
fn test_ohlcv_series_volume_spike_detects_spike() {
use crate::types::{NanoTimestamp, Quantity, Symbol};
let sym = Symbol::new("X").unwrap();
let p = crate::types::Price::new(dec!(100)).unwrap();
let mut series = OhlcvSeries::new();
for _ in 0..3 {
series.push(OhlcvBar {
symbol: sym.clone(), open: p, high: p, low: p, close: p,
volume: Quantity::new(dec!(100)).unwrap(),
ts_open: NanoTimestamp::new(0), ts_close: NanoTimestamp::new(1), tick_count: 1,
}).unwrap();
}
series.push(OhlcvBar {
symbol: sym.clone(), open: p, high: p, low: p, close: p,
volume: Quantity::new(dec!(500)).unwrap(),
ts_open: NanoTimestamp::new(2), ts_close: NanoTimestamp::new(3), tick_count: 1,
}).unwrap();
assert!(series.volume_spike(3, dec!(3)));
}
#[test]
fn test_ohlcv_series_volume_spike_false_for_normal_volume() {
use crate::types::{NanoTimestamp, Quantity, Symbol};
let sym = Symbol::new("X").unwrap();
let p = crate::types::Price::new(dec!(100)).unwrap();
let mut series = OhlcvSeries::new();
for _ in 0..4 {
series.push(OhlcvBar {
symbol: sym.clone(), open: p, high: p, low: p, close: p,
volume: Quantity::new(dec!(100)).unwrap(),
ts_open: NanoTimestamp::new(0), ts_close: NanoTimestamp::new(1), tick_count: 1,
}).unwrap();
}
assert!(!series.volume_spike(3, dec!(3)));
}
#[test]
fn test_efficiency_ratio_trending() {
let mut series = OhlcvSeries::new();
for i in 0..6u32 {
series.push(make_bar(&format!("{}", 100 + i), &format!("{}", 105 + i), &format!("{}", 99 + i), &format!("{}", 100 + i))).unwrap();
}
let er = series.efficiency_ratio(5).unwrap();
assert_eq!(er, dec!(1));
}
#[test]
fn test_efficiency_ratio_none_when_not_enough_bars() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "110", "90", "100")).unwrap();
assert!(series.efficiency_ratio(5).is_none());
}
#[test]
fn test_efficiency_ratio_zero_period_returns_none() {
let series = OhlcvSeries::new();
assert!(series.efficiency_ratio(0).is_none());
}
#[test]
fn test_body_pct_series_full_body() {
let mut series = OhlcvSeries::new();
series.push(make_bar("90", "110", "90", "110")).unwrap();
let v = series.body_pct_series(1);
assert_eq!(v.len(), 1);
assert_eq!(v[0], Some(dec!(100)));
}
#[test]
fn test_body_pct_series_zero_range_returns_none() {
let mut series = OhlcvSeries::new();
series.push(make_bar("100", "100", "100", "100")).unwrap();
let v = series.body_pct_series(1);
assert_eq!(v[0], None);
}
#[test]
fn test_candle_color_changes_alternating() {
let mut series = OhlcvSeries::new();
series.push(make_bar("95", "110", "90", "105")).unwrap(); series.push(make_bar("105", "115", "100", "102")).unwrap(); series.push(make_bar("102", "115", "98", "110")).unwrap(); assert_eq!(series.candle_color_changes(3), 2);
}
#[test]
fn test_candle_color_changes_no_changes() {
let mut series = OhlcvSeries::new();
for _ in 0..3 {
series.push(make_bar("95", "110", "90", "105")).unwrap();
}
assert_eq!(series.candle_color_changes(3), 0);
}
#[test]
fn test_typical_price_series_values() {
let mut series = OhlcvSeries::new();
series.push(make_bar("95", "110", "90", "100")).unwrap();
let v = series.typical_price_series(1);
assert_eq!(v.len(), 1);
assert_eq!(v[0], dec!(100));
}
#[test]
fn test_typical_price_series_empty_series_returns_empty() {
let series = OhlcvSeries::new();
assert!(series.typical_price_series(3).is_empty());
}
#[test]
fn test_bar_at_index_valid() {
let bars = vec![bar("100"), bar("101"), bar("102")];
let series = OhlcvSeries::from_bars(bars).unwrap();
assert!(series.bar_at_index(0).is_some());
assert_eq!(series.bar_at_index(2).unwrap().close.value(), dec!(102));
}
#[test]
fn test_bar_at_index_out_of_bounds() {
let bars = vec![bar("100")];
let series = OhlcvSeries::from_bars(bars).unwrap();
assert!(series.bar_at_index(5).is_none());
}
#[test]
fn test_rolling_close_std_returns_none_for_fewer_than_two() {
let bars = vec![bar("100")];
let series = OhlcvSeries::from_bars(bars).unwrap();
assert!(series.rolling_close_std(1).is_none());
}
#[test]
fn test_rolling_close_std_constant_prices_is_zero() {
let bars = vec![bar("100"), bar("100"), bar("100")];
let series = OhlcvSeries::from_bars(bars).unwrap();
let std = series.rolling_close_std(3).unwrap();
assert_eq!(std, Decimal::ZERO);
}
#[test]
fn test_rolling_close_std_varying_prices_positive() {
let bars = vec![bar("100"), bar("110"), bar("120"), bar("130")];
let series = OhlcvSeries::from_bars(bars).unwrap();
let std = series.rolling_close_std(4).unwrap();
assert!(std > Decimal::ZERO);
}
#[test]
fn test_gap_direction_series_empty_for_single_bar() {
let bars = vec![bar("100")];
let series = OhlcvSeries::from_bars(bars).unwrap();
assert!(series.gap_direction_series(3).is_empty());
}
#[test]
fn test_gap_direction_series_flat_on_equal_prices() {
let bars = vec![bar("100"), bar("100"), bar("100")];
let series = OhlcvSeries::from_bars(bars).unwrap();
let gaps = series.gap_direction_series(3);
assert!(gaps.iter().all(|&g| g == 0));
}
#[test]
fn test_gap_direction_series_detects_gap_up() {
let p1 = Price::new(dec!(100)).unwrap();
let p2 = Price::new(dec!(110)).unwrap();
let b1 = OhlcvBar {
symbol: Symbol::new("X").unwrap(),
open: p1, high: p1, low: p1, close: p1,
volume: Quantity::zero(),
ts_open: NanoTimestamp::new(0),
ts_close: NanoTimestamp::new(1),
tick_count: 1,
};
let b2 = OhlcvBar {
symbol: Symbol::new("X").unwrap(),
open: p2, high: p2, low: p2, close: p2,
volume: Quantity::zero(),
ts_open: NanoTimestamp::new(2),
ts_close: NanoTimestamp::new(3),
tick_count: 1,
};
let series = OhlcvSeries::from_bars(vec![b1, b2]).unwrap();
let gaps = series.gap_direction_series(2);
assert_eq!(gaps, vec![1i8]);
}
#[test]
fn test_bullish_candle_pct_all_bullish() {
let bars = vec![
make_bar("95", "105", "94", "100"),
make_bar("99", "110", "98", "108"),
make_bar("107", "115", "106", "112"),
];
let series = OhlcvSeries::from_bars(bars).unwrap();
assert_eq!(series.bullish_candle_pct(3).unwrap(), 1.0);
}
#[test]
fn test_bullish_candle_pct_none_for_zero_n() {
let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
assert!(series.bullish_candle_pct(0).is_none());
}
#[test]
fn test_price_above_ma_pct_all_above() {
let bars = vec![
bar("100"), bar("102"), bar("104"), bar("106"), bar("108"),
];
let series = OhlcvSeries::from_bars(bars).unwrap();
let pct = series.price_above_ma_pct(3, 2).unwrap();
assert!(pct > 0.0);
}
#[test]
fn test_price_above_ma_pct_insufficient_bars() {
let series = OhlcvSeries::from_bars(vec![bar("100"), bar("101")]).unwrap();
assert!(series.price_above_ma_pct(2, 3).is_none());
}
#[test]
fn test_avg_body_size_flat() {
let bars = vec![bar("100"), bar("100"), bar("100")];
let series = OhlcvSeries::from_bars(bars).unwrap();
assert_eq!(series.avg_body_size(3).unwrap(), dec!(0));
}
#[test]
fn test_avg_body_size_none_for_zero_n() {
let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
assert!(series.avg_body_size(0).is_none());
}
#[test]
fn test_true_range_series_flat() {
let bars = vec![bar("100"), bar("100"), bar("100")];
let series = OhlcvSeries::from_bars(bars).unwrap();
let trs = series.true_range_series(3).unwrap();
assert_eq!(trs.len(), 3);
for tr in trs {
assert_eq!(tr, dec!(0));
}
}
#[test]
fn test_true_range_series_none_when_insufficient() {
let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
assert!(series.true_range_series(0).is_none());
assert!(series.true_range_series(2).is_none());
}
#[test]
fn test_intraday_return_pct_positive() {
let make_bar = |o: &str, c: &str| {
let op = Price::new(o.parse::<rust_decimal::Decimal>().unwrap()).unwrap();
let cl = Price::new(c.parse::<rust_decimal::Decimal>().unwrap()).unwrap();
OhlcvBar {
symbol: Symbol::new("X").unwrap(),
open: op, high: cl, low: op, close: cl,
volume: Quantity::zero(),
ts_open: NanoTimestamp::new(0),
ts_close: NanoTimestamp::new(1),
tick_count: 1,
}
};
let series = OhlcvSeries::from_bars(vec![make_bar("100", "110")]).unwrap();
assert_eq!(series.intraday_return_pct().unwrap(), dec!(10));
}
#[test]
fn test_intraday_return_pct_empty() {
assert!(OhlcvSeries::new().intraday_return_pct().is_none());
}
#[test]
fn test_bearish_bar_count_all_flat() {
let bars = vec![bar("100"), bar("100"), bar("100")];
let series = OhlcvSeries::from_bars(bars).unwrap();
assert_eq!(series.bearish_bar_count(3).unwrap(), 0);
}
#[test]
fn test_bearish_bar_count_none_insufficient() {
let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
assert!(series.bearish_bar_count(0).is_none());
assert!(series.bearish_bar_count(2).is_none());
}
#[test]
fn test_hl_midpoint_flat() {
let bars = vec![bar("100"), bar("100"), bar("100")];
let series = OhlcvSeries::from_bars(bars).unwrap();
assert_eq!(series.hl_midpoint(3).unwrap(), dec!(100));
}
#[test]
fn test_hl_midpoint_none_when_insufficient() {
let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
assert!(series.hl_midpoint(0).is_none());
assert!(series.hl_midpoint(2).is_none());
}
#[test]
fn test_up_volume_ratio_flat_bars() {
let bars = vec![bar("100"), bar("100"), bar("100")];
let series = OhlcvSeries::from_bars(bars).unwrap();
let ratio = series.up_volume_ratio(3);
if let Some(r) = ratio {
assert_eq!(r, dec!(0));
}
}
#[test]
fn test_price_efficiency_trending() {
let bars: Vec<_> = (100..106u32).map(|i| bar(&i.to_string())).collect();
let series = OhlcvSeries::from_bars(bars).unwrap();
assert_eq!(series.price_efficiency(5).unwrap(), dec!(1));
}
#[test]
fn test_price_efficiency_none_insufficient() {
let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
assert!(series.price_efficiency(1).is_none());
assert!(series.price_efficiency(3).is_none());
}
#[test]
fn test_avg_gap_zero_when_no_jumps() {
let bars = vec![bar("100"), bar("100"), bar("100")];
let series = OhlcvSeries::from_bars(bars).unwrap();
assert_eq!(series.avg_gap(2).unwrap(), dec!(0));
}
#[test]
fn test_avg_gap_none_when_insufficient() {
let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
assert!(series.avg_gap(0).is_none());
assert!(series.avg_gap(1).is_none());
}
#[test]
fn test_largest_gap_pct_no_gap() {
let bars: Vec<_> = (0..5).map(|_| bar("100")).collect();
let series = OhlcvSeries::from_bars(bars).unwrap();
assert_eq!(series.largest_gap_pct(4).unwrap(), dec!(0));
}
#[test]
fn test_largest_gap_pct_none_insufficient() {
let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
assert!(series.largest_gap_pct(2).is_none());
assert!(series.largest_gap_pct(1).is_none());
}
#[test]
fn test_close_momentum_flat_zero() {
let bars: Vec<_> = (0..6).map(|_| bar("100")).collect();
let series = OhlcvSeries::from_bars(bars).unwrap();
assert_eq!(series.close_momentum(3).unwrap(), dec!(0));
}
#[test]
fn test_close_momentum_none_insufficient() {
let series = OhlcvSeries::from_bars(vec![bar("100"), bar("101")]).unwrap();
assert!(series.close_momentum(2).is_none()); assert!(series.close_momentum(0).is_none());
}
#[test]
fn test_swing_high_count_none_when_insufficient() {
let series = OhlcvSeries::from_bars(vec![bar("100"), bar("101")]).unwrap();
assert!(series.swing_high_count(5, 1).is_none()); assert!(series.swing_high_count(0, 1).is_none()); assert!(series.swing_high_count(2, 0).is_none()); }
#[test]
fn test_swing_high_count_detects_peak() {
let bars = vec![bar("100"), bar("110"), bar("100"), bar("100"), bar("100")];
let series = OhlcvSeries::from_bars(bars).unwrap();
let count = series.swing_high_count(5, 1).unwrap();
assert_eq!(count, 1);
}
#[test]
fn test_swing_high_count_flat_no_highs() {
let bars: Vec<_> = (0..7).map(|_| bar("100")).collect();
let series = OhlcvSeries::from_bars(bars).unwrap();
assert_eq!(series.swing_high_count(7, 1).unwrap(), 0);
}
#[test]
fn test_avg_wick_pct_none_when_zero_range() {
let bars = vec![bar("100"), bar("100")];
let series = OhlcvSeries::from_bars(bars).unwrap();
assert!(series.avg_wick_pct(2).is_none()); }
#[test]
fn test_avg_wick_pct_none_insufficient() {
let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
assert!(series.avg_wick_pct(0).is_none());
assert!(series.avg_wick_pct(2).is_none());
}
#[test]
fn test_trend_continuation_pct_none_insufficient() {
let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
assert!(series.trend_continuation_pct(0).is_none());
assert!(series.trend_continuation_pct(1).is_none()); }
fn make_bar_vol(o: &str, h: &str, l: &str, c: &str, vol: &str) -> OhlcvBar {
OhlcvBar {
symbol: Symbol::new("X").unwrap(),
open: make_price(o),
high: make_price(h),
low: make_price(l),
close: make_price(c),
volume: make_qty(vol),
ts_open: NanoTimestamp::new(0),
ts_close: NanoTimestamp::new(1),
tick_count: 1,
}
}
#[test]
fn test_close_to_open_ratio_bullish() {
let bars = vec![
make_bar_vol("100", "110", "95", "110", "1000"), make_bar_vol("105", "115", "100", "115", "1000"), ];
let series = OhlcvSeries::from_bars(bars).unwrap();
let ratio = series.close_to_open_ratio(2).unwrap();
assert!(ratio > dec!(1), "bullish bars: ratio > 1, got {ratio}");
}
#[test]
fn test_close_to_open_ratio_none_zero_n() {
let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
assert!(series.close_to_open_ratio(0).is_none());
}
#[test]
fn test_volume_trend_rising() {
let bars: Vec<OhlcvBar> = (1..=5u32).map(|i| {
make_bar_vol("100", "100", "100", "100", &(i * 100).to_string())
}).collect();
let series = OhlcvSeries::from_bars(bars).unwrap();
let slope = series.volume_trend(5).unwrap();
assert!(slope > 0.0_f64, "rising volume: positive slope, got {slope}");
}
#[test]
fn test_volume_trend_none_insufficient() {
let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
assert!(series.volume_trend(0).is_none());
assert!(series.volume_trend(2).is_none()); }
#[test]
fn test_high_volume_price_returns_close_of_max_vol_bar() {
let bars = vec![
make_bar_vol("100", "100", "100", "100", "500"),
make_bar_vol("200", "200", "200", "200", "1000"), make_bar_vol("150", "150", "150", "150", "300"),
];
let series = OhlcvSeries::from_bars(bars).unwrap();
assert_eq!(series.high_volume_price(3), Some(dec!(200)));
}
#[test]
fn test_high_volume_price_none_zero_n() {
let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
assert!(series.high_volume_price(0).is_none());
}
#[test]
fn test_avg_close_minus_open_bullish() {
let bars = vec![
make_bar_vol("100", "110", "95", "105", "1000"), make_bar_vol("105", "115", "100", "108", "1000"), ];
let series = OhlcvSeries::from_bars(bars).unwrap();
let avg = series.avg_close_minus_open(2).unwrap();
assert_eq!(avg, dec!(4)); }
#[test]
fn test_avg_close_minus_open_none_zero_n() {
let series = OhlcvSeries::from_bars(vec![bar("100")]).unwrap();
assert!(series.avg_close_minus_open(0).is_none());
}
}