use scirs2_core::ndarray::Array1;
use scirs2_core::numeric::Float;
use crate::error::{Result, TimeSeriesError};
#[derive(Debug, Clone)]
pub struct PortfolioMetrics<F: Float> {
pub total_return: F,
pub annualized_return: F,
pub volatility: F,
pub sharpe_ratio: F,
pub sortino_ratio: F,
pub max_drawdown: F,
pub calmar_ratio: F,
pub var_95: F,
pub es_95: F,
}
impl<F: Float> PortfolioMetrics<F> {
pub fn new(
total_return: F,
annualized_return: F,
volatility: F,
sharpe_ratio: F,
sortino_ratio: F,
max_drawdown: F,
calmar_ratio: F,
var_95: F,
es_95: F,
) -> Self {
Self {
total_return,
annualized_return,
volatility,
sharpe_ratio,
sortino_ratio,
max_drawdown,
calmar_ratio,
var_95,
es_95,
}
}
pub fn risk_adjusted_ratios(&self) -> (F, F, F) {
(self.sharpe_ratio, self.sortino_ratio, self.calmar_ratio)
}
pub fn tail_risk_measures(&self) -> (F, F, F) {
(self.var_95, self.es_95, self.max_drawdown)
}
pub fn is_valid(&self) -> bool {
self.volatility.is_finite()
&& self.volatility >= F::zero()
&& self.total_return.is_finite()
&& self.annualized_return.is_finite()
}
}
#[derive(Debug, Clone)]
pub struct Portfolio<F: Float> {
pub weights: Array1<F>,
pub asset_names: Vec<String>,
pub rebalance_frequency: Option<usize>,
}
impl<F: Float + Clone> Portfolio<F> {
pub fn new(weights: Array1<F>, asset_names: Vec<String>) -> Result<Self> {
if weights.len() != asset_names.len() {
return Err(TimeSeriesError::DimensionMismatch {
expected: weights.len(),
actual: asset_names.len(),
});
}
let weight_sum = weights.sum();
let tolerance = F::from(0.01).expect("Failed to convert constant to float");
if (weight_sum - F::one()).abs() > tolerance {
return Err(TimeSeriesError::InvalidInput(
"Portfolio weights must sum to approximately 1.0".to_string(),
));
}
Ok(Self {
weights,
asset_names,
rebalance_frequency: None,
})
}
pub fn equal_weight(n_assets: usize, asset_names: Vec<String>) -> Result<Self> {
if n_assets == 0 {
return Err(TimeSeriesError::InvalidInput(
"Number of assets must be positive".to_string(),
));
}
if n_assets != asset_names.len() {
return Err(TimeSeriesError::DimensionMismatch {
expected: n_assets,
actual: asset_names.len(),
});
}
let weight = F::one() / F::from(n_assets).expect("Failed to convert to float");
let weights = Array1::from_elem(n_assets, weight);
Self::new(weights, asset_names)
}
pub fn get_weight(&self, asset_name: &str) -> Option<F> {
self.asset_names
.iter()
.position(|name| name == asset_name)
.map(|idx| self.weights[idx])
}
pub fn set_weight(&mut self, asset_name: &str, new_weight: F) -> Result<()> {
if let Some(idx) = self.asset_names.iter().position(|name| name == asset_name) {
self.weights[idx] = new_weight;
Ok(())
} else {
Err(TimeSeriesError::InvalidInput(format!(
"Asset '{}' not found in portfolio",
asset_name
)))
}
}
pub fn num_assets(&self) -> usize {
self.asset_names.len()
}
pub fn validate(&self) -> Result<()> {
for &weight in self.weights.iter() {
if weight < F::zero() {
return Err(TimeSeriesError::InvalidInput(
"Portfolio weights must be non-negative".to_string(),
));
}
}
let weight_sum = self.weights.sum();
let tolerance = F::from(0.01).expect("Failed to convert constant to float");
if (weight_sum - F::one()).abs() > tolerance {
return Err(TimeSeriesError::InvalidInput(
"Portfolio weights must sum to approximately 1.0".to_string(),
));
}
Ok(())
}
pub fn normalize_weights(&mut self) {
let weight_sum = self.weights.sum();
if weight_sum > F::zero() {
self.weights.mapv_inplace(|w| w / weight_sum);
}
}
pub fn set_rebalance_frequency(&mut self, frequency_days: usize) {
self.rebalance_frequency = Some(frequency_days);
}
pub fn asset_names(&self) -> &[String] {
&self.asset_names
}
pub fn weights(&self) -> &Array1<F> {
&self.weights
}
}
pub fn calculate_portfolio_returns<F: Float + Clone>(
asset_returns: &scirs2_core::ndarray::Array2<F>, weights: &Array1<F>,
) -> Result<Array1<F>> {
if asset_returns.ncols() != weights.len() {
return Err(TimeSeriesError::DimensionMismatch {
expected: asset_returns.ncols(),
actual: weights.len(),
});
}
let mut portfolio_returns = Array1::zeros(asset_returns.nrows());
for t in 0..asset_returns.nrows() {
let mut return_sum = F::zero();
for i in 0..weights.len() {
return_sum = return_sum + weights[i] * asset_returns[[t, i]];
}
portfolio_returns[t] = return_sum;
}
Ok(portfolio_returns)
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::{arr1, Array2};
#[test]
fn test_portfolio_creation() {
let weights = arr1(&[0.6, 0.4]);
let names = vec!["AAPL".to_string(), "GOOGL".to_string()];
let portfolio = Portfolio::new(weights, names).expect("Operation failed");
assert_eq!(portfolio.num_assets(), 2);
assert_eq!(portfolio.get_weight("AAPL"), Some(0.6));
assert_eq!(portfolio.get_weight("GOOGL"), Some(0.4));
}
#[test]
fn test_equal_weight_portfolio() {
let names = vec!["A".to_string(), "B".to_string(), "C".to_string()];
let portfolio: Portfolio<f64> =
Portfolio::equal_weight(3, names).expect("Operation failed");
assert_eq!(portfolio.num_assets(), 3);
for weight in portfolio.weights.iter() {
assert!((*weight - 1.0 / 3.0).abs() < 1e-10);
}
}
#[test]
fn test_portfolio_validation() {
let weights = arr1(&[0.6, 0.4]);
let names = vec!["A".to_string(), "B".to_string()];
let portfolio = Portfolio::new(weights, names).expect("Operation failed");
assert!(portfolio.validate().is_ok());
}
#[test]
fn test_invalid_weights_sum() {
let weights = arr1(&[0.6, 0.6]); let names = vec!["A".to_string(), "B".to_string()];
let result = Portfolio::new(weights, names);
assert!(result.is_err());
}
#[test]
fn test_dimension_mismatch() {
let weights = arr1(&[0.6, 0.4]);
let names = vec!["A".to_string()]; let result = Portfolio::new(weights, names);
assert!(result.is_err());
}
#[test]
fn test_calculate_portfolio_returns() {
let asset_returns =
Array2::from_shape_vec((3, 2), vec![0.01, 0.02, -0.01, 0.01, 0.015, -0.005])
.expect("Operation failed");
let weights = arr1(&[0.6, 0.4]);
let portfolio_returns =
calculate_portfolio_returns(&asset_returns, &weights).expect("Operation failed");
assert_eq!(portfolio_returns.len(), 3);
assert!((portfolio_returns[0] - 0.014).abs() < 1e-10);
}
#[test]
fn test_portfolio_metrics_creation() {
let metrics = PortfolioMetrics::new(0.15, 0.12, 0.18, 0.67, 0.89, 0.08, 1.5, 0.05, 0.07);
assert!(metrics.is_valid());
let (sharpe, sortino, calmar) = metrics.risk_adjusted_ratios();
assert_eq!(sharpe, 0.67);
assert_eq!(sortino, 0.89);
assert_eq!(calmar, 1.5);
}
}