use super::Study;
use super::averages::positive;
use super::bivariate::rolling_bivariate_values;
use super::kernels::{js_atan, percent_change_at};
use super::regression::{linear_regression_values, regression_at};
use std::borrow::Cow;
use crate::column::ColumnData;
use crate::error::{PondError, Result};
use crate::par;
use crate::series::TimeSeries;
study_options! {
LinearRegression, prefix = "linreg" {
period: usize = 14,
}
}
study_options! {
Beta(benchmark), output = "beta" {
period: usize = 5,
}
}
study_options! {
Correlation(benchmark), output = "corr" {
period: usize = 30,
}
}
study_options! {
PriceRelative(benchmark), output = "priceRel" {}
}
study_options! {
PerformanceIndex(benchmark), output = "perf" {
period: usize = 20,
}
}
const PIN_SLACK: f64 = 1e-9;
impl TimeSeries {
pub fn linear_regression(&self, options: LinearRegression) -> Result<TimeSeries> {
const CALL: &str = "TimeSeries::linear_regression";
positive(CALL, "period", options.period)?;
regression_period(CALL, "linearRegression", options.period)?;
let prefix = options.name();
let names = ["Value", "Slope", "Intercept", "Angle", "R2"].map(|s| format!("{prefix}{s}"));
self.free_names(CALL, &names)?;
let values = self.study_source(CALL, options.source())?;
let fit = linear_regression_values(&values, options.period);
let value = regression_at(&fit, (options.period - 1) as f64);
let degrees_per_radian = 180.0 / std::f64::consts::PI;
let mut angle = vec![0.0; fit.slope.len()];
par::fill_chunks(&mut angle, &mut [], 1 << 16, |start, out, _| {
for (a, &m) in out.iter_mut().zip(&fit.slope[start..]) {
*a = js_atan(m) * degrees_per_radian;
}
});
let [value_name, slope_name, intercept_name, angle_name, r2_name] = names;
self.study_outputs(
CALL,
[
(value_name, value),
(slope_name, fit.slope),
(intercept_name, fit.intercept),
(angle_name, angle),
(r2_name, fit.r2),
],
)
}
pub fn time_series_forecast(&self, study: Study) -> Result<TimeSeries> {
const CALL: &str = "TimeSeries::time_series_forecast";
positive(CALL, "period", study.period)?;
regression_period(CALL, "timeSeriesForecast", study.period)?;
let (values, output) = self.study_input(CALL, &study, "tsf")?;
let fit = linear_regression_values(&values, study.period);
self.study_output(CALL, output, regression_at(&fit, study.period as f64))
}
pub fn beta(&self, options: Beta) -> Result<TimeSeries> {
const CALL: &str = "TimeSeries::beta";
positive(CALL, "period", options.period)?;
at_least_two(
CALL,
options.period,
"beta period must be at least 2 (a single return has no variance, so every reading would be 0/0)",
)?;
let (values, benchmark, output) = self.pair_input(
CALL,
"beta",
&options,
"a column has a beta of exactly 1 against itself",
)?;
let (mut returns, mut benchmark_returns) =
(vec![0.0; values.len()], vec![0.0; values.len()]);
par::fill_chunks(
&mut returns,
&mut benchmark_returns,
1 << 16,
|start, a, b| {
for (k, (a, b)) in a.iter_mut().zip(b).enumerate() {
*a = percent_change_at(&values, 1, start + k);
*b = percent_change_at(&benchmark, 1, start + k);
}
},
);
let moments = rolling_bivariate_values(&returns, &benchmark_returns, options.period);
let out = moments
.covariance
.iter()
.zip(&moments.variance_y)
.map(|(&c, &v)| c / v)
.collect();
self.study_output(CALL, output, out)
}
pub fn correlation(&self, options: Correlation) -> Result<TimeSeries> {
const CALL: &str = "TimeSeries::correlation";
positive(CALL, "period", options.period)?;
at_least_two(
CALL,
options.period,
"correlation period must be at least 2 (a one-bar window has no variance, so every reading would be 0/0)",
)?;
let (values, benchmark, output) = self.pair_input(
CALL,
"correlation",
&options,
"a column correlates with itself at exactly 1",
)?;
let moments = rolling_bivariate_values(&values, &benchmark, options.period);
let out = moments
.covariance
.iter()
.zip(moments.variance_x.iter().zip(&moments.variance_y))
.map(|(&c, (&vx, &vy))| {
let mut denominator = (vx * vy).sqrt();
if denominator == 0.0 || denominator == f64::INFINITY {
denominator = vx.sqrt() * vy.sqrt();
}
let r = c / denominator;
if r > 1.0 && r <= 1.0 + PIN_SLACK {
1.0
} else if (-1.0 - PIN_SLACK..-1.0).contains(&r) {
-1.0
} else {
r
}
})
.collect();
self.study_output(CALL, output, out)
}
pub fn price_relative(&self, options: PriceRelative) -> Result<TimeSeries> {
const CALL: &str = "TimeSeries::price_relative";
let (values, benchmark, output) = self.pair_input(
CALL,
"priceRelative",
&options,
"the ratio would be 1 on every bar",
)?;
let out = values
.iter()
.zip(benchmark.iter())
.map(|(&v, &b)| if b == 0.0 { f64::NAN } else { v / b })
.collect();
self.study_output(CALL, output, out)
}
pub fn performance_index(&self, options: PerformanceIndex) -> Result<TimeSeries> {
const CALL: &str = "TimeSeries::performance_index";
positive(CALL, "period", options.period)?;
let (values, base, output) = self.pair_input(
CALL,
"performanceIndex",
&options,
"the reading would be 1 on every bar",
)?;
let period = options.period;
let out = (0..values.len())
.map(|i| {
if i < period {
return f64::NAN;
}
let (value_previous, base_previous, base_now) =
(values[i - period], base[i - period], base[i]);
if value_previous == 0.0 || base_previous == 0.0 || base_now == 0.0 {
f64::NAN
} else {
(values[i] / value_previous) * (base_previous / base_now)
}
})
.collect();
self.study_output(CALL, output, out)
}
fn pair_input<O: PairOptions>(
&self,
call: &'static str,
study: &str,
options: &O,
same_reason: &str,
) -> Result<PairInput<'_>> {
let (column, benchmark) = (options.source(), options.benchmark_name());
if benchmark == column {
return Err(PondError::InvalidData {
call,
message: format!(
"{study} benchmark '{benchmark}' is the same column as 'column'; {same_reason}"
),
});
}
let found = self.columns().iter().find(|c| c.name() == benchmark);
match found.map(|c| c.data()) {
None => {
return Err(PondError::InvalidData {
call,
message: format!(
"benchmark column '{benchmark}' is not on the series; join the comparison series in first (align + joinMany) or pass a different 'benchmark'"
),
});
}
Some(ColumnData::Number(_)) => {}
Some(other) => {
return Err(PondError::InvalidData {
call,
message: format!(
"benchmark column '{benchmark}' is a {} column, not a number column",
other.kind().as_str()
),
});
}
}
let output = options.output_name().to_string();
self.free_names(call, &[&output])?;
let values = self.study_source(call, column)?;
let benchmark = self.study_source(call, benchmark)?;
Ok((values, benchmark, output))
}
}
type PairInput<'a> = (Cow<'a, [f64]>, Cow<'a, [f64]>, String);
trait PairOptions {
fn source(&self) -> &str;
fn benchmark_name(&self) -> &str;
fn output_name(&self) -> &str;
}
macro_rules! pair_options {
($($name:ident),*) => {
$(
impl PairOptions for $name {
fn source(&self) -> &str {
$name::source(self)
}
fn benchmark_name(&self) -> &str {
$name::benchmark_name(self)
}
fn output_name(&self) -> &str {
$name::name(self)
}
}
)*
};
}
pair_options!(Beta, Correlation, PriceRelative, PerformanceIndex);
fn regression_period(call: &'static str, study: &str, period: usize) -> Result<()> {
at_least_two(
call,
period,
&format!(
"{study} period must be at least 2 (a one-bar window has no slope: the regression denominator n²(n²−1)/12 is 0 at n = 1)"
),
)
}
fn at_least_two(call: &'static str, period: usize, message: &str) -> Result<()> {
if period < 2 {
return Err(PondError::InvalidData {
call,
message: message.to_string(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::series::Column;
fn series(columns: Vec<Column>) -> TimeSeries {
TimeSeries::new("s", (0..3).collect(), columns).unwrap()
}
fn message(result: Result<TimeSeries>) -> String {
result.unwrap_err().to_string()
}
#[test]
fn two_column_checks_run_in_pond_ts_order() {
let s = series(vec![
Column::required("close", vec![1.0, 2.0, 3.0]),
Column::required("spy", vec![1.0, 2.0, 4.0]),
Column::required("name", vec!["a", "b", "c"]),
]);
let same = message(s.beta(Beta::new("close").output("close")));
assert!(same.contains("same column as 'column'"), "{same}");
let missing = message(s.correlation(Correlation::new("qqq").output("close")));
assert!(
missing.contains("benchmark column 'qqq' is not on the series"),
"{missing}"
);
let text = message(s.price_relative(PriceRelative::new("name").output("close")));
assert!(
text.contains("is a string column, not a number column"),
"{text}"
);
let output = message(
s.performance_index(PerformanceIndex::new("spy").column("nope").output("close")),
);
assert!(
output.contains("collides with an existing column"),
"{output}"
);
let period = message(s.beta(Beta::new("close").period(1)));
assert!(
period.contains("beta period must be at least 2"),
"{period}"
);
}
}