Update version numbers across Rust, Python, and documentation files to 1.1.0. Enhance the .gitignore to include macOS dSYM files and plans directory. Introduce new dependencies in the Rust core library and update the README to reflect recent performance benchmarks and backtesting engine capabilities. Add new artifacts to the benchmarks manifest and improve documentation for the backtesting engine API.
371 lines
11 KiB
Rust
371 lines
11 KiB
Rust
//! Cycle indicators — Hilbert Transform-based cycle analysis (Ehlers).
|
|
//!
|
|
//! Based on John Ehlers' Discrete Hilbert Transform as implemented in TA-Lib.
|
|
//! Reference: "Cybernetic Analysis for Stocks and Futures" by J.F. Ehlers
|
|
//!
|
|
//! All HT functions share a 63-bar lookback period.
|
|
|
|
use std::f64::consts::PI;
|
|
|
|
/// Number of leading bars that are set to NaN / zero.
|
|
pub const HT_LOOKBACK: usize = 63;
|
|
|
|
/// Shared output from the core Hilbert Transform computation.
|
|
pub struct HtCore {
|
|
pub trendline: Vec<f64>,
|
|
pub dc_period: Vec<f64>,
|
|
pub dc_phase: Vec<f64>,
|
|
pub inphase: Vec<f64>,
|
|
pub quadrature: Vec<f64>,
|
|
pub trend_mode: Vec<i32>,
|
|
}
|
|
|
|
/// Run the full Hilbert Transform pipeline on a slice of close prices.
|
|
pub fn compute_ht_core(prices: &[f64]) -> HtCore {
|
|
let n = prices.len();
|
|
|
|
let mut trendline = vec![f64::NAN; n];
|
|
let mut dc_period = vec![f64::NAN; n];
|
|
let mut dc_phase = vec![f64::NAN; n];
|
|
let mut inphase = vec![f64::NAN; n];
|
|
let mut quadrature = vec![f64::NAN; n];
|
|
let mut trend_mode = vec![0i32; n];
|
|
|
|
if n <= HT_LOOKBACK {
|
|
return HtCore {
|
|
trendline,
|
|
dc_period,
|
|
dc_phase,
|
|
inphase,
|
|
quadrature,
|
|
trend_mode,
|
|
};
|
|
}
|
|
|
|
// Step 1: Smooth the price series (4-bar weighted average)
|
|
let mut smooth = vec![0.0f64; n];
|
|
for i in 0..n {
|
|
smooth[i] = if i >= 3 {
|
|
(4.0 * prices[i] + 3.0 * prices[i - 1] + 2.0 * prices[i - 2] + prices[i - 3]) / 10.0
|
|
} else {
|
|
prices[i]
|
|
};
|
|
}
|
|
|
|
// Step 2: Full Hilbert Transform pipeline
|
|
let mut detrender = vec![0.0f64; n];
|
|
let mut q1 = vec![0.0f64; n];
|
|
let mut i1 = vec![0.0f64; n];
|
|
let mut ji = vec![0.0f64; n];
|
|
let mut jq = vec![0.0f64; n];
|
|
let mut i2 = vec![0.0f64; n];
|
|
let mut q2 = vec![0.0f64; n];
|
|
let mut re = vec![0.0f64; n];
|
|
let mut im = vec![0.0f64; n];
|
|
let mut period = vec![0.0f64; n];
|
|
let mut smooth_period = vec![0.0f64; n];
|
|
let mut phase = vec![0.0f64; n];
|
|
|
|
for i in 6..n {
|
|
let prev_period = period[i - 1];
|
|
// Alpha coefficient for HT filters depends on the current period estimate
|
|
let alpha = 0.075 * prev_period + 0.54;
|
|
|
|
// Discrete Hilbert Transform of smooth price (detrender)
|
|
detrender[i] = (0.0962 * smooth[i] + 0.5769 * smooth[i - 2]
|
|
- 0.5769 * smooth[i - 4]
|
|
- 0.0962 * smooth[i - 6])
|
|
* alpha;
|
|
|
|
// Q1: HT of detrender
|
|
if i >= 12 {
|
|
q1[i] = (0.0962 * detrender[i] + 0.5769 * detrender[i - 2]
|
|
- 0.5769 * detrender[i - 4]
|
|
- 0.0962 * detrender[i - 6])
|
|
* alpha;
|
|
}
|
|
|
|
// I1: delayed detrender
|
|
if i >= 9 {
|
|
i1[i] = detrender[i - 3];
|
|
}
|
|
|
|
// jI: HT of I1
|
|
if i >= 15 {
|
|
ji[i] = (0.0962 * i1[i] + 0.5769 * i1[i - 2] - 0.5769 * i1[i - 4] - 0.0962 * i1[i - 6])
|
|
* alpha;
|
|
}
|
|
|
|
// jQ: HT of Q1
|
|
if i >= 18 {
|
|
jq[i] = (0.0962 * q1[i] + 0.5769 * q1[i - 2] - 0.5769 * q1[i - 4] - 0.0962 * q1[i - 6])
|
|
* alpha;
|
|
}
|
|
|
|
// Phase components
|
|
let i2_raw = i1[i] - jq[i];
|
|
let q2_raw = q1[i] + ji[i];
|
|
|
|
// EMA smoothing of I2 and Q2
|
|
let i2_prev = i2[i - 1];
|
|
let q2_prev = q2[i - 1];
|
|
i2[i] = 0.2 * i2_raw + 0.8 * i2_prev;
|
|
q2[i] = 0.2 * q2_raw + 0.8 * q2_prev;
|
|
|
|
// Cross-product for period estimation
|
|
let re_raw = i2[i] * i2_prev + q2[i] * q2_prev;
|
|
let im_raw = i2[i] * q2_prev - q2[i] * i2_prev;
|
|
|
|
// EMA smoothing of Re and Im
|
|
re[i] = 0.2 * re_raw + 0.8 * re[i - 1];
|
|
im[i] = 0.2 * im_raw + 0.8 * im[i - 1];
|
|
|
|
// Compute period from cross-product of consecutive phasors.
|
|
let mut p = if re[i] != 0.0 && im[i] != 0.0 && re[i] > 0.0 {
|
|
2.0 * PI / (im[i] / re[i]).atan()
|
|
} else {
|
|
prev_period
|
|
};
|
|
|
|
// Clamp period relative to previous
|
|
if prev_period > 0.0 {
|
|
if p > 1.5 * prev_period {
|
|
p = 1.5 * prev_period;
|
|
}
|
|
if p < 0.67 * prev_period {
|
|
p = 0.67 * prev_period;
|
|
}
|
|
}
|
|
// Hard clamp to [6, 50] bars
|
|
p = p.clamp(6.0, 50.0);
|
|
|
|
// EMA smooth the period
|
|
period[i] = 0.2 * p + 0.8 * prev_period;
|
|
|
|
// Smooth the smoothed period once more
|
|
smooth_period[i] = 0.33 * period[i] + 0.67 * smooth_period[i - 1];
|
|
|
|
// Phase from I1 and Q1
|
|
phase[i] = if i1[i] != 0.0 {
|
|
q1[i].atan2(i1[i]) * 180.0 / PI
|
|
} else if q1[i] > 0.0 {
|
|
90.0
|
|
} else if q1[i] < 0.0 {
|
|
-90.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
// Write outputs once past lookback
|
|
if i >= HT_LOOKBACK {
|
|
dc_period[i] = smooth_period[i];
|
|
dc_phase[i] = phase[i];
|
|
inphase[i] = i1[i];
|
|
quadrature[i] = q1[i];
|
|
|
|
// Trend mode: cycle when SmoothPeriod >= 20, trend when < 20
|
|
trend_mode[i] = if smooth_period[i] < 20.0 { 1 } else { 0 };
|
|
}
|
|
}
|
|
|
|
// Trendline: average over the current dominant cycle period
|
|
for i in HT_LOOKBACK..n {
|
|
let sp = smooth_period[i];
|
|
let dc = (sp.round() as usize).max(1).min(i + 1);
|
|
let sum: f64 = (0..dc).map(|j| smooth[i - j]).sum();
|
|
trendline[i] = sum / dc as f64;
|
|
}
|
|
|
|
HtCore {
|
|
trendline,
|
|
dc_period,
|
|
dc_phase,
|
|
inphase,
|
|
quadrature,
|
|
trend_mode,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public indicator functions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Hilbert Transform Instantaneous Trendline (Ehlers).
|
|
/// Smooths price over the dominant cycle period.
|
|
pub fn ht_trendline(close: &[f64]) -> Vec<f64> {
|
|
compute_ht_core(close).trendline
|
|
}
|
|
|
|
/// Hilbert Transform Dominant Cycle Period in bars.
|
|
pub fn ht_dcperiod(close: &[f64]) -> Vec<f64> {
|
|
compute_ht_core(close).dc_period
|
|
}
|
|
|
|
/// Hilbert Transform Dominant Cycle Phase in degrees.
|
|
pub fn ht_dcphase(close: &[f64]) -> Vec<f64> {
|
|
compute_ht_core(close).dc_phase
|
|
}
|
|
|
|
/// Hilbert Transform Phasor components. Returns `(inphase, quadrature)`.
|
|
pub fn ht_phasor(close: &[f64]) -> (Vec<f64>, Vec<f64>) {
|
|
let core = compute_ht_core(close);
|
|
(core.inphase, core.quadrature)
|
|
}
|
|
|
|
/// Hilbert Transform SineWave. Returns `(sine, leadsine)` where leadsine
|
|
/// leads sine by 45 degrees.
|
|
pub fn ht_sine(close: &[f64]) -> (Vec<f64>, Vec<f64>) {
|
|
let n = close.len();
|
|
let core = compute_ht_core(close);
|
|
|
|
let mut sine = vec![f64::NAN; n];
|
|
let mut lead_sine = vec![f64::NAN; n];
|
|
|
|
for i in HT_LOOKBACK..n {
|
|
if !core.dc_phase[i].is_nan() {
|
|
let phase_rad = core.dc_phase[i] * PI / 180.0;
|
|
sine[i] = phase_rad.sin();
|
|
lead_sine[i] = (phase_rad + PI / 4.0).sin(); // 45-degree lead
|
|
}
|
|
}
|
|
|
|
(sine, lead_sine)
|
|
}
|
|
|
|
/// Hilbert Transform Trend vs Cycle Mode: 1 = trending, 0 = cycling.
|
|
pub fn ht_trendmode(close: &[f64]) -> Vec<i32> {
|
|
compute_ht_core(close).trend_mode
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Generate a simple sine wave for testing cycle detection.
|
|
fn sine_wave(n: usize, period: f64) -> Vec<f64> {
|
|
(0..n)
|
|
.map(|i| 100.0 + 10.0 * (2.0 * PI * i as f64 / period).sin())
|
|
.collect()
|
|
}
|
|
|
|
/// Flat price series for baseline testing.
|
|
fn flat_prices(n: usize) -> Vec<f64> {
|
|
vec![100.0; n]
|
|
}
|
|
|
|
#[test]
|
|
fn test_ht_trendline_length_and_lookback() {
|
|
let close = sine_wave(200, 20.0);
|
|
let result = ht_trendline(&close);
|
|
assert_eq!(result.len(), close.len());
|
|
// First HT_LOOKBACK values must be NaN
|
|
for v in &result[..HT_LOOKBACK] {
|
|
assert!(v.is_nan(), "expected NaN in lookback region");
|
|
}
|
|
// Values after lookback must be finite
|
|
for v in &result[HT_LOOKBACK..] {
|
|
assert!(v.is_finite(), "expected finite value after lookback");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_ht_dcperiod_length_and_lookback() {
|
|
let close = sine_wave(200, 20.0);
|
|
let result = ht_dcperiod(&close);
|
|
assert_eq!(result.len(), close.len());
|
|
for v in &result[..HT_LOOKBACK] {
|
|
assert!(v.is_nan());
|
|
}
|
|
// After lookback, period should be positive and finite
|
|
for v in &result[HT_LOOKBACK..] {
|
|
assert!(v.is_finite());
|
|
assert!(*v >= 6.0 && *v <= 50.0, "period {} out of [6,50]", v);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_ht_dcphase_length_and_lookback() {
|
|
let close = sine_wave(200, 20.0);
|
|
let result = ht_dcphase(&close);
|
|
assert_eq!(result.len(), close.len());
|
|
for v in &result[..HT_LOOKBACK] {
|
|
assert!(v.is_nan());
|
|
}
|
|
for v in &result[HT_LOOKBACK..] {
|
|
assert!(v.is_finite());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_ht_phasor_dual_output() {
|
|
let close = sine_wave(200, 20.0);
|
|
let (inp, quad) = ht_phasor(&close);
|
|
assert_eq!(inp.len(), close.len());
|
|
assert_eq!(quad.len(), close.len());
|
|
for v in &inp[..HT_LOOKBACK] {
|
|
assert!(v.is_nan());
|
|
}
|
|
for v in &quad[..HT_LOOKBACK] {
|
|
assert!(v.is_nan());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_ht_sine_dual_output() {
|
|
let close = sine_wave(200, 20.0);
|
|
let (s, ls) = ht_sine(&close);
|
|
assert_eq!(s.len(), close.len());
|
|
assert_eq!(ls.len(), close.len());
|
|
for v in &s[..HT_LOOKBACK] {
|
|
assert!(v.is_nan());
|
|
}
|
|
// Sine values should be in [-1, 1]
|
|
for v in &s[HT_LOOKBACK..] {
|
|
assert!(v.is_finite());
|
|
assert!(*v >= -1.0 && *v <= 1.0, "sine {} out of [-1,1]", v);
|
|
}
|
|
for v in &ls[HT_LOOKBACK..] {
|
|
assert!(v.is_finite());
|
|
assert!(*v >= -1.0 && *v <= 1.0, "leadsine {} out of [-1,1]", v);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_ht_trendmode_values() {
|
|
let close = sine_wave(200, 20.0);
|
|
let result = ht_trendmode(&close);
|
|
assert_eq!(result.len(), close.len());
|
|
// All values must be 0 or 1
|
|
for v in &result {
|
|
assert!(*v == 0 || *v == 1, "trend_mode {} not 0 or 1", v);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_short_input_all_nan() {
|
|
let close = vec![100.0; HT_LOOKBACK]; // exactly HT_LOOKBACK, not enough
|
|
let tl = ht_trendline(&close);
|
|
assert!(tl.iter().all(|v| v.is_nan()));
|
|
let dp = ht_dcperiod(&close);
|
|
assert!(dp.iter().all(|v| v.is_nan()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_flat_prices_trendline_equals_price() {
|
|
let close = flat_prices(200);
|
|
let tl = ht_trendline(&close);
|
|
// For a flat price, trendline after lookback should be very close to the price
|
|
for v in &tl[HT_LOOKBACK..] {
|
|
assert!(
|
|
(v - 100.0).abs() < 1e-6,
|
|
"trendline {} diverged from flat price",
|
|
v
|
|
);
|
|
}
|
|
}
|
|
}
|