#[macro_use]
mod options;
mod averages;
mod bands;
mod bivariate;
mod calendar;
pub mod catalog;
mod kernels;
mod momentum;
mod price;
mod regression;
mod session;
mod statistics;
mod trend;
mod volatility;
mod volume;
pub use averages::{
Coppock, Guppy, Kst, Macd, MovingAverageCross, OscillatorMode, PriceMomentumOscillator,
PriceOscillator, Rainbow, RainbowOscillator, SchaffTrendCycle, SpecialK, Trix,
};
pub use bands::{
AtrBands, Bollinger, BollingerBandwidth, BollingerPercentB, Donchian, Envelope, HighLowBands,
Keltner, PrimeNumberBands, StarcBands,
};
pub use calendar::{
DateBound, Session, SessionBreak, SessionRef, SessionRules, Stamped, TagSessions,
TradingCalendar, generate_sessions, normalize_sessions,
};
pub use momentum::{
AwesomeOscillator, CommodityChannelIndex, FisherTransform, IntradayMomentumIndex,
PercentChange, PrettyGoodOscillator, PrimeNumberOscillator, Qstick, RelativeVigorIndex,
Stochastic, StochasticMomentumIndex, StochasticRsi, TrueStrengthIndex, UltimateOscillator,
WilliamsR,
};
pub use price::{AveragePrice, BalanceOfPower, MedianPrice, TypicalPrice, WeightedClose};
pub use session::{AnchoredVwap, PivotMethod, PivotPoints, SessionAnchor, SessionVwap};
pub use statistics::{Beta, Correlation, LinearRegression, PerformanceIndex, PriceRelative};
pub use trend::{
AccumulativeSwingIndex, Aroon, AtrTrailingStop, DirectionalMovement, ElderImpulse, ElderRay,
Ichimoku, ParabolicSar, RandomWalkIndex, Ravi, SuperTrend, SwingIndex, TrendIntensityIndex,
Vortex, ZigZag,
};
pub use volatility::{
Atr, ChaikinVolatility, ChoppinessIndex, GopalakrishnanRangeIndex, HistoricalVolatility,
MassIndex, RelativeVolatilityIndex,
};
pub use volume::{
AccumulationDistribution, ChaikinMoneyFlow, ChaikinOscillator, EaseOfMovement, ForceIndex,
Klinger, MarketFacilitationIndex, MoneyFlowIndex, NegativeVolumeIndex, Obv,
PositiveVolumeIndex, PriceVolumeTrend, ShinoharaIntensityRatio, TradeVolumeIndex,
TwiggsMoneyFlow, VolumeOscillator, Vwap,
};
use std::borrow::Cow;
use crate::column::ColumnData;
use crate::error::{PondError, Result};
use crate::reducer::{Agg, Reducer};
use crate::rolling::Rolling;
use crate::series::{Column, TimeSeries};
pub const DEFAULT_SOURCE: &str = "close";
#[derive(Debug, Clone, PartialEq)]
pub struct Study {
period: usize,
column: Option<String>,
output: Option<String>,
}
impl Study {
pub fn period(period: usize) -> Self {
Self {
period,
column: None,
output: None,
}
}
pub fn column(mut self, name: &str) -> Self {
self.column = Some(name.to_string());
self
}
pub fn output(mut self, name: &str) -> Self {
self.output = Some(name.to_string());
self
}
fn source(&self) -> &str {
self.column.as_deref().unwrap_or(DEFAULT_SOURCE)
}
fn output_or<'a>(&'a self, default: &'a str) -> &'a str {
self.output.as_deref().unwrap_or(default)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaType {
Sma,
Ema,
Wma,
Smma,
Dema,
Tema,
Trima,
Hull,
Kama,
Zlema,
}
impl MaType {
pub const ALL: [MaType; 10] = [
MaType::Sma,
MaType::Ema,
MaType::Wma,
MaType::Smma,
MaType::Dema,
MaType::Tema,
MaType::Trima,
MaType::Hull,
MaType::Kama,
MaType::Zlema,
];
pub fn as_str(self) -> &'static str {
match self {
MaType::Sma => "sma",
MaType::Ema => "ema",
MaType::Wma => "wma",
MaType::Smma => "smma",
MaType::Dema => "dema",
MaType::Tema => "tema",
MaType::Trima => "trima",
MaType::Hull => "hull",
MaType::Kama => "kama",
MaType::Zlema => "zlema",
}
}
pub fn parse(name: &str) -> Result<Self> {
MaType::ALL
.into_iter()
.find(|t| t.as_str() == name)
.ok_or_else(|| PondError::InvalidData {
call: "MaType::parse",
message: format!(
"unknown moving-average type '{name}'; expected one of {}",
MaType::ALL.map(MaType::as_str).join(", ")
),
})
}
}
impl TimeSeries {
pub fn sma(&self, study: Study) -> Result<TimeSeries> {
const CALL: &str = "TimeSeries::sma";
let (values, output) = self.study_input(CALL, &study, "sma")?;
let (mean, _) = kernels::rolling_mean_sd(&values, study.period, false);
self.study_output(CALL, output, mean)
}
pub fn ema(&self, study: Study) -> Result<TimeSeries> {
const CALL: &str = "TimeSeries::ema";
let (values, output) = self.study_input(CALL, &study, "ema")?;
let ema = kernels::ema_values(&values, study.period);
self.study_output(CALL, output, ema)
}
pub fn moving_average(&self, kind: MaType, study: Study) -> Result<TimeSeries> {
const CALL: &str = "TimeSeries::moving_average";
let (values, output) = self.study_input(CALL, &study, "ma")?;
let ma = match kind {
MaType::Sma => kernels::rolling_mean_sd(&values, study.period, false).0,
other => kernels::moving_average_values(&values, study.period, other),
};
self.study_output(CALL, output, ma)
}
pub fn rolling_stdev(&self, study: Study) -> Result<TimeSeries> {
const CALL: &str = "TimeSeries::rolling_stdev";
let (values, output) = self.study_input(CALL, &study, "stdev")?;
let (_, sd) = kernels::rolling_mean_sd(&values, study.period, true);
self.study_output(CALL, output, sd.expect("asked for the spread"))
}
pub fn rolling_min(&self, study: Study) -> Result<TimeSeries> {
const CALL: &str = "TimeSeries::rolling_min";
let (values, output) = self.study_input(CALL, &study, "min")?;
let min = kernels::rolling_extreme::<false>(&values, study.period);
self.study_output(CALL, output, min)
}
pub fn rolling_max(&self, study: Study) -> Result<TimeSeries> {
const CALL: &str = "TimeSeries::rolling_max";
let (values, output) = self.study_input(CALL, &study, "max")?;
let max = kernels::rolling_extreme::<true>(&values, study.period);
self.study_output(CALL, output, max)
}
pub fn rolling_percentile(&self, q: f64, study: Study) -> Result<TimeSeries> {
const CALL: &str = "TimeSeries::rolling_percentile";
if !(0.0..=100.0).contains(&q) {
return Err(PondError::InvalidData {
call: CALL,
message: format!("q must be in [0, 100], got {q}"),
});
}
let name = format!("p{}", crate::grid::js_number(q));
self.rolling_stat(CALL, study, Reducer::Percentile(q), &name)
}
pub fn z_score(&self, study: Study) -> Result<TimeSeries> {
const CALL: &str = "TimeSeries::z_score";
let (values, output) = self.study_input(CALL, &study, "zscore")?;
let (deviation, sd) = kernels::rolling_deviation_sd(&values, study.period);
let z = deviation
.iter()
.zip(&sd)
.map(|(&d, &s)| if s == 0.0 { f64::NAN } else { d / s })
.collect();
self.study_output(CALL, output, z)
}
fn rolling_stat(
&self,
call: &'static str,
study: Study,
reducer: Reducer,
default: &str,
) -> Result<TimeSeries> {
let (_, output) = self.study_input(call, &study, default)?;
let rolled = self
.rolling_rows(
study.period,
&[Agg::new("value", study.source(), reducer)],
Rolling {
min_samples: study.period,
..Rolling::default()
},
)
.map_err(|e| e.renamed(call))?;
let values = rolled.column("value")?.numbers()?.values().to_vec();
self.study_output(call, output, values)
}
fn study_input(
&self,
call: &'static str,
study: &Study,
default: &str,
) -> Result<(Cow<'_, [f64]>, String)> {
if study.period == 0 {
return Err(PondError::InvalidData {
call,
message: "period must be a positive integer (bar count)".into(),
});
}
let output = study.output_or(default).to_string();
self.free_names(call, &[&output])?;
Ok((self.study_source(call, study.source())?, output))
}
fn price_columns<const N: usize>(
&self,
call: &'static str,
names: [&Option<String>; N],
defaults: [&str; N],
) -> Result<[std::borrow::Cow<'_, [f64]>; N]> {
let mut out = Vec::with_capacity(N);
for (name, default) in names.into_iter().zip(defaults) {
out.push(self.study_source(call, name.as_deref().unwrap_or(default))?);
}
Ok(out.try_into().unwrap_or_else(|_| unreachable!()))
}
fn free_names(&self, call: &'static str, names: &[impl AsRef<str>]) -> Result<()> {
for name in names {
let name = name.as_ref();
if name == self.key_name() || self.columns().iter().any(|c| c.name() == name) {
return Err(PondError::InvalidData {
call,
message: format!(
"study output column '{name}' collides with an existing column; pass a different 'output'"
),
});
}
}
Ok(())
}
fn study_source(&self, call: &'static str, column: &str) -> Result<Cow<'_, [f64]>> {
let source = &self.columns()[self.column_index(call, column)?];
match source.data() {
ColumnData::Number(v) => {
let values = v.values();
Ok(
if v.all_finite() || !values.iter().any(|x| x.is_infinite()) {
Cow::Borrowed(values)
} else {
Cow::Owned(kernels::gaps_as_nan(values))
},
)
}
other => Err(PondError::WrongKind {
call,
column: source.name().to_string(),
kind: other.kind().as_str(),
reason: "studies read a number column".into(),
}),
}
}
fn study_output(
&self,
call: &'static str,
output: String,
values: Vec<f64>,
) -> Result<TimeSeries> {
self.study_outputs(call, [(output, values)])
}
fn study_outputs(
&self,
call: &'static str,
columns: impl IntoIterator<Item = (String, Vec<f64>)>,
) -> Result<TimeSeries> {
let mut columns = columns.into_iter();
let (name, values) = columns.next().expect("a study writes a column");
let mut out = self
.with_column(Column::optional(name, values))
.map_err(|e| e.renamed(call))?;
for (name, values) in columns {
out = out
.with_column(Column::optional(name, values))
.map_err(|e| e.renamed(call))?;
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn infinite_gaps_read_as_gaps() {
let make = |gap: f64| {
let close = vec![1.0, 2.0, gap, 4.0, 5.0, 6.0, 7.0, 8.0];
let time = (0..close.len() as i64).collect();
TimeSeries::new("s", time, vec![Column::optional("close", close)]).unwrap()
};
for kind in MaType::ALL {
let with = |gap| {
make(gap)
.moving_average(kind, Study::period(2))
.unwrap()
.column("ma")
.unwrap()
.numbers()
.unwrap()
.iter()
.collect::<Vec<_>>()
};
assert_eq!(with(f64::INFINITY), with(f64::NAN), "{}", kind.as_str());
assert_eq!(with(f64::NEG_INFINITY), with(f64::NAN), "{}", kind.as_str());
}
}
#[test]
fn huge_periods_give_gaps() {
let close: Vec<f64> = (0..10).map(f64::from).collect();
let series = TimeSeries::new(
"s",
(0..10).collect(),
vec![Column::required("close", close)],
)
.unwrap();
for period in [11, 1 << 40, usize::MAX - 1, usize::MAX] {
let study = || Study::period(period).output("out");
let mut results = vec![
series.sma(study()),
series.ema(study()),
series.rolling_stdev(study()),
series.rolling_min(study()),
series.rolling_max(study()),
series.rolling_percentile(50.0, study()),
series.z_score(study()),
];
for kind in MaType::ALL {
results.push(series.moving_average(kind, study()));
}
for kind in MaType::ALL {
results.push(series.moving_average_deviation(kind, study()));
results.push(series.disparity_index(kind, study()));
results.push(series.detrended_price_oscillator(kind, study()));
let (fast, slow) = (period - 1, period);
results.push(
series.price_oscillator(
PriceOscillator::default().fast(fast).slow(slow).ma(kind),
),
);
results.push(series.moving_average_cross(
MovingAverageCross::default().fast(fast).slow(slow).ma(kind),
));
results.push(series.rainbow(Rainbow::default().period(period).ma(kind)));
results.push(
series.rainbow_oscillator(
RainbowOscillator::default()
.period(period)
.lookback(period)
.ma(kind),
),
);
}
let (fast, slow) = (period - 1, period);
results.push(series.macd(Macd::default().fast(fast).slow(slow).signal(period)));
results.push(series.trix(Trix::default().period(period).signal(period)));
results.push(
series.schaff_trend_cycle(
SchaffTrendCycle::default()
.fast(fast)
.slow(slow)
.cycle(period),
),
);
results.push(
series.coppock(
Coppock::default()
.long(period)
.short(period)
.wma_period(period),
),
);
for result in results {
let out = result.unwrap();
for column in &out.columns()[1..] {
let values = column.numbers().unwrap();
assert!(
(0..10).all(|i| values.get(i).is_none()),
"period {period}: {}",
column.name()
);
}
}
}
}
}