Skip to main content

vector_ta/indicators/
historical_volatility.rs

1#[cfg(feature = "python")]
2use numpy::{IntoPyArray, PyArray1, PyArrayMethods, PyReadonlyArray1};
3#[cfg(feature = "python")]
4use pyo3::exceptions::PyValueError;
5#[cfg(feature = "python")]
6use pyo3::prelude::*;
7#[cfg(feature = "python")]
8use pyo3::types::PyDict;
9
10#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
11use serde::{Deserialize, Serialize};
12#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
13use wasm_bindgen::prelude::*;
14
15use crate::utilities::data_loader::{source_type, Candles};
16use crate::utilities::enums::Kernel;
17use crate::utilities::helpers::{
18    alloc_with_nan_prefix, detect_best_batch_kernel, detect_best_kernel, init_matrix_prefixes,
19    make_uninit_matrix,
20};
21#[cfg(feature = "python")]
22use crate::utilities::kernel_validation::validate_kernel;
23#[cfg(not(target_arch = "wasm32"))]
24use rayon::prelude::*;
25use std::convert::AsRef;
26use std::mem::ManuallyDrop;
27use thiserror::Error;
28
29impl<'a> AsRef<[f64]> for HistoricalVolatilityInput<'a> {
30    #[inline(always)]
31    fn as_ref(&self) -> &[f64] {
32        match &self.data {
33            HistoricalVolatilityData::Slice(slice) => slice,
34            HistoricalVolatilityData::Candles { candles, source } => source_type(candles, source),
35        }
36    }
37}
38
39#[derive(Debug, Clone)]
40pub enum HistoricalVolatilityData<'a> {
41    Candles {
42        candles: &'a Candles,
43        source: &'a str,
44    },
45    Slice(&'a [f64]),
46}
47
48#[derive(Debug, Clone)]
49pub struct HistoricalVolatilityOutput {
50    pub values: Vec<f64>,
51}
52
53#[derive(Debug, Clone)]
54#[cfg_attr(
55    all(target_arch = "wasm32", feature = "wasm"),
56    derive(Serialize, Deserialize)
57)]
58pub struct HistoricalVolatilityParams {
59    pub lookback: Option<usize>,
60    pub annualization_days: Option<f64>,
61}
62
63impl Default for HistoricalVolatilityParams {
64    fn default() -> Self {
65        Self {
66            lookback: Some(20),
67            annualization_days: Some(250.0),
68        }
69    }
70}
71
72#[derive(Debug, Clone)]
73pub struct HistoricalVolatilityInput<'a> {
74    pub data: HistoricalVolatilityData<'a>,
75    pub params: HistoricalVolatilityParams,
76}
77
78impl<'a> HistoricalVolatilityInput<'a> {
79    #[inline]
80    pub fn from_candles(
81        candles: &'a Candles,
82        source: &'a str,
83        params: HistoricalVolatilityParams,
84    ) -> Self {
85        Self {
86            data: HistoricalVolatilityData::Candles { candles, source },
87            params,
88        }
89    }
90
91    #[inline]
92    pub fn from_slice(slice: &'a [f64], params: HistoricalVolatilityParams) -> Self {
93        Self {
94            data: HistoricalVolatilityData::Slice(slice),
95            params,
96        }
97    }
98
99    #[inline]
100    pub fn with_default_candles(candles: &'a Candles) -> Self {
101        Self::from_candles(candles, "close", HistoricalVolatilityParams::default())
102    }
103
104    #[inline]
105    pub fn get_lookback(&self) -> usize {
106        self.params.lookback.unwrap_or(20)
107    }
108
109    #[inline]
110    pub fn get_annualization_days(&self) -> f64 {
111        self.params.annualization_days.unwrap_or(250.0)
112    }
113}
114
115#[derive(Copy, Clone, Debug)]
116pub struct HistoricalVolatilityBuilder {
117    lookback: Option<usize>,
118    annualization_days: Option<f64>,
119    kernel: Kernel,
120}
121
122impl Default for HistoricalVolatilityBuilder {
123    fn default() -> Self {
124        Self {
125            lookback: None,
126            annualization_days: None,
127            kernel: Kernel::Auto,
128        }
129    }
130}
131
132impl HistoricalVolatilityBuilder {
133    #[inline(always)]
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    #[inline(always)]
139    pub fn lookback(mut self, lookback: usize) -> Self {
140        self.lookback = Some(lookback);
141        self
142    }
143
144    #[inline(always)]
145    pub fn annualization_days(mut self, annualization_days: f64) -> Self {
146        self.annualization_days = Some(annualization_days);
147        self
148    }
149
150    #[inline(always)]
151    pub fn kernel(mut self, kernel: Kernel) -> Self {
152        self.kernel = kernel;
153        self
154    }
155
156    #[inline(always)]
157    pub fn apply(
158        self,
159        candles: &Candles,
160        source: &str,
161    ) -> Result<HistoricalVolatilityOutput, HistoricalVolatilityError> {
162        let input = HistoricalVolatilityInput::from_candles(
163            candles,
164            source,
165            HistoricalVolatilityParams {
166                lookback: self.lookback,
167                annualization_days: self.annualization_days,
168            },
169        );
170        historical_volatility_with_kernel(&input, self.kernel)
171    }
172
173    #[inline(always)]
174    pub fn apply_slice(
175        self,
176        data: &[f64],
177    ) -> Result<HistoricalVolatilityOutput, HistoricalVolatilityError> {
178        let input = HistoricalVolatilityInput::from_slice(
179            data,
180            HistoricalVolatilityParams {
181                lookback: self.lookback,
182                annualization_days: self.annualization_days,
183            },
184        );
185        historical_volatility_with_kernel(&input, self.kernel)
186    }
187
188    #[inline(always)]
189    pub fn into_stream(self) -> Result<HistoricalVolatilityStream, HistoricalVolatilityError> {
190        HistoricalVolatilityStream::try_new(HistoricalVolatilityParams {
191            lookback: self.lookback,
192            annualization_days: self.annualization_days,
193        })
194    }
195}
196
197#[derive(Debug, Error)]
198pub enum HistoricalVolatilityError {
199    #[error("historical_volatility: Input data slice is empty.")]
200    EmptyInputData,
201    #[error("historical_volatility: All values are NaN or do not produce valid returns.")]
202    AllValuesNaN,
203    #[error(
204        "historical_volatility: Invalid lookback: lookback = {lookback}, data length = {data_len}"
205    )]
206    InvalidLookback { lookback: usize, data_len: usize },
207    #[error("historical_volatility: Not enough valid data: needed = {needed}, valid = {valid}")]
208    NotEnoughValidData { needed: usize, valid: usize },
209    #[error(
210        "historical_volatility: Invalid annualization_days: {annualization_days}. Must be finite and > 0."
211    )]
212    InvalidAnnualizationDays { annualization_days: f64 },
213    #[error("historical_volatility: Output length mismatch: expected = {expected}, got = {got}")]
214    OutputLengthMismatch { expected: usize, got: usize },
215    #[error("historical_volatility: Invalid range: start={start}, end={end}, step={step}")]
216    InvalidRange {
217        start: String,
218        end: String,
219        step: String,
220    },
221    #[error("historical_volatility: Invalid kernel for batch: {0:?}")]
222    InvalidKernelForBatch(Kernel),
223}
224
225#[derive(Debug, Clone)]
226pub struct HistoricalVolatilityStream {
227    lookback: usize,
228    annualization_scale: f64,
229    prev: f64,
230    has_prev: bool,
231    returns: Vec<f64>,
232    valid: Vec<u8>,
233    idx: usize,
234    cnt: usize,
235    valid_count: usize,
236    sum: f64,
237    sumsq: f64,
238}
239
240impl HistoricalVolatilityStream {
241    pub fn try_new(
242        params: HistoricalVolatilityParams,
243    ) -> Result<HistoricalVolatilityStream, HistoricalVolatilityError> {
244        let lookback = params.lookback.unwrap_or(20);
245        if lookback == 0 {
246            return Err(HistoricalVolatilityError::InvalidLookback {
247                lookback,
248                data_len: 0,
249            });
250        }
251        let annualization_days = params.annualization_days.unwrap_or(250.0);
252        if !annualization_days.is_finite() || annualization_days <= 0.0 {
253            return Err(HistoricalVolatilityError::InvalidAnnualizationDays { annualization_days });
254        }
255        Ok(Self {
256            lookback,
257            annualization_scale: annualization_days.sqrt(),
258            prev: f64::NAN,
259            has_prev: false,
260            returns: vec![0.0; lookback],
261            valid: vec![0u8; lookback],
262            idx: 0,
263            cnt: 0,
264            valid_count: 0,
265            sum: 0.0,
266            sumsq: 0.0,
267        })
268    }
269
270    #[inline(always)]
271    pub fn update(&mut self, value: f64) -> Option<f64> {
272        if !self.has_prev {
273            self.prev = value;
274            self.has_prev = true;
275            return None;
276        }
277
278        if self.cnt >= self.lookback {
279            let old_idx = self.idx;
280            if self.valid[old_idx] != 0 {
281                let old = self.returns[old_idx];
282                self.valid_count = self.valid_count.saturating_sub(1);
283                self.sum -= old;
284                self.sumsq -= old * old;
285            }
286        } else {
287            self.cnt += 1;
288        }
289
290        if valid_return_pair(self.prev, value) {
291            let ret = pct_return(self.prev, value);
292            self.returns[self.idx] = ret;
293            self.valid[self.idx] = 1;
294            self.valid_count += 1;
295            self.sum += ret;
296            self.sumsq += ret * ret;
297        } else {
298            self.returns[self.idx] = 0.0;
299            self.valid[self.idx] = 0;
300        }
301
302        self.prev = value;
303        self.idx += 1;
304        if self.idx == self.lookback {
305            self.idx = 0;
306        }
307
308        if self.cnt < self.lookback {
309            return None;
310        }
311        if self.valid_count != self.lookback {
312            return Some(f64::NAN);
313        }
314
315        let mean = self.sum / self.lookback as f64;
316        let variance = ((self.sumsq / self.lookback as f64) - mean * mean).max(0.0);
317        Some(variance.sqrt() * self.annualization_scale)
318    }
319
320    #[inline(always)]
321    pub fn get_warmup_period(&self) -> usize {
322        self.lookback
323    }
324}
325
326#[inline]
327pub fn historical_volatility(
328    input: &HistoricalVolatilityInput,
329) -> Result<HistoricalVolatilityOutput, HistoricalVolatilityError> {
330    historical_volatility_with_kernel(input, Kernel::Auto)
331}
332
333#[inline(always)]
334fn valid_return_pair(prev: f64, curr: f64) -> bool {
335    prev.is_finite() && curr.is_finite() && prev != 0.0
336}
337
338#[inline(always)]
339fn pct_return(prev: f64, curr: f64) -> f64 {
340    ((curr / prev) - 1.0) * 100.0
341}
342
343#[inline(always)]
344fn first_valid_return(data: &[f64]) -> usize {
345    let len = data.len();
346    let mut i = 1usize;
347    while i < len {
348        if valid_return_pair(data[i - 1], data[i]) {
349            return i;
350        }
351        i += 1;
352    }
353    len
354}
355
356#[inline(always)]
357fn count_valid_returns(data: &[f64]) -> usize {
358    let mut count = 0usize;
359    for i in 1..data.len() {
360        if valid_return_pair(data[i - 1], data[i]) {
361            count += 1;
362        }
363    }
364    count
365}
366
367#[inline(always)]
368fn build_return_prefixes(data: &[f64]) -> (Vec<u32>, Vec<f64>, Vec<f64>) {
369    let len = data.len();
370    let mut prefix_valid = vec![0u32; len + 1];
371    let mut prefix_sum = vec![0.0f64; len + 1];
372    let mut prefix_sumsq = vec![0.0f64; len + 1];
373
374    for i in 0..len {
375        prefix_valid[i + 1] = prefix_valid[i];
376        prefix_sum[i + 1] = prefix_sum[i];
377        prefix_sumsq[i + 1] = prefix_sumsq[i];
378
379        if i == 0 || !valid_return_pair(data[i - 1], data[i]) {
380            continue;
381        }
382
383        let ret = pct_return(data[i - 1], data[i]);
384        prefix_valid[i + 1] += 1;
385        prefix_sum[i + 1] += ret;
386        prefix_sumsq[i + 1] += ret * ret;
387    }
388
389    (prefix_valid, prefix_sum, prefix_sumsq)
390}
391
392#[inline(always)]
393fn hv_row_from_prefix(
394    prefix_valid: &[u32],
395    prefix_sum: &[f64],
396    prefix_sumsq: &[f64],
397    lookback: usize,
398    annualization_scale: f64,
399    first: usize,
400    out: &mut [f64],
401) {
402    let warmup = first.saturating_add(lookback.saturating_sub(1));
403    let lookback_u32 = lookback as u32;
404    let inv_lb = 1.0 / lookback as f64;
405
406    for (t, slot) in out.iter_mut().enumerate() {
407        if t < warmup {
408            *slot = f64::NAN;
409            continue;
410        }
411
412        let window_start = t + 1 - lookback;
413        let valid_count = prefix_valid[t + 1] - prefix_valid[window_start];
414        if valid_count != lookback_u32 {
415            *slot = f64::NAN;
416            continue;
417        }
418
419        let sum = prefix_sum[t + 1] - prefix_sum[window_start];
420        let sumsq = prefix_sumsq[t + 1] - prefix_sumsq[window_start];
421        let mean = sum * inv_lb;
422        let variance = (sumsq * inv_lb - mean * mean).max(0.0);
423        *slot = variance.sqrt() * annualization_scale;
424    }
425}
426
427#[inline(always)]
428fn historical_volatility_prepare<'a>(
429    input: &'a HistoricalVolatilityInput,
430    kernel: Kernel,
431) -> Result<(&'a [f64], usize, usize, f64, Kernel), HistoricalVolatilityError> {
432    let data = input.as_ref();
433    let len = data.len();
434    if len == 0 {
435        return Err(HistoricalVolatilityError::EmptyInputData);
436    }
437
438    let first = first_valid_return(data);
439    if first >= len {
440        return Err(HistoricalVolatilityError::AllValuesNaN);
441    }
442
443    let lookback = input.get_lookback();
444    if lookback == 0 || lookback > len {
445        return Err(HistoricalVolatilityError::InvalidLookback {
446            lookback,
447            data_len: len,
448        });
449    }
450
451    let annualization_days = input.get_annualization_days();
452    if !annualization_days.is_finite() || annualization_days <= 0.0 {
453        return Err(HistoricalVolatilityError::InvalidAnnualizationDays { annualization_days });
454    }
455
456    let valid = count_valid_returns(data);
457    if valid < lookback {
458        return Err(HistoricalVolatilityError::NotEnoughValidData {
459            needed: lookback,
460            valid,
461        });
462    }
463
464    let chosen = match kernel {
465        Kernel::Auto => detect_best_kernel(),
466        other => other.to_non_batch(),
467    };
468
469    Ok((data, lookback, first, annualization_days.sqrt(), chosen))
470}
471
472#[inline]
473pub fn historical_volatility_with_kernel(
474    input: &HistoricalVolatilityInput,
475    kernel: Kernel,
476) -> Result<HistoricalVolatilityOutput, HistoricalVolatilityError> {
477    let (data, lookback, first, annualization_scale, _chosen) =
478        historical_volatility_prepare(input, kernel)?;
479    let mut values =
480        alloc_with_nan_prefix(data.len(), first.saturating_add(lookback.saturating_sub(1)));
481    let (prefix_valid, prefix_sum, prefix_sumsq) = build_return_prefixes(data);
482    hv_row_from_prefix(
483        &prefix_valid,
484        &prefix_sum,
485        &prefix_sumsq,
486        lookback,
487        annualization_scale,
488        first,
489        &mut values,
490    );
491    Ok(HistoricalVolatilityOutput { values })
492}
493
494#[inline]
495pub fn historical_volatility_into_slice(
496    dst: &mut [f64],
497    input: &HistoricalVolatilityInput,
498    kernel: Kernel,
499) -> Result<(), HistoricalVolatilityError> {
500    let (data, lookback, first, annualization_scale, _chosen) =
501        historical_volatility_prepare(input, kernel)?;
502    if dst.len() != data.len() {
503        return Err(HistoricalVolatilityError::OutputLengthMismatch {
504            expected: data.len(),
505            got: dst.len(),
506        });
507    }
508    let (prefix_valid, prefix_sum, prefix_sumsq) = build_return_prefixes(data);
509    hv_row_from_prefix(
510        &prefix_valid,
511        &prefix_sum,
512        &prefix_sumsq,
513        lookback,
514        annualization_scale,
515        first,
516        dst,
517    );
518    Ok(())
519}
520
521#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
522#[inline]
523pub fn historical_volatility_into(
524    input: &HistoricalVolatilityInput,
525    out: &mut [f64],
526) -> Result<(), HistoricalVolatilityError> {
527    historical_volatility_into_slice(out, input, Kernel::Auto)
528}
529
530#[derive(Clone, Debug)]
531pub struct HistoricalVolatilityBatchRange {
532    pub lookback: (usize, usize, usize),
533    pub annualization_days: (f64, f64, f64),
534}
535
536impl Default for HistoricalVolatilityBatchRange {
537    fn default() -> Self {
538        Self {
539            lookback: (20, 252, 1),
540            annualization_days: (250.0, 250.0, 0.0),
541        }
542    }
543}
544
545#[derive(Clone, Debug, Default)]
546pub struct HistoricalVolatilityBatchBuilder {
547    range: HistoricalVolatilityBatchRange,
548    kernel: Kernel,
549}
550
551impl HistoricalVolatilityBatchBuilder {
552    #[inline]
553    pub fn new() -> Self {
554        Self::default()
555    }
556
557    #[inline]
558    pub fn kernel(mut self, kernel: Kernel) -> Self {
559        self.kernel = kernel;
560        self
561    }
562
563    #[inline]
564    pub fn lookback_range(mut self, start: usize, end: usize, step: usize) -> Self {
565        self.range.lookback = (start, end, step);
566        self
567    }
568
569    #[inline]
570    pub fn lookback_static(mut self, lookback: usize) -> Self {
571        self.range.lookback = (lookback, lookback, 0);
572        self
573    }
574
575    #[inline]
576    pub fn annualization_days_range(mut self, start: f64, end: f64, step: f64) -> Self {
577        self.range.annualization_days = (start, end, step);
578        self
579    }
580
581    #[inline]
582    pub fn annualization_days_static(mut self, annualization_days: f64) -> Self {
583        self.range.annualization_days = (annualization_days, annualization_days, 0.0);
584        self
585    }
586
587    #[inline]
588    pub fn apply_slice(
589        self,
590        data: &[f64],
591    ) -> Result<HistoricalVolatilityBatchOutput, HistoricalVolatilityError> {
592        historical_volatility_batch_with_kernel(data, &self.range, self.kernel)
593    }
594
595    #[inline]
596    pub fn apply_candles(
597        self,
598        candles: &Candles,
599        source: &str,
600    ) -> Result<HistoricalVolatilityBatchOutput, HistoricalVolatilityError> {
601        self.apply_slice(source_type(candles, source))
602    }
603
604    #[inline]
605    pub fn with_default_candles(
606        candles: &Candles,
607    ) -> Result<HistoricalVolatilityBatchOutput, HistoricalVolatilityError> {
608        HistoricalVolatilityBatchBuilder::new()
609            .kernel(Kernel::Auto)
610            .apply_candles(candles, "close")
611    }
612}
613
614#[derive(Clone, Debug)]
615pub struct HistoricalVolatilityBatchOutput {
616    pub values: Vec<f64>,
617    pub combos: Vec<HistoricalVolatilityParams>,
618    pub rows: usize,
619    pub cols: usize,
620}
621
622impl HistoricalVolatilityBatchOutput {
623    pub fn row_for_params(&self, params: &HistoricalVolatilityParams) -> Option<usize> {
624        self.combos.iter().position(|combo| {
625            combo.lookback.unwrap_or(20) == params.lookback.unwrap_or(20)
626                && (combo.annualization_days.unwrap_or(250.0)
627                    - params.annualization_days.unwrap_or(250.0))
628                .abs()
629                    < 1e-12
630        })
631    }
632
633    pub fn values_for(&self, params: &HistoricalVolatilityParams) -> Option<&[f64]> {
634        self.row_for_params(params).and_then(|row| {
635            row.checked_mul(self.cols)
636                .and_then(|start| self.values.get(start..start + self.cols))
637        })
638    }
639}
640
641#[inline(always)]
642fn expand_grid_historical_volatility(
643    range: &HistoricalVolatilityBatchRange,
644) -> Result<Vec<HistoricalVolatilityParams>, HistoricalVolatilityError> {
645    fn axis_usize(
646        (start, end, step): (usize, usize, usize),
647    ) -> Result<Vec<usize>, HistoricalVolatilityError> {
648        if step == 0 || start == end {
649            return Ok(vec![start]);
650        }
651
652        let mut out = Vec::new();
653        if start < end {
654            let mut x = start;
655            while x <= end {
656                out.push(x);
657                let next = x.saturating_add(step);
658                if next == x {
659                    break;
660                }
661                x = next;
662            }
663        } else {
664            let mut x = start;
665            loop {
666                out.push(x);
667                if x == end {
668                    break;
669                }
670                let next = x.saturating_sub(step);
671                if next == x || next < end {
672                    break;
673                }
674                x = next;
675            }
676        }
677
678        if out.is_empty() {
679            return Err(HistoricalVolatilityError::InvalidRange {
680                start: start.to_string(),
681                end: end.to_string(),
682                step: step.to_string(),
683            });
684        }
685        Ok(out)
686    }
687
688    fn axis_f64(
689        (start, end, step): (f64, f64, f64),
690    ) -> Result<Vec<f64>, HistoricalVolatilityError> {
691        if !start.is_finite() || !end.is_finite() || !step.is_finite() {
692            return Err(HistoricalVolatilityError::InvalidRange {
693                start: start.to_string(),
694                end: end.to_string(),
695                step: step.to_string(),
696            });
697        }
698        if step.abs() < 1e-12 || (start - end).abs() < 1e-12 {
699            return Ok(vec![start]);
700        }
701
702        let mut out = Vec::new();
703        if start < end {
704            let st = step.abs();
705            let mut x = start;
706            while x <= end + 1e-12 {
707                out.push(x);
708                x += st;
709            }
710        } else {
711            let st = -step.abs();
712            let mut x = start;
713            while x >= end - 1e-12 {
714                out.push(x);
715                x += st;
716            }
717        }
718
719        if out.is_empty() {
720            return Err(HistoricalVolatilityError::InvalidRange {
721                start: start.to_string(),
722                end: end.to_string(),
723                step: step.to_string(),
724            });
725        }
726        Ok(out)
727    }
728
729    let lookbacks = axis_usize(range.lookback)?;
730    if lookbacks.iter().any(|&lookback| lookback == 0) {
731        return Err(HistoricalVolatilityError::InvalidLookback {
732            lookback: 0,
733            data_len: 0,
734        });
735    }
736
737    let annualization_days = axis_f64(range.annualization_days)?;
738    if let Some(&bad) = annualization_days
739        .iter()
740        .find(|&&annualization_days| !annualization_days.is_finite() || annualization_days <= 0.0)
741    {
742        return Err(HistoricalVolatilityError::InvalidAnnualizationDays {
743            annualization_days: bad,
744        });
745    }
746
747    let mut out = Vec::with_capacity(lookbacks.len() * annualization_days.len());
748    for &lookback in &lookbacks {
749        for &annualization_days in &annualization_days {
750            out.push(HistoricalVolatilityParams {
751                lookback: Some(lookback),
752                annualization_days: Some(annualization_days),
753            });
754        }
755    }
756    Ok(out)
757}
758
759#[inline]
760pub fn historical_volatility_batch_with_kernel(
761    data: &[f64],
762    sweep: &HistoricalVolatilityBatchRange,
763    kernel: Kernel,
764) -> Result<HistoricalVolatilityBatchOutput, HistoricalVolatilityError> {
765    let batch_kernel = match kernel {
766        Kernel::Auto => detect_best_batch_kernel(),
767        other if other.is_batch() => other,
768        other => return Err(HistoricalVolatilityError::InvalidKernelForBatch(other)),
769    };
770    historical_volatility_batch_par_slice(data, sweep, batch_kernel.to_non_batch())
771}
772
773#[inline]
774pub fn historical_volatility_batch_slice(
775    data: &[f64],
776    sweep: &HistoricalVolatilityBatchRange,
777    kernel: Kernel,
778) -> Result<HistoricalVolatilityBatchOutput, HistoricalVolatilityError> {
779    historical_volatility_batch_inner(data, sweep, kernel, false)
780}
781
782#[inline]
783pub fn historical_volatility_batch_par_slice(
784    data: &[f64],
785    sweep: &HistoricalVolatilityBatchRange,
786    kernel: Kernel,
787) -> Result<HistoricalVolatilityBatchOutput, HistoricalVolatilityError> {
788    historical_volatility_batch_inner(data, sweep, kernel, true)
789}
790
791#[inline(always)]
792fn historical_volatility_batch_inner(
793    data: &[f64],
794    sweep: &HistoricalVolatilityBatchRange,
795    _kernel: Kernel,
796    parallel: bool,
797) -> Result<HistoricalVolatilityBatchOutput, HistoricalVolatilityError> {
798    let combos = expand_grid_historical_volatility(sweep)?;
799    let rows = combos.len();
800    let cols = data.len();
801    if cols == 0 {
802        return Err(HistoricalVolatilityError::EmptyInputData);
803    }
804    let first = first_valid_return(data);
805    if first >= cols {
806        return Err(HistoricalVolatilityError::AllValuesNaN);
807    }
808    let valid = count_valid_returns(data);
809    let max_lookback = combos
810        .iter()
811        .map(|combo| combo.lookback.unwrap_or(20))
812        .max()
813        .unwrap_or(0);
814    if max_lookback == 0 || valid < max_lookback {
815        return Err(HistoricalVolatilityError::NotEnoughValidData {
816            needed: max_lookback,
817            valid,
818        });
819    }
820
821    let mut buf_mu = make_uninit_matrix(rows, cols);
822    let warmups: Vec<usize> = combos
823        .iter()
824        .map(|combo| first.saturating_add(combo.lookback.unwrap_or(20).saturating_sub(1)))
825        .collect();
826    init_matrix_prefixes(&mut buf_mu, cols, &warmups);
827
828    let mut guard = ManuallyDrop::new(buf_mu);
829    let out: &mut [f64] =
830        unsafe { std::slice::from_raw_parts_mut(guard.as_mut_ptr() as *mut f64, guard.len()) };
831
832    let (prefix_valid, prefix_sum, prefix_sumsq) = build_return_prefixes(data);
833
834    if parallel {
835        #[cfg(not(target_arch = "wasm32"))]
836        out.par_chunks_mut(cols)
837            .enumerate()
838            .for_each(|(row, out_row)| {
839                let combo = &combos[row];
840                hv_row_from_prefix(
841                    &prefix_valid,
842                    &prefix_sum,
843                    &prefix_sumsq,
844                    combo.lookback.unwrap_or(20),
845                    combo.annualization_days.unwrap_or(250.0).sqrt(),
846                    first,
847                    out_row,
848                );
849            });
850
851        #[cfg(target_arch = "wasm32")]
852        for (row, out_row) in out.chunks_mut(cols).enumerate() {
853            let combo = &combos[row];
854            hv_row_from_prefix(
855                &prefix_valid,
856                &prefix_sum,
857                &prefix_sumsq,
858                combo.lookback.unwrap_or(20),
859                combo.annualization_days.unwrap_or(250.0).sqrt(),
860                first,
861                out_row,
862            );
863        }
864    } else {
865        for (row, out_row) in out.chunks_mut(cols).enumerate() {
866            let combo = &combos[row];
867            hv_row_from_prefix(
868                &prefix_valid,
869                &prefix_sum,
870                &prefix_sumsq,
871                combo.lookback.unwrap_or(20),
872                combo.annualization_days.unwrap_or(250.0).sqrt(),
873                first,
874                out_row,
875            );
876        }
877    }
878
879    let values = unsafe {
880        Vec::from_raw_parts(
881            guard.as_mut_ptr() as *mut f64,
882            guard.len(),
883            guard.capacity(),
884        )
885    };
886
887    Ok(HistoricalVolatilityBatchOutput {
888        values,
889        combos,
890        rows,
891        cols,
892    })
893}
894
895#[inline(always)]
896pub fn historical_volatility_batch_inner_into(
897    data: &[f64],
898    sweep: &HistoricalVolatilityBatchRange,
899    _kernel: Kernel,
900    parallel: bool,
901    out: &mut [f64],
902) -> Result<Vec<HistoricalVolatilityParams>, HistoricalVolatilityError> {
903    let combos = expand_grid_historical_volatility(sweep)?;
904    let rows = combos.len();
905    let cols = data.len();
906    if cols == 0 {
907        return Err(HistoricalVolatilityError::EmptyInputData);
908    }
909    let total =
910        rows.checked_mul(cols)
911            .ok_or_else(|| HistoricalVolatilityError::OutputLengthMismatch {
912                expected: usize::MAX,
913                got: out.len(),
914            })?;
915    if out.len() != total {
916        return Err(HistoricalVolatilityError::OutputLengthMismatch {
917            expected: total,
918            got: out.len(),
919        });
920    }
921    let first = first_valid_return(data);
922    if first >= cols {
923        return Err(HistoricalVolatilityError::AllValuesNaN);
924    }
925    let valid = count_valid_returns(data);
926    let max_lookback = combos
927        .iter()
928        .map(|combo| combo.lookback.unwrap_or(20))
929        .max()
930        .unwrap_or(0);
931    if max_lookback == 0 || valid < max_lookback {
932        return Err(HistoricalVolatilityError::NotEnoughValidData {
933            needed: max_lookback,
934            valid,
935        });
936    }
937
938    let (prefix_valid, prefix_sum, prefix_sumsq) = build_return_prefixes(data);
939
940    if parallel {
941        #[cfg(not(target_arch = "wasm32"))]
942        out.par_chunks_mut(cols)
943            .enumerate()
944            .for_each(|(row, out_row)| {
945                let combo = &combos[row];
946                hv_row_from_prefix(
947                    &prefix_valid,
948                    &prefix_sum,
949                    &prefix_sumsq,
950                    combo.lookback.unwrap_or(20),
951                    combo.annualization_days.unwrap_or(250.0).sqrt(),
952                    first,
953                    out_row,
954                );
955            });
956
957        #[cfg(target_arch = "wasm32")]
958        for (row, out_row) in out.chunks_mut(cols).enumerate() {
959            let combo = &combos[row];
960            hv_row_from_prefix(
961                &prefix_valid,
962                &prefix_sum,
963                &prefix_sumsq,
964                combo.lookback.unwrap_or(20),
965                combo.annualization_days.unwrap_or(250.0).sqrt(),
966                first,
967                out_row,
968            );
969        }
970    } else {
971        for (row, out_row) in out.chunks_mut(cols).enumerate() {
972            let combo = &combos[row];
973            hv_row_from_prefix(
974                &prefix_valid,
975                &prefix_sum,
976                &prefix_sumsq,
977                combo.lookback.unwrap_or(20),
978                combo.annualization_days.unwrap_or(250.0).sqrt(),
979                first,
980                out_row,
981            );
982        }
983    }
984
985    Ok(combos)
986}
987
988#[cfg(feature = "python")]
989#[pyfunction(name = "historical_volatility")]
990#[pyo3(signature = (data, lookback=20, annualization_days=250.0, kernel=None))]
991pub fn historical_volatility_py<'py>(
992    py: Python<'py>,
993    data: PyReadonlyArray1<'py, f64>,
994    lookback: usize,
995    annualization_days: f64,
996    kernel: Option<&str>,
997) -> PyResult<Bound<'py, PyArray1<f64>>> {
998    let slice = data.as_slice()?;
999    let kernel = validate_kernel(kernel, false)?;
1000    let input = HistoricalVolatilityInput::from_slice(
1001        slice,
1002        HistoricalVolatilityParams {
1003            lookback: Some(lookback),
1004            annualization_days: Some(annualization_days),
1005        },
1006    );
1007    let output = py
1008        .allow_threads(|| historical_volatility_with_kernel(&input, kernel))
1009        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1010    Ok(output.values.into_pyarray(py))
1011}
1012
1013#[cfg(feature = "python")]
1014#[pyclass(name = "HistoricalVolatilityStream")]
1015pub struct HistoricalVolatilityStreamPy {
1016    stream: HistoricalVolatilityStream,
1017}
1018
1019#[cfg(feature = "python")]
1020#[pymethods]
1021impl HistoricalVolatilityStreamPy {
1022    #[new]
1023    fn new(lookback: usize, annualization_days: f64) -> PyResult<Self> {
1024        let stream = HistoricalVolatilityStream::try_new(HistoricalVolatilityParams {
1025            lookback: Some(lookback),
1026            annualization_days: Some(annualization_days),
1027        })
1028        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1029        Ok(Self { stream })
1030    }
1031
1032    fn update(&mut self, value: f64) -> Option<f64> {
1033        self.stream.update(value)
1034    }
1035}
1036
1037#[cfg(feature = "python")]
1038#[pyfunction(name = "historical_volatility_batch")]
1039#[pyo3(signature = (data, lookback_range, annualization_days_range=(250.0, 250.0, 0.0), kernel=None))]
1040pub fn historical_volatility_batch_py<'py>(
1041    py: Python<'py>,
1042    data: PyReadonlyArray1<'py, f64>,
1043    lookback_range: (usize, usize, usize),
1044    annualization_days_range: (f64, f64, f64),
1045    kernel: Option<&str>,
1046) -> PyResult<Bound<'py, PyDict>> {
1047    let slice = data.as_slice()?;
1048    let kernel = validate_kernel(kernel, true)?;
1049    let sweep = HistoricalVolatilityBatchRange {
1050        lookback: lookback_range,
1051        annualization_days: annualization_days_range,
1052    };
1053
1054    let combos = expand_grid_historical_volatility(&sweep)
1055        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1056    let rows = combos.len();
1057    let cols = slice.len();
1058    let total = rows
1059        .checked_mul(cols)
1060        .ok_or_else(|| PyValueError::new_err("rows*cols overflow"))?;
1061
1062    let out_arr = unsafe { PyArray1::<f64>::new(py, [total], false) };
1063    let slice_out = unsafe { out_arr.as_slice_mut()? };
1064
1065    let combos = py
1066        .allow_threads(|| {
1067            let batch = match kernel {
1068                Kernel::Auto => detect_best_batch_kernel(),
1069                other => other,
1070            };
1071            historical_volatility_batch_inner_into(
1072                slice,
1073                &sweep,
1074                batch.to_non_batch(),
1075                true,
1076                slice_out,
1077            )
1078        })
1079        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1080
1081    let dict = PyDict::new(py);
1082    dict.set_item("values", out_arr.reshape((rows, cols))?)?;
1083    dict.set_item(
1084        "lookbacks",
1085        combos
1086            .iter()
1087            .map(|combo| combo.lookback.unwrap_or(20) as u64)
1088            .collect::<Vec<_>>()
1089            .into_pyarray(py),
1090    )?;
1091    dict.set_item(
1092        "annualization_days",
1093        combos
1094            .iter()
1095            .map(|combo| combo.annualization_days.unwrap_or(250.0))
1096            .collect::<Vec<_>>()
1097            .into_pyarray(py),
1098    )?;
1099    dict.set_item("rows", rows)?;
1100    dict.set_item("cols", cols)?;
1101    Ok(dict)
1102}
1103
1104#[cfg(feature = "python")]
1105pub fn register_historical_volatility_module(
1106    module: &Bound<'_, pyo3::types::PyModule>,
1107) -> PyResult<()> {
1108    module.add_function(wrap_pyfunction!(historical_volatility_py, module)?)?;
1109    module.add_function(wrap_pyfunction!(historical_volatility_batch_py, module)?)?;
1110    module.add_class::<HistoricalVolatilityStreamPy>()?;
1111    Ok(())
1112}
1113
1114#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1115#[wasm_bindgen(js_name = "historical_volatility_js")]
1116pub fn historical_volatility_js(
1117    data: &[f64],
1118    lookback: usize,
1119    annualization_days: f64,
1120) -> Result<Vec<f64>, JsValue> {
1121    let input = HistoricalVolatilityInput::from_slice(
1122        data,
1123        HistoricalVolatilityParams {
1124            lookback: Some(lookback),
1125            annualization_days: Some(annualization_days),
1126        },
1127    );
1128    let mut output = vec![0.0; data.len()];
1129    historical_volatility_into_slice(&mut output, &input, Kernel::Auto)
1130        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1131    Ok(output)
1132}
1133
1134#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1135#[wasm_bindgen]
1136pub fn historical_volatility_alloc(len: usize) -> *mut f64 {
1137    let mut vec = Vec::<f64>::with_capacity(len);
1138    let ptr = vec.as_mut_ptr();
1139    std::mem::forget(vec);
1140    ptr
1141}
1142
1143#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1144#[wasm_bindgen]
1145pub fn historical_volatility_free(ptr: *mut f64, len: usize) {
1146    if !ptr.is_null() {
1147        unsafe {
1148            let _ = Vec::from_raw_parts(ptr, len, len);
1149        }
1150    }
1151}
1152
1153#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1154#[wasm_bindgen]
1155pub fn historical_volatility_into(
1156    in_ptr: *const f64,
1157    out_ptr: *mut f64,
1158    len: usize,
1159    lookback: usize,
1160    annualization_days: f64,
1161) -> Result<(), JsValue> {
1162    if in_ptr.is_null() || out_ptr.is_null() {
1163        return Err(JsValue::from_str("Null pointer provided"));
1164    }
1165
1166    unsafe {
1167        let data = std::slice::from_raw_parts(in_ptr, len);
1168        let input = HistoricalVolatilityInput::from_slice(
1169            data,
1170            HistoricalVolatilityParams {
1171                lookback: Some(lookback),
1172                annualization_days: Some(annualization_days),
1173            },
1174        );
1175
1176        if in_ptr == out_ptr {
1177            let mut tmp = vec![0.0; len];
1178            historical_volatility_into_slice(&mut tmp, &input, Kernel::Auto)
1179                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1180            std::slice::from_raw_parts_mut(out_ptr, len).copy_from_slice(&tmp);
1181        } else {
1182            let out = std::slice::from_raw_parts_mut(out_ptr, len);
1183            historical_volatility_into_slice(out, &input, Kernel::Auto)
1184                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1185        }
1186    }
1187
1188    Ok(())
1189}
1190
1191#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1192#[derive(Serialize, Deserialize)]
1193pub struct HistoricalVolatilityBatchConfig {
1194    pub lookback_range: (usize, usize, usize),
1195    pub annualization_days_range: Option<(f64, f64, f64)>,
1196}
1197
1198#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1199#[derive(Serialize, Deserialize)]
1200pub struct HistoricalVolatilityBatchJsOutput {
1201    pub values: Vec<f64>,
1202    pub combos: Vec<HistoricalVolatilityParams>,
1203    pub lookbacks: Vec<usize>,
1204    pub annualization_days: Vec<f64>,
1205    pub rows: usize,
1206    pub cols: usize,
1207}
1208
1209#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1210#[wasm_bindgen(js_name = "historical_volatility_batch_js")]
1211pub fn historical_volatility_batch_js(data: &[f64], config: JsValue) -> Result<JsValue, JsValue> {
1212    let config: HistoricalVolatilityBatchConfig = serde_wasm_bindgen::from_value(config)
1213        .map_err(|e| JsValue::from_str(&format!("Invalid config: {e}")))?;
1214    let sweep = HistoricalVolatilityBatchRange {
1215        lookback: config.lookback_range,
1216        annualization_days: config
1217            .annualization_days_range
1218            .unwrap_or((250.0, 250.0, 0.0)),
1219    };
1220    let output = historical_volatility_batch_inner(data, &sweep, detect_best_kernel(), false)
1221        .map_err(|e| JsValue::from_str(&e.to_string()))?;
1222    serde_wasm_bindgen::to_value(&HistoricalVolatilityBatchJsOutput {
1223        lookbacks: output
1224            .combos
1225            .iter()
1226            .map(|combo| combo.lookback.unwrap_or(20))
1227            .collect(),
1228        annualization_days: output
1229            .combos
1230            .iter()
1231            .map(|combo| combo.annualization_days.unwrap_or(250.0))
1232            .collect(),
1233        values: output.values,
1234        combos: output.combos,
1235        rows: output.rows,
1236        cols: output.cols,
1237    })
1238    .map_err(|e| JsValue::from_str(&e.to_string()))
1239}
1240
1241#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
1242#[wasm_bindgen]
1243pub fn historical_volatility_batch_into(
1244    in_ptr: *const f64,
1245    out_ptr: *mut f64,
1246    len: usize,
1247    lookback_start: usize,
1248    lookback_end: usize,
1249    lookback_step: usize,
1250    annualization_days_start: f64,
1251    annualization_days_end: f64,
1252    annualization_days_step: f64,
1253) -> Result<usize, JsValue> {
1254    if in_ptr.is_null() || out_ptr.is_null() {
1255        return Err(JsValue::from_str("Null pointer provided"));
1256    }
1257
1258    let sweep = HistoricalVolatilityBatchRange {
1259        lookback: (lookback_start, lookback_end, lookback_step),
1260        annualization_days: (
1261            annualization_days_start,
1262            annualization_days_end,
1263            annualization_days_step,
1264        ),
1265    };
1266    let combos =
1267        expand_grid_historical_volatility(&sweep).map_err(|e| JsValue::from_str(&e.to_string()))?;
1268    let rows = combos.len();
1269
1270    unsafe {
1271        let data = std::slice::from_raw_parts(in_ptr, len);
1272        let total = rows
1273            .checked_mul(len)
1274            .ok_or_else(|| JsValue::from_str("rows*cols overflow"))?;
1275        let out = std::slice::from_raw_parts_mut(out_ptr, total);
1276        historical_volatility_batch_inner_into(data, &sweep, detect_best_kernel(), false, out)
1277            .map_err(|e| JsValue::from_str(&e.to_string()))?;
1278    }
1279
1280    Ok(rows)
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285    use super::*;
1286    use crate::utilities::data_loader::read_candles_from_csv;
1287    use std::error::Error;
1288
1289    fn load_close() -> Result<Vec<f64>, Box<dyn Error>> {
1290        let candles = read_candles_from_csv("src/data/2018-09-01-2024-Bitfinex_Spot-4h.csv")?;
1291        Ok(candles.close)
1292    }
1293
1294    #[test]
1295    fn historical_volatility_output_contract() -> Result<(), Box<dyn Error>> {
1296        let close = load_close()?;
1297        let input = HistoricalVolatilityInput::from_slice(
1298            &close,
1299            HistoricalVolatilityParams {
1300                lookback: Some(20),
1301                annualization_days: Some(250.0),
1302            },
1303        );
1304        let out = historical_volatility_with_kernel(&input, Kernel::Scalar)?;
1305        assert_eq!(out.values.len(), close.len());
1306        let first_valid = out.values.iter().position(|v| !v.is_nan()).unwrap();
1307        assert!(first_valid >= 20);
1308        assert!(out.values[first_valid..].iter().any(|v| v.is_finite()));
1309        Ok(())
1310    }
1311
1312    #[test]
1313    fn historical_volatility_auto_matches_scalar() -> Result<(), Box<dyn Error>> {
1314        let close = load_close()?;
1315        let input = HistoricalVolatilityInput::from_slice(
1316            &close,
1317            HistoricalVolatilityParams {
1318                lookback: Some(30),
1319                annualization_days: Some(252.0),
1320            },
1321        );
1322        let auto = historical_volatility_with_kernel(&input, Kernel::Auto)?;
1323        let scalar = historical_volatility_with_kernel(&input, Kernel::Scalar)?;
1324        for (a, b) in auto.values.iter().zip(scalar.values.iter()) {
1325            if a.is_nan() && b.is_nan() {
1326                continue;
1327            }
1328            assert!((a - b).abs() <= 1e-12);
1329        }
1330        Ok(())
1331    }
1332
1333    #[test]
1334    fn historical_volatility_rejects_invalid_annualization_days() {
1335        let data = [100.0, 101.0, 102.0, 103.0];
1336        let input = HistoricalVolatilityInput::from_slice(
1337            &data,
1338            HistoricalVolatilityParams {
1339                lookback: Some(2),
1340                annualization_days: Some(0.0),
1341            },
1342        );
1343        let err = historical_volatility_with_kernel(&input, Kernel::Scalar).unwrap_err();
1344        assert!(matches!(
1345            err,
1346            HistoricalVolatilityError::InvalidAnnualizationDays { .. }
1347        ));
1348    }
1349
1350    #[test]
1351    fn historical_volatility_stream_matches_batch() -> Result<(), Box<dyn Error>> {
1352        let close = load_close()?;
1353        let params = HistoricalVolatilityParams {
1354            lookback: Some(20),
1355            annualization_days: Some(250.0),
1356        };
1357        let input = HistoricalVolatilityInput::from_slice(&close, params.clone());
1358        let batch = historical_volatility_with_kernel(&input, Kernel::Scalar)?;
1359        let mut stream = HistoricalVolatilityStream::try_new(params)?;
1360        let mut streamed = Vec::with_capacity(close.len());
1361        for &value in &close {
1362            streamed.push(stream.update(value).unwrap_or(f64::NAN));
1363        }
1364        for (a, b) in streamed.iter().zip(batch.values.iter()) {
1365            if a.is_nan() && b.is_nan() {
1366                continue;
1367            }
1368            assert!((a - b).abs() <= 1e-10);
1369        }
1370        Ok(())
1371    }
1372
1373    #[test]
1374    fn historical_volatility_batch_matches_single() -> Result<(), Box<dyn Error>> {
1375        let close = load_close()?;
1376        let sweep = HistoricalVolatilityBatchRange {
1377            lookback: (20, 20, 0),
1378            annualization_days: (250.0, 250.0, 0.0),
1379        };
1380        let batch = historical_volatility_batch_with_kernel(&close, &sweep, Kernel::ScalarBatch)?;
1381        assert_eq!(batch.rows, 1);
1382        assert_eq!(batch.cols, close.len());
1383        let single = historical_volatility_with_kernel(
1384            &HistoricalVolatilityInput::from_slice(
1385                &close,
1386                HistoricalVolatilityParams {
1387                    lookback: Some(20),
1388                    annualization_days: Some(250.0),
1389                },
1390            ),
1391            Kernel::Scalar,
1392        )?;
1393        for (a, b) in batch.values.iter().zip(single.values.iter()) {
1394            if a.is_nan() && b.is_nan() {
1395                continue;
1396            }
1397            assert!((a - b).abs() <= 1e-12);
1398        }
1399        Ok(())
1400    }
1401
1402    #[test]
1403    fn historical_volatility_nan_window_recovers() -> Result<(), Box<dyn Error>> {
1404        let mut close = load_close()?;
1405        close[40] = f64::NAN;
1406        let out = historical_volatility_with_kernel(
1407            &HistoricalVolatilityInput::from_slice(
1408                &close,
1409                HistoricalVolatilityParams {
1410                    lookback: Some(10),
1411                    annualization_days: Some(250.0),
1412                },
1413            ),
1414            Kernel::Scalar,
1415        )?;
1416        assert!(out.values[40].is_nan());
1417        assert!(out.values[49].is_nan());
1418        assert!(out.values[50].is_nan());
1419        assert!(out.values[51].is_finite());
1420        Ok(())
1421    }
1422}