From 287c94eddfba6dbf6f9ad3129bde07c4e6a4c712 Mon Sep 17 00:00:00 2001 From: Saurabh Jamadagni Date: Mon, 15 Sep 2025 00:01:20 -0500 Subject: [PATCH 1/2] feat: read csv + mean returns and covariance --- Cargo.lock | 100 ++++++++++++++++ Cargo.toml | 3 + examples/portfolio_optimization.rs | 12 ++ src/lib.rs | 1 + src/portfolio.rs | 5 + src/portfolio/mean_variance.rs | 179 +++++++++++++++++++++++++++++ 6 files changed, 300 insertions(+) create mode 100644 examples/portfolio_optimization.rs create mode 100644 src/portfolio.rs create mode 100644 src/portfolio/mean_variance.rs diff --git a/Cargo.lock b/Cargo.lock index 5c932bf..ed88b05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -307,6 +307,27 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "csv" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d02f3b0da4c6504f86e9cd789d8dbafab48c2321be74e9987593de5a894d93d" +dependencies = [ + "memchr", +] + [[package]] name = "dirs" version = "6.0.0" @@ -355,6 +376,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "fdeflate" version = "0.3.7" @@ -486,6 +513,12 @@ dependencies = [ "crunchy", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + [[package]] name = "iana-time-zone" version = "0.1.63" @@ -524,6 +557,16 @@ dependencies = [ "png", ] +[[package]] +name = "indexmap" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206a8042aec68fa4a62e8d3f7aa4ceb508177d9324faf261e1959e495b7a1921" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itertools" version = "0.13.0" @@ -642,6 +685,45 @@ dependencies = [ "typenum", ] +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "ndarray-stats" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ebbe97acce52d06aebed4cd4a87c0941f4b2519b59b82b4feb5bd0ce003dfd" +dependencies = [ + "indexmap", + "itertools", + "ndarray", + "noisy_float", + "num-integer", + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "noisy_float" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978fe6e6ebc0bf53de533cd456ca2d9de13de13856eda1518a285d7705a213af" +dependencies = [ + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -799,6 +881,21 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -824,6 +921,9 @@ dependencies = [ "approx", "chrono", "criterion", + "csv", + "ndarray", + "ndarray-stats", "plotters", "rand 0.9.2", "rand_distr 0.5.1", diff --git a/Cargo.toml b/Cargo.toml index 8f64b89..e60c885 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,9 @@ rand = "0.9.2" rand_distr = "0.5.1" rayon = "1.10.0" statrs = "0.18.0" +ndarray = "0.16.1" +ndarray-stats = "0.6.0" +csv = "=1.3.0" [dev-dependencies] approx = "0.5.1" diff --git a/examples/portfolio_optimization.rs b/examples/portfolio_optimization.rs new file mode 100644 index 0000000..41e3597 --- /dev/null +++ b/examples/portfolio_optimization.rs @@ -0,0 +1,12 @@ +use quantrs::portfolio::{Portfolio, ReturnsCalculation}; + +#[warn(unused_variables)] +fn main() { + let data_path = "/Users/moneymaker/Downloads/ETFprices.csv"; + let risk_free_rate = 0.01; // 1% + let expected_return = 0.1; // 10% + let returns_calc = ReturnsCalculation::Log; + let portfolio = Portfolio::new(data_path, risk_free_rate, expected_return, returns_calc); + + println!("{}", portfolio); +} diff --git a/src/lib.rs b/src/lib.rs index 1856af6..1c74708 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,3 +41,4 @@ mod macros { pub mod data; pub mod fixed_income; pub mod options; +pub mod portfolio; diff --git a/src/portfolio.rs b/src/portfolio.rs new file mode 100644 index 0000000..2f03767 --- /dev/null +++ b/src/portfolio.rs @@ -0,0 +1,5 @@ +//! Module for Portfolio Optimization techniques + +pub use mean_variance::{Portfolio, ReturnsCalculation}; + +mod mean_variance; diff --git a/src/portfolio/mean_variance.rs b/src/portfolio/mean_variance.rs new file mode 100644 index 0000000..2dfe7b9 --- /dev/null +++ b/src/portfolio/mean_variance.rs @@ -0,0 +1,179 @@ +//! Module implementing Mean-Variance Portfolio Optimization +//! +//! Current Support: +//! - Portfolio struct to hold assets, mean returns, covariance matrix +//! - Calculation of mean returns and covariance matrix from CSV data +//! - Support for Simple and Log returns calculation methods +//! Next Steps: +//! - Implement optimization algorithms (e.g., Markowitz optimization) + +use csv::ReaderBuilder; +use ndarray::{s, Array2, Axis}; +use ndarray_stats::CorrelationExt; +use std::fmt; + +#[derive(Debug)] +pub enum ReturnsCalculation { + Simple, + Log, +} + +#[derive(Debug)] +/// Struct representing a portfolio of assets. +pub struct Portfolio { + /// List of asset tickers in the portfolio. + tickers: Vec, + /// Mean returns of the assets. + mean_returns: Vec, + /// Covariance matrix of the asset returns. + covariance_matrix: Array2, + /// Risk-free rate for the market + risk_free_rate: f64, + /// Expected return for the portfolio + expected_return: f64, + /// Method used to calculate returns (Simple or Log) + returns_calculation: ReturnsCalculation, + /// Weights of the assets in the portfolio (if calculated) + /// None if not yet calculated + weights: Option>, + /// Internal storage of returns data + _returns: Array2, +} + +impl Portfolio { + pub fn new( + data_path: &str, + risk_free_rate: f64, + expected_return: f64, + returns_calc: ReturnsCalculation, + ) -> Self { + // Read data from CSV file given file path + let mut rdr = ReaderBuilder::new() + .has_headers(true) + .from_path(data_path) + .expect("Failed to read CSV file"); + + // Extract tickers from headers + let tickers: Vec = rdr + .headers() + .expect("Failed to read headers") + .iter() + .map(|s| s.to_string()) + .collect(); + + // Read records and parse to f64, defaulting to 0.0 on parse failure + let records: Vec> = rdr + .records() + .map(|result| { + result + .expect("Failed to read record") + .iter() + .map(|s| s.parse::().unwrap_or(0.0)) + .collect() + }) + .collect(); + + let n = tickers.len(); + let m = records.len(); + + // Create ndarray from records to store prices + let prices = Array2::from_shape_vec((m, n), records.into_iter().flatten().collect()) + .expect("Failed to create prices array"); + + // Calculate returns based on specified method + let returns = match returns_calc { + ReturnsCalculation::Simple => Self::calculate_simple_returns(&prices), + ReturnsCalculation::Log => Self::calculate_log_returns(&prices), + }; + + // Calculate mean returns + let mean_returns = returns.mean_axis(Axis(0)).unwrap().to_vec(); + + // Calculate covariance matrix + // Using unbiased estimator (N-1 in denominator) for sample covariance + // ddof = 1. + // If you want population covariance, use ddof = 0 + // Transpose of returns is used as ndarray-stats expects variables in rows + let covariance_matrix = returns + .t() + .cov(1.0) + .expect("Failed to compute covariance matrix"); + + Self { + tickers: tickers, + mean_returns: mean_returns, + covariance_matrix: covariance_matrix, + risk_free_rate: risk_free_rate, + expected_return: expected_return, + returns_calculation: returns_calc, + weights: None, + _returns: returns, + } + } + + /// Function to calculate log returns + fn calculate_log_returns(prices: &Array2) -> Array2 { + let log_prices = prices.mapv(|x| x.ln()); + let log_returns = &log_prices.slice(s![1.., ..]) - &log_prices.slice(s![..-1, ..]); + log_returns.to_owned() + } + + /// Function to calculate simple returns + fn calculate_simple_returns(prices: &Array2) -> Array2 { + let simple_returns = (&prices.slice(s![1.., ..]) - &prices.slice(s![..-1, ..])) + / &prices.slice(s![..-1, ..]); + simple_returns.to_owned() + } +} + +/// Implement Display trait for pretty-printing the Portfolio +impl fmt::Display for Portfolio { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "Portfolio")?; + writeln!(f, "Tickers: {:?}", self.tickers)?; + writeln!(f, "Risk-Free Rate: {:.4}", self.risk_free_rate)?; + writeln!(f, "Expected Return: {:.4}", self.expected_return)?; + writeln!(f, "Returns Calculation: {}", self.returns_calculation)?; + writeln!(f, "Weights: {:?}", self.weights)?; + + // Print mean returns as percentages + writeln!(f, "\nMean Returns (%):")?; + for (i, &mean_return) in self.mean_returns.iter().enumerate() { + writeln!(f, "{:>8}: {:>8.4}%", self.tickers[i], mean_return * 100.0)?; + } + + // Print covariance matrix in a readable format + writeln!( + f, + "\nCovariance Matrix ({} x {}):", + self.covariance_matrix.nrows(), + self.covariance_matrix.ncols() + )?; + // Print header with ticker names + write!(f, " ")?; // spacing for row labels + for ticker in &self.tickers { + write!(f, "{:>10}", ticker)?; + } + writeln!(f)?; + + // Print each row with ticker name as label + for (i, row) in self.covariance_matrix.outer_iter().enumerate() { + write!(f, "{:>8} ", self.tickers[i])?; // row label + for value in row { + write!(f, "{:>10.6}", value)?; + } + writeln!(f)?; + } + Ok(()) + } +} + +/// Implement Display trait for ReturnsCalculation enum +impl fmt::Display for ReturnsCalculation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ReturnsCalculation::Simple => write!(f, "Simple"), + ReturnsCalculation::Log => write!(f, "Log"), + } + } +} From f36f7e676405c8e2bb8170e1c3228825e99c5873 Mon Sep 17 00:00:00 2001 From: Saurabh Jamadagni Date: Mon, 15 Sep 2025 00:07:46 -0500 Subject: [PATCH 2/2] fix: fixing linting errors --- src/portfolio/mean_variance.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/portfolio/mean_variance.rs b/src/portfolio/mean_variance.rs index 2dfe7b9..5f979e1 100644 --- a/src/portfolio/mean_variance.rs +++ b/src/portfolio/mean_variance.rs @@ -4,6 +4,7 @@ //! - Portfolio struct to hold assets, mean returns, covariance matrix //! - Calculation of mean returns and covariance matrix from CSV data //! - Support for Simple and Log returns calculation methods +//! //! Next Steps: //! - Implement optimization algorithms (e.g., Markowitz optimization) @@ -100,11 +101,11 @@ impl Portfolio { .expect("Failed to compute covariance matrix"); Self { - tickers: tickers, - mean_returns: mean_returns, - covariance_matrix: covariance_matrix, - risk_free_rate: risk_free_rate, - expected_return: expected_return, + tickers, + mean_returns, + covariance_matrix, + risk_free_rate, + expected_return, returns_calculation: returns_calc, weights: None, _returns: returns, @@ -120,8 +121,8 @@ impl Portfolio { /// Function to calculate simple returns fn calculate_simple_returns(prices: &Array2) -> Array2 { - let simple_returns = (&prices.slice(s![1.., ..]) - &prices.slice(s![..-1, ..])) - / &prices.slice(s![..-1, ..]); + let simple_returns = + (&prices.slice(s![1.., ..]) - &prices.slice(s![..-1, ..])) / prices.slice(s![..-1, ..]); simple_returns.to_owned() } }