use scirs2_core::ndarray::Array1;
use scirs2_core::numeric::Float;
use crate::error::{Result, TimeSeriesError};
#[derive(Debug, Clone)]
pub struct DrawdownPeriod<F: Float> {
pub start_index: usize,
pub end_index: usize,
pub recovery_index: Option<usize>,
pub max_drawdown: F,
pub duration: usize,
pub recovery_periods: Option<usize>,
pub peak_value: F,
pub trough_value: F,
}
pub fn max_drawdown<F: Float + Clone>(values: &Array1<F>) -> Result<F> {
if values.is_empty() {
return Err(TimeSeriesError::InvalidInput(
"Values cannot be empty".to_string(),
));
}
let mut max_value = values[0];
let mut max_dd = F::zero();
for &value in values.iter() {
if value > max_value {
max_value = value;
}
let drawdown = (max_value - value) / max_value;
if drawdown > max_dd {
max_dd = drawdown;
}
}
Ok(max_dd)
}
pub fn calculate_drawdown_series<F: Float + Clone>(values: &Array1<F>) -> Result<Array1<F>> {
if values.is_empty() {
return Err(TimeSeriesError::InvalidInput(
"Values cannot be empty".to_string(),
));
}
let mut drawdowns = Array1::zeros(values.len());
let mut peak = values[0];
for i in 0..values.len() {
if values[i] > peak {
peak = values[i];
}
drawdowns[i] = (values[i] - peak) / peak;
}
Ok(drawdowns)
}
pub fn pain_index<F: Float + Clone>(values: &Array1<F>) -> Result<F> {
let drawdowns = calculate_drawdown_series(values)?;
let total_drawdown = drawdowns.mapv(|x| -x).sum();
Ok(total_drawdown / F::from(values.len()).expect("Operation failed"))
}
pub fn ulcer_index<F: Float + Clone>(values: &Array1<F>) -> Result<F> {
let drawdowns = calculate_drawdown_series(values)?;
let sum_squared_dd = drawdowns.mapv(|x| x.powi(2)).sum();
Ok((sum_squared_dd / F::from(values.len()).expect("Operation failed")).sqrt())
}
pub fn calmar_ratio<F: Float + Clone>(
returns: &Array1<F>,
values: &Array1<F>,
periods_per_year: usize,
) -> Result<F> {
if returns.is_empty() || values.is_empty() {
return Err(TimeSeriesError::InvalidInput(
"Returns and values cannot be empty".to_string(),
));
}
let total_return = (values[values.len() - 1] / values[0]) - F::one();
let years = F::from(returns.len()).expect("Operation failed")
/ F::from(periods_per_year).expect("Failed to convert to float");
let annualized_return = (F::one() + total_return).powf(F::one() / years) - F::one();
let mdd = max_drawdown(values)?;
if mdd == F::zero() {
Ok(F::infinity())
} else {
Ok(annualized_return / mdd)
}
}
pub fn drawdown_recovery_analysis<F: Float + Clone>(
values: &Array1<F>,
) -> Result<Vec<DrawdownPeriod<F>>> {
if values.is_empty() {
return Err(TimeSeriesError::InvalidInput(
"Values cannot be empty".to_string(),
));
}
let mut periods = Vec::new();
let mut peak = values[0];
let mut peak_index = 0;
let mut in_drawdown = false;
let mut trough_value = values[0];
let mut trough_index = 0;
for i in 0..values.len() {
if values[i] > peak {
if in_drawdown {
let recovery_index = find_recovery_index(values, i, peak);
let duration = trough_index - peak_index;
let recovery_periods = recovery_index.map(|idx| idx - trough_index);
let max_dd = (peak - trough_value) / peak;
periods.push(DrawdownPeriod {
start_index: peak_index,
end_index: trough_index,
recovery_index,
max_drawdown: max_dd,
duration,
recovery_periods,
peak_value: peak,
trough_value,
});
in_drawdown = false;
}
peak = values[i];
peak_index = i;
} else if values[i] < peak {
if !in_drawdown {
in_drawdown = true;
trough_value = values[i];
trough_index = i;
} else if values[i] < trough_value {
trough_value = values[i];
trough_index = i;
}
}
}
if in_drawdown {
let duration = trough_index - peak_index;
let max_dd = (peak - trough_value) / peak;
periods.push(DrawdownPeriod {
start_index: peak_index,
end_index: trough_index,
recovery_index: None,
max_drawdown: max_dd,
duration,
recovery_periods: None,
peak_value: peak,
trough_value,
});
}
Ok(periods)
}
pub fn max_consecutive_losses<F: Float + Clone>(returns: &Array1<F>) -> usize {
let mut max_consecutive = 0;
let mut current_consecutive = 0;
for &ret in returns.iter() {
if ret < F::zero() {
current_consecutive += 1;
max_consecutive = max_consecutive.max(current_consecutive);
} else {
current_consecutive = 0;
}
}
max_consecutive
}
pub fn average_drawdown_duration<F: Float + Clone>(values: &Array1<F>) -> Result<F> {
let periods = drawdown_recovery_analysis(values)?;
if periods.is_empty() {
return Ok(F::zero());
}
let total_duration: usize = periods.iter().map(|p| p.duration).sum();
Ok(F::from(total_duration).expect("Failed to convert to float")
/ F::from(periods.len()).expect("Operation failed"))
}
pub fn average_recovery_time<F: Float + Clone>(values: &Array1<F>) -> Result<F> {
let periods = drawdown_recovery_analysis(values)?;
let recovered_periods: Vec<&DrawdownPeriod<F>> = periods
.iter()
.filter(|p| p.recovery_periods.is_some())
.collect();
if recovered_periods.is_empty() {
return Ok(F::zero());
}
let total_recovery: usize = recovered_periods
.iter()
.map(|p| p.recovery_periods.expect("Operation failed"))
.sum();
Ok(F::from(total_recovery).expect("Failed to convert to float")
/ F::from(recovered_periods.len()).expect("Operation failed"))
}
fn find_recovery_index<F: Float + Clone>(
values: &Array1<F>,
start_search: usize,
target_peak: F,
) -> Option<usize> {
(start_search..values.len()).find(|&i| values[i] >= target_peak)
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::arr1;
#[test]
fn test_max_drawdown() {
let values = arr1(&[1000.0, 1100.0, 1050.0, 950.0, 1200.0, 1150.0, 1300.0]);
let mdd = max_drawdown(&values).expect("Operation failed");
let expected = (1100.0 - 950.0) / 1100.0;
assert!((mdd - expected).abs() < 1e-10);
}
#[test]
fn test_drawdown_series() {
let values = arr1(&[1000.0, 1100.0, 1050.0, 950.0, 1200.0]);
let drawdowns = calculate_drawdown_series(&values).expect("Operation failed");
assert_eq!(drawdowns.len(), values.len());
assert!(drawdowns[0] == 0.0);
for &dd in drawdowns.iter() {
assert!(dd <= 0.0);
}
}
#[test]
fn test_pain_index() {
let values = arr1(&[1000.0, 1100.0, 1050.0, 950.0, 1200.0]);
let pain = pain_index(&values).expect("Operation failed");
assert!(pain >= 0.0);
}
#[test]
fn test_ulcer_index() {
let values = arr1(&[1000.0, 1100.0, 1050.0, 950.0, 1200.0]);
let ulcer = ulcer_index(&values).expect("Operation failed");
assert!(ulcer >= 0.0);
}
#[test]
fn test_calmar_ratio() {
let returns = arr1(&[0.10, -0.05, -0.10, 0.26, -0.04]);
let values = arr1(&[1000.0, 1100.0, 1050.0, 950.0, 1200.0, 1150.0]);
let result = calmar_ratio(&returns, &values, 252);
assert!(result.is_ok());
let calmar = result.expect("Operation failed");
assert!(calmar.is_finite() || calmar.is_infinite());
}
#[test]
fn test_drawdown_recovery_analysis() {
let values = arr1(&[1000.0, 1100.0, 1050.0, 950.0, 1200.0, 1150.0, 1300.0]);
let periods = drawdown_recovery_analysis(&values).expect("Operation failed");
assert!(!periods.is_empty());
if !periods.is_empty() {
let first_period = &periods[0];
assert!(first_period.max_drawdown > 0.0);
assert!(first_period.duration > 0);
assert!(first_period.peak_value >= first_period.trough_value);
}
}
#[test]
fn test_max_consecutive_losses() {
let returns = arr1(&[0.01, -0.02, -0.01, -0.005, 0.02, -0.01, 0.03]);
let max_losses = max_consecutive_losses(&returns);
assert_eq!(max_losses, 3);
}
#[test]
fn test_no_drawdown() {
let values = arr1(&[1000.0, 1100.0, 1200.0, 1300.0, 1400.0]);
let mdd = max_drawdown(&values).expect("Operation failed");
assert!(mdd == 0.0);
}
#[test]
fn test_empty_input() {
let values: Array1<f64> = arr1(&[]);
let result = max_drawdown(&values);
assert!(result.is_err());
}
#[test]
fn test_single_value() {
let values = arr1(&[1000.0]);
let mdd = max_drawdown(&values).expect("Operation failed");
assert!(mdd == 0.0);
}
}