diff --git a/Cargo.lock b/Cargo.lock index 1120538..521a97c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "assert_approx_eq" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c07dab4369547dbe5114677b33fbbf724971019f3818172d59a97a61c774ffd" + [[package]] name = "autocfg" version = "1.1.0" @@ -130,11 +136,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "indoc" -version = "1.0.6" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05a0bd019339e5d968b37855180087b7b9d512c5046fbd244cf8c95687927d6e" +checksum = "1e186cfbae8084e513daff4240b4797e342f988cecda4fb6c939150f96315fd8" [[package]] name = "libc" @@ -228,9 +240,9 @@ dependencies = [ [[package]] name = "numpy" -version = "0.19.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "437213adf41bbccf4aeae535fbfcdad0f6fed241e1ae182ebe97fa1f3ce19389" +checksum = "bef41cbb417ea83b30525259e30ccef6af39b31c240bda578889494c5392d331" dependencies = [ "libc", "ndarray", @@ -293,9 +305,9 @@ dependencies = [ [[package]] name = "pyo3" -version = "0.19.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cffef52f74ec3b1a1baf295d9b8fcc3070327aefc39a6d00656b13c1d0b8885c" +checksum = "04e8453b658fe480c3e70c8ed4e3d3ec33eb74988bd186561b0cc66b85c3bc4b" dependencies = [ "cfg-if", "indoc", @@ -310,9 +322,9 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.19.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "713eccf888fb05f1a96eb78c0dbc51907fee42b3377272dc902eb38985f418d5" +checksum = "a96fe70b176a89cff78f2fa7b3c930081e163d5379b4dcdf993e3ae29ca662e5" dependencies = [ "once_cell", "target-lexicon", @@ -320,9 +332,9 @@ dependencies = [ [[package]] name = "pyo3-ffi" -version = "0.19.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b2ecbdcfb01cbbf56e179ce969a048fd7305a66d4cdf3303e0da09d69afe4c3" +checksum = "214929900fd25e6604661ed9cf349727c8920d47deff196c4e28165a6ef2a96b" dependencies = [ "libc", "pyo3-build-config", @@ -330,31 +342,33 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.19.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b78fdc0899f2ea781c463679b20cb08af9247febc8d052de941951024cd8aea0" +checksum = "dac53072f717aa1bfa4db832b39de8c875b7c7af4f4a6fe93cdbf9264cf8383b" dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn 1.0.98", + "syn 2.0.38", ] [[package]] name = "pyo3-macros-backend" -version = "0.19.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60da7b84f1227c3e2fe7593505de274dcf4c8928b4e0a1c23d551a14e4e80a0f" +checksum = "7774b5a8282bd4f25f803b1f0d945120be959a36c72e08e7cd031c792fdfd424" dependencies = [ + "heck", "proc-macro2", "quote", - "syn 1.0.98", + "syn 2.0.38", ] [[package]] name = "pyxirr" version = "0.9.3" dependencies = [ + "assert_approx_eq", "ndarray", "numpy", "pyo3", @@ -545,9 +559,9 @@ checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc" [[package]] name = "unindent" -version = "0.1.9" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52fee519a3e570f7df377a06a1a7775cdbfb7aa460be7e08de2b1f0e69973a44" +checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" [[package]] name = "windows-sys" diff --git a/Cargo.toml b/Cargo.toml index 67756a8..a0cddd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,14 +27,15 @@ name = "pyxirr" crate-type = ["rlib", "cdylib"] [dependencies] -pyo3 = "0.19" -numpy = "0.19" +pyo3 = "0.20" +numpy = "0.20" time = { version = "0.3", features = ["parsing", "macros"] } ndarray = "0.15" [dev-dependencies] +assert_approx_eq = "1.1" rstest = "0.18.2" -pyo3 = { version = "0.19", features = ["auto-initialize"]} +pyo3 = { version = "0.20", features = ["auto-initialize"]} [features] nonumpy = [] diff --git a/docs/_inline/day_count_conventions.md b/docs/_inline/day_count_conventions.md new file mode 100644 index 0000000..787ca5d --- /dev/null +++ b/docs/_inline/day_count_conventions.md @@ -0,0 +1,25 @@ +The [day count convention](https://en.wikipedia.org/wiki/Day_count_convention) +determines how interest accrues over time in a variety of transactions, +including bonds, swaps, bills and loans. + +The following conventions are supported: + +| Name | Constant | Also known | +| ------------------ | -------------------------- | ------------------------------- | +| Actual/Actual ISDA | DayCount.ACT_ACT_ISDA | Act/Act ISDA | +| Actual/365 Fixed | DayCount.ACT_365F | Act/365F, English | +| Actual/365.25 | DayCount.ACT_365_25 | | +| Actual/364 | DayCount.ACT_364 | | +| Actual/360 | DayCount.ACT_360 | French | +| 30/360 ISDA | DayCount.THIRTY_360_ISDA | Bond basis | +| 30E/360 | DayCount.THIRTY_E_360 | 30/360 ISMA, Eurobond basis | +| 30E+/360 | DayCount.THIRTY_E_PLUS_360 | | +| 30E/360 ISDA | DayCount.THIRTY_E_360_ISDA | 30E/360 German, German | +| 30U/360 | DayCount.THIRTY_U_360 | 30/360 US, 30US/360, 30/360 SIA | +| NL/365 | DayCount.NL_365 | Actual/365 No leap year | +| NL/360 | DayCount.NL_360 | | + +See also: + +- [2006 ISDA definitions](https://www.rbccm.com/assets/rbccm/docs/legal/doddfrank/Documents/ISDALibrary/2006%20ISDA%20Definitions.pdf) +- http://www.deltaquants.com/day-count-conventions diff --git a/docs/_inline/pe/dpi.md b/docs/_inline/pe/dpi.md new file mode 100644 index 0000000..d7978b1 --- /dev/null +++ b/docs/_inline/pe/dpi.md @@ -0,0 +1,3 @@ +Calculates the DPI (Distributions to Paid-In) for a set of cash flows. This is +just the total distributions divided by the total contributions, with +contributions coerced to be positive. diff --git a/docs/_inline/pe/ks_pme_flows.md b/docs/_inline/pe/ks_pme_flows.md new file mode 100644 index 0000000..bd451a3 --- /dev/null +++ b/docs/_inline/pe/ks_pme_flows.md @@ -0,0 +1,5 @@ +Use the Kaplan-Schoar method to re-scale the private equity flows to match the +public market equivalents (PME) for comparison. This method works as follows, +for each period, re-scale the amount as: amount * (pme_price_final_period / +pme_price_current_period). Basically you are future valuing the amount to the +final period based on the returns of the PME. diff --git a/docs/_inline/pe/ln_pme_nav.md b/docs/_inline/pe/ln_pme_nav.md new file mode 100644 index 0000000..542f139 --- /dev/null +++ b/docs/_inline/pe/ln_pme_nav.md @@ -0,0 +1,9 @@ +Use the Long-Nickels method to re-calculate the private equity nav to match the +public market equivalents (PME) for comparison. This method just re-calculates +the nav. Instead of relying on the given nav, it is calculated as the future +valued contributions less the future valued distributions. + +This will look like (for two periods with a contribution and distribution in each): + +nav = c1 * px_final/px_1 + c2 * px_final/px_2 + - d1 * px_final/px_1 - d2 * px_final/px_2 diff --git a/docs/_inline/pe/pme_plus_flows.md b/docs/_inline/pe/pme_plus_flows.md new file mode 100644 index 0000000..27481ca --- /dev/null +++ b/docs/_inline/pe/pme_plus_flows.md @@ -0,0 +1,13 @@ +Use the PME+ method to re-scale the private equity flows to match the public +market equivalents (PME) for comparison. This method works as follows: create +an equation that sets the nav equal to the contributions future valued based on +the PME returns, minus the distributions multiplied by a scalar (lambda) future +valued based on the PME returns. + +This will look like (for two periods with a contribution and distribution in each): + +nav = c1 * px_final/px_1 + c2 * px_final/px_2 + - d1 * lambda * px_final/px_1 - d2 * lambda * px_final/px_2 + +Solve for lambda so that the two sides of the equation are equal. Then multiply +all the distributions by lambda to re-scale them. diff --git a/docs/_inline/pe/rvpi.md b/docs/_inline/pe/rvpi.md new file mode 100644 index 0000000..9c16b20 --- /dev/null +++ b/docs/_inline/pe/rvpi.md @@ -0,0 +1,3 @@ +Calculates RVPI (Residual Value to Paid-In) for a set of cash flows. This is +the total residual value (NAV) divided by the total contributions, with +contributions coerced to be positive. diff --git a/docs/_inline/pe/tvpi.md b/docs/_inline/pe/tvpi.md new file mode 100644 index 0000000..c998665 --- /dev/null +++ b/docs/_inline/pe/tvpi.md @@ -0,0 +1,5 @@ +Total Value to Paid-In Capital is a measure of the performance of a private +equity fund. It represents the total value of a fund relative to the amount of +capital paid into the fund to date. The total value of a fund is the sum of +realised value (all distributions made to investors to date) plus the +unrealised value (residual value of investments) still held by the fund. diff --git a/docs/functions.md b/docs/functions.md index fd954c2..bb1d722 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -26,26 +26,7 @@ CashFlow = Union[CashFlowSeries, CashFlowTable, CashFlowDict] ## Day Count Conventions -The [day count convention](https://en.wikipedia.org/wiki/Day_count_convention) -determines how interest accrues over time in a variety of transactions, -including bonds, swaps, bills and loans. - -The following conventions are supported: - -| Name | Constant | Also known | -| ------------------ | -------------------------- | ------------------------------- | -| Actual/Actual ISDA | DayCount.ACT_ACT_ISDA | Act/Act ISDA | -| Actual/365 Fixed | DayCount.ACT_365F | Act/365F, English | -| Actual/365.25 | DayCount.ACT_365_25 | | -| Actual/364 | DayCount.ACT_364 | | -| Actual/360 | DayCount.ACT_360 | French | -| 30/360 ISDA | DayCount.THIRTY_360_ISDA | Bond basis | -| 30E/360 | DayCount.THIRTY_E_360 | 30/360 ISMA, Eurobond basis | -| 30E+/360 | DayCount.THIRTY_E_PLUS_360 | | -| 30E/360 ISDA | DayCount.THIRTY_E_360_ISDA | 30E/360 German, German | -| 30U/360 | DayCount.THIRTY_U_360 | 30/360 US, 30US/360, 30/360 SIA | -| NL/365 | DayCount.NL_365 | Actual/365 No leap year | -| NL/360 | DayCount.NL_360 | | +{% include_relative ./_inline/day_count_conventions.md %} Definition: @@ -67,11 +48,6 @@ year_fraction("2019-11-09", "2020-03-05", DayCount.THIRTY_E_360) year_fraction("2019-11-09", "2020-03-05", "act/360") ``` -See also: - -- [2006 ISDA definitions](https://www.rbccm.com/assets/rbccm/docs/legal/doddfrank/Documents/ISDALibrary/2006%20ISDA%20Definitions.pdf) -- http://www.deltaquants.com/day-count-conventions - ## Exceptions - `InvalidPaymentsError`. Occurs if either: diff --git a/src/broadcasting.rs b/src/broadcasting.rs index 6335005..9d22274 100644 --- a/src/broadcasting.rs +++ b/src/broadcasting.rs @@ -6,7 +6,6 @@ use pyo3::{ exceptions::{PyTypeError, PyValueError}, prelude::*, types::{PyIterator, PyList, PySequence, PyTuple}, - AsPyPointer, }; use crate::conversions::float_or_none; diff --git a/src/conversions.rs b/src/conversions.rs index edfbfa5..a6b592e 100644 --- a/src/conversions.rs +++ b/src/conversions.rs @@ -211,6 +211,22 @@ fn extract_records(data: &PyAny) -> PyResult<(Vec, Vec)> { Ok((_dates, _amounts)) } +pub struct AmountArray(Vec); + +impl<'s> FromPyObject<'s> for AmountArray { + fn extract(obj: &'s PyAny) -> PyResult { + extract_amount_series(obj).map(|v| AmountArray(v)) + } +} + +impl std::ops::Deref for AmountArray { + type Target = [f64]; + + fn deref(&self) -> &[f64] { + self.0.as_ref() + } +} + pub fn extract_amount_series(series: &PyAny) -> PyResult> { match series.get_type().name()? { "Series" => extract_amount_series_from_numpy(series.getattr("values")?), diff --git a/src/core/mod.rs b/src/core/mod.rs index a0bef56..fde1cc1 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -8,3 +8,4 @@ mod scheduled; pub use models::{DateLike, InvalidPaymentsError}; pub use periodic::*; pub use scheduled::{days_between, xfv, xirr, xnfv, xnpv, year_fraction, DayCount}; +pub mod private_equity; diff --git a/src/core/models.rs b/src/core/models.rs index 996428b..a262287 100644 --- a/src/core/models.rs +++ b/src/core/models.rs @@ -49,6 +49,12 @@ impl FromStr for DateLike { #[derive(Debug)] pub struct InvalidPaymentsError(String); +impl InvalidPaymentsError { + pub fn new(message: T) -> Self { + Self(message.to_string()) + } +} + impl fmt::Display for InvalidPaymentsError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { self.0.fmt(f) @@ -59,8 +65,8 @@ impl Error for InvalidPaymentsError {} pub fn validate(payments: &[f64], dates: Option<&[DateLike]>) -> Result<(), InvalidPaymentsError> { if dates.is_some() && payments.len() != dates.unwrap_or_default().len() { - return Err(InvalidPaymentsError( - "the amounts and dates arrays are of different lengths".into(), + return Err(InvalidPaymentsError::new( + "the amounts and dates arrays are of different lengths", )); } @@ -70,6 +76,6 @@ pub fn validate(payments: &[f64], dates: Option<&[DateLike]>) -> Result<(), Inva if positive && negative { Ok(()) } else { - Err(InvalidPaymentsError("negative and positive payments are required".into())) + Err(InvalidPaymentsError::new("negative and positive payments are required")) } } diff --git a/src/core/private_equity.rs b/src/core/private_equity.rs new file mode 100644 index 0000000..8cf65b1 --- /dev/null +++ b/src/core/private_equity.rs @@ -0,0 +1,387 @@ +// https://www.insead.edu/sites/default/files/assets/dept/centres/gpei/docs/Measuring_PE_Fund-Performance-2019.pdf + +use super::InvalidPaymentsError; + +#[doc = include_str!("../../docs/_inline/pe/dpi.md")] +pub fn dpi(amounts: &[f64]) -> f64 { + let cs: f64 = amounts.iter().filter(|x| x.is_sign_negative()).sum(); + let ds: f64 = amounts.iter().filter(|x| x.is_sign_positive()).sum(); + // TODO: check for zero + ds / -cs +} + +#[doc = include_str!("../../docs/_inline/pe/dpi.md")] +pub fn dpi_2(contributions: &[f64], distributions: &[f64]) -> f64 { + let cs: f64 = contributions.iter().sum(); + let ds: f64 = distributions.iter().sum(); + // TODO: check for zero + ds / cs +} + +#[doc = include_str!("../../docs/_inline/pe/rvpi.md")] +pub fn rvpi(contributions: &[f64], nav: f64) -> f64 { + #[rustfmt::skip] + let sign = contributions.iter() + .any(|x| x.is_sign_negative()) + .then_some(-1.0) + .unwrap_or(1.0); + + let cs: f64 = contributions.iter().sum(); + // TODO: check for zero + nav / (sign * cs) +} + +#[doc = include_str!("../../docs/_inline/pe/tvpi.md")] +pub fn tvpi(amounts: &[f64], nav: f64) -> f64 { + let cs: f64 = amounts.iter().filter(|x| x.is_sign_negative()).sum(); + let ds: f64 = amounts.iter().filter(|x| x.is_sign_positive()).sum(); + (ds + nav) / -cs +} + +#[doc = include_str!("../../docs/_inline/pe/tvpi.md")] +pub fn tvpi_2(contributions: &[f64], distributions: &[f64], nav: f64) -> f64 { + // this is basically dpi_2(contributions, distributions) + rvpi(&contributions, nav) + let cs: f64 = contributions.iter().sum(); + let ds: f64 = distributions.iter().sum(); + (ds + nav) / cs +} + +pub fn moic(amounts: &[f64], nav: f64) -> f64 { + // MOIC divides the total value of the investment or fund by the total invested capital, + // whereas TVPI divides it by the paid-in capital (meaning, the capital that investors have + // actually transferred to the fund). + // https://financestu.com/tvpi-vs-moic/ + // The math behind is the same. Simply create a semantic alias for the user. + tvpi(amounts, nav) +} + +pub fn moic_2(contributions: &[f64], distributions: &[f64], nav: f64) -> f64 { + tvpi_2(contributions, distributions, nav) +} + +#[doc = include_str!("../../docs/_inline/pe/ks_pme_flows.md")] +pub fn ks_pme_flows(amounts: &[f64], index: &[f64]) -> Result, InvalidPaymentsError> { + check_input_len(amounts, index)?; + + Ok(pairwise_mul(amounts, &px_series(index))) +} + +#[doc = include_str!("../../docs/_inline/pe/ks_pme_flows.md")] +pub fn ks_pme_flows_2( + contributions: &[f64], + distributions: &[f64], + index: &[f64], +) -> Result<(Vec, Vec), InvalidPaymentsError> { + check_input_len(contributions, index)?; + check_input_len(distributions, index)?; + + let px = px_series(index); + let c = pairwise_mul(contributions, &px); + let d = pairwise_mul(distributions, &px); + + Ok((c, d)) +} + +pub fn ks_pme(amounts: &[f64], nav: f64, index: &[f64]) -> Result { + ks_pme_flows(amounts, index).map(|a| tvpi(&a, nav)) +} + +pub fn ks_pme_2( + contributions: &[f64], + distributions: &[f64], + nav: f64, + index: &[f64], +) -> Result { + ks_pme_flows_2(contributions, distributions, index).map(|(c, d)| tvpi_2(&c, &d, nav)) +} + +#[doc = include_str!("../../docs/_inline/pe/pme_plus_flows.md")] +pub fn pme_plus_flows( + amounts: &[f64], + nav: f64, + index: &[f64], +) -> Result, InvalidPaymentsError> { + check_input_len(amounts, index)?; + + let (contributions, distributions) = split_amounts(amounts); + let scaled_distributions = pme_plus_flows_2(&contributions, &distributions, nav, index)?; + let scaled_amounts = combine_amounts(&contributions, &scaled_distributions); + + Ok(scaled_amounts) +} + +#[doc = include_str!("../../docs/_inline/pe/pme_plus_flows.md")] +pub fn pme_plus_flows_2( + contributions: &[f64], + distributions: &[f64], + nav: f64, + index: &[f64], +) -> Result, InvalidPaymentsError> { + let lambda = pme_plus_lambda_2(contributions, distributions, nav, index)?; + Ok(scale(distributions, lambda)) +} + +pub fn pme_plus_lambda( + amounts: &[f64], + nav: f64, + index: &[f64], +) -> Result { + check_input_len(amounts, index)?; + + let (contributions, distributions) = split_amounts(amounts); + pme_plus_lambda_2(&contributions, &distributions, nav, index) +} + +pub fn pme_plus_lambda_2( + contributions: &[f64], + distributions: &[f64], + nav: f64, + index: &[f64], +) -> Result { + check_input_len(contributions, index)?; + check_input_len(distributions, index)?; + + let px = px_series(index); + let ds = sum_pairwise_mul(distributions, &px); + let cs = sum_pairwise_mul(contributions, &px); + + Ok((cs - nav) / ds) +} + +pub fn pme_plus(amounts: &[f64], nav: f64, index: &[f64]) -> Result { + let mut cf = pme_plus_flows(amounts, nav, index)?; + + if let Some(last) = cf.last_mut() { + *last = nav + }; + + super::irr(&cf, None) +} + +pub fn pme_plus_2( + contributions: &[f64], + distributions: &[f64], + nav: f64, + index: &[f64], +) -> Result { + let scaled_distributions = pme_plus_flows_2(contributions, distributions, nav, index)?; + let mut cf = combine_amounts(contributions, &scaled_distributions); + + if let Some(last) = cf.last_mut() { + *last = nav + }; + + super::irr(&cf, None) +} +#[doc = include_str!("../../docs/_inline/pe/ln_pme_nav.md")] +pub fn ln_pme_nav(amounts: &[f64], index: &[f64]) -> Result { + check_input_len(amounts, index)?; + Ok(-sum_pairwise_mul(amounts, &px_series(index))) +} + +#[doc = include_str!("../../docs/_inline/pe/ln_pme_nav.md")] +pub fn ln_pme_nav_2( + contributions: &[f64], + distributions: &[f64], + index: &[f64], +) -> Result { + check_input_len(contributions, index)?; + check_input_len(distributions, index)?; + + let amounts = combine_amounts(contributions, distributions); + ln_pme_nav(&amounts, index) +} + +pub fn ln_pme(amounts: &[f64], index: &[f64]) -> Result { + let pme_nav = ln_pme_nav(amounts, index)?; + let mut cf = amounts.to_owned(); + if let Some(last) = cf.last_mut() { + *last = pme_nav + }; + super::irr(&cf, None) +} + +pub fn ln_pme_2( + contributions: &[f64], + distributions: &[f64], + index: &[f64], +) -> Result { + let mut amounts = combine_amounts(contributions, distributions); + let pme_nav = ln_pme_nav(&amounts, index)?; + if let Some(last) = amounts.last_mut() { + *last = pme_nav + }; + super::irr(&amounts, None) +} + +fn check_input_len(amounts: &[f64], index: &[f64]) -> Result<(), InvalidPaymentsError> { + if amounts.len() != index.len() { + Err(InvalidPaymentsError::new("Amounts must be the same length as index.")) + } else if index.len() == 0 { + Err(InvalidPaymentsError::new("Input array must contain at least one value")) + } else { + Ok(()) + } +} + +fn split_amounts(amounts: &[f64]) -> (Vec, Vec) { + // split amounts into contributions and distributions. + // make contributions positive + let contributions: Vec<_> = amounts.iter().map(|x| x.clamp(f64::MIN, 0.0).abs()).collect(); + let distributions: Vec<_> = amounts.iter().map(|x| x.clamp(0.0, f64::MAX)).collect(); + + (contributions, distributions) +} + +fn combine_amounts(contributions: &[f64], distributions: &[f64]) -> Vec { + contributions.iter().zip(distributions).map(|(c, d)| d - c).collect() +} + +fn px_series(index: &[f64]) -> Vec { + let last = index.last().unwrap(); + index.iter().map(|p| last / p).collect() +} + +fn scale(values: &[f64], factor: f64) -> Vec { + values.iter().map(|v| v * factor).collect() +} + +fn sum_pairwise_mul(a: &[f64], b: &[f64]) -> f64 { + a.iter().zip(b).map(|(x, y)| x * y).sum() +} + +fn pairwise_mul(a: &[f64], b: &[f64]) -> Vec { + a.iter().zip(b).map(|(x, y)| x * y).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use assert_approx_eq::assert_approx_eq; + use rstest::rstest; + + // Test examples from the book: + // https://www.insead.edu/sites/default/files/assets/dept/centres/gpei/docs/Measuring_PE_Fund-Performance-2019.pdf + + #[rstest] + #[case(&[-12.0, 0.0, 0.0, 40.0], 0.494)] + #[case(&[-12.0, -10.0, -4.0, 40.0, 0.0, 15.0, 5.0], 0.324)] + fn test_irr(#[case] amounts: &[f64], #[case] expected: f64) { + let result = crate::core::irr(amounts, None).unwrap(); + assert_approx_eq!(result, expected, 1e-3); + } + + #[rstest] + fn test_mirr() { + let amounts = &[-12.0, -10.0, -4.0, 40.0, 0.0, 15.0, 5.0]; + let finance_rate = 0.07; + let reinvest_rate = 0.12; + let result = crate::core::mirr(amounts, finance_rate, reinvest_rate).unwrap(); + assert_approx_eq!(result, 0.21, 1e-3); + } + + #[rstest] + fn test_dpi() { + let amounts = &[-10.0, -20.0, 15.0, 30.0]; + let (contributions, distributions) = split_amounts(amounts); + + assert_approx_eq!(dpi(amounts), 1.5); + assert_approx_eq!(dpi_2(&contributions, &distributions), 1.5); + } + + #[rstest] + fn test_rvpi() { + let amounts = &[10.0, 20.0, 15.0, 30.0]; + assert_approx_eq!(rvpi(amounts, 15.0), 0.2); + } + + #[rstest] + #[case(&[-10.0, -20.0, 15.0, 30.0], 15.0, 2.0)] + #[case(&[-25.0, 15.0, 0.0], 20.0, 1.4)] + fn test_tvpi(#[case] amounts: &[f64], #[case] nav: f64, #[case] expected: f64) { + let result = tvpi(amounts, nav); + assert_approx_eq!(result, expected); + + let (contributions, distributions) = split_amounts(amounts); + let result = tvpi_2(&contributions, &distributions, nav); + assert_approx_eq!(result, expected); + } + + #[rstest] + #[case(&[-25.0, 15.0, 0.0], 20.0, &[100.0, 115.0, 130.0], 1.14)] + fn test_ks_pme( + #[case] amounts: &[f64], + #[case] nav: f64, + #[case] index: &[f64], + #[case] expected: f64, + ) { + let result = ks_pme(amounts, nav, index).unwrap(); + assert_approx_eq!(result, expected, 0.01); + + let (contributions, distributions) = split_amounts(amounts); + let result = ks_pme_2(&contributions, &distributions, nav, index).unwrap(); + assert_approx_eq!(result, expected, 0.01); + } + + #[rstest] + #[case(&[-25.0, 15.0, 0.0], &[100.0, 115.0, 130.0], 15.5)] + // example from https://en.wikipedia.org/wiki/Public_Market_Equivalent#Long-Nickels_PME + #[case(&[-100.0, -50.0, 60.0, 10.0, 0.0], &[100.0, 105.0, 115.0, 117.0, 120.0], 104.28)] + fn test_ln_pme_nav(#[case] amounts: &[f64], #[case] index: &[f64], #[case] expected: f64) { + let result = ln_pme_nav(amounts, index).unwrap(); + assert_approx_eq!(result, expected, 0.1); + + let (contributions, distributions) = split_amounts(amounts); + let result = ln_pme_nav_2(&contributions, &distributions, index).unwrap(); + assert_approx_eq!(result, expected, 0.1); + } + + #[rstest] + #[case(&[-25.0, 15.0, 0.0], &[100.0, 115.0, 130.0], 0.144)] + // example from https://en.wikipedia.org/wiki/Public_Market_Equivalent#Long-Nickels_PME + #[case(&[-100.0, -50.0, 60.0, 10.0, 0.0], &[100.0, 105.0, 115.0, 117.0, 120.0], 0.053)] + fn test_ln_pme(#[case] amounts: &[f64], #[case] index: &[f64], #[case] expected: f64) { + let result = ln_pme(amounts, index).unwrap(); + assert_approx_eq!(result, expected, 1e-3); + + let (contributions, distributions) = split_amounts(amounts); + let result = ln_pme_2(&contributions, &distributions, index).unwrap(); + assert_approx_eq!(result, expected, 1e-3); + } + + #[rstest] + #[case(&[-25.0, 15.0, 0.0], 20.0, &[100.0, 115.0, 130.0], 0.7)] + // example from https://en.wikipedia.org/wiki/Public_Market_Equivalent#PME+_Formula + #[case(&[-100.0, -50.0, 60.0, 100.0, 0.0], 20.0, &[100.0, 105.0, 115.0, 110.0, 120.0], 0.86)] + fn test_pme_plus_lambda( + #[case] amounts: &[f64], + #[case] nav: f64, + #[case] index: &[f64], + #[case] expected: f64, + ) { + let result = pme_plus_lambda(amounts, nav, index).unwrap(); + assert_approx_eq!(result, expected, 0.1); + + let (contributions, distributions) = split_amounts(amounts); + let result = pme_plus_lambda_2(&contributions, &distributions, nav, index).unwrap(); + assert_approx_eq!(result, expected, 0.1); + } + + #[rstest] + #[case(&[-25.0, 15.0, 0.0], 20.0, &[100.0, 115.0, 130.0], 0.143)] + // example from https://en.wikipedia.org/wiki/Public_Market_Equivalent#PME+_Formula + #[case(&[-100.0, -50.0, 60.0, 100.0, 0.0], 20.0, &[100.0, 105.0, 115.0, 110.0, 120.0], 0.0205)] + fn test_pme_plus( + #[case] amounts: &[f64], + #[case] nav: f64, + #[case] index: &[f64], + #[case] expected: f64, + ) { + let result = pme_plus(amounts, nav, index).unwrap(); + assert_approx_eq!(result, expected, 0.1); + + let (contributions, distributions) = split_amounts(amounts); + let result = pme_plus_2(&contributions, &distributions, nav, index).unwrap(); + assert_approx_eq!(result, expected, 0.1); + } +} diff --git a/src/lib.rs b/src/lib.rs index 65bdeb9..471135c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ use broadcasting::Arg; -use conversions::{fallible_float_or_none, float_or_none, PyDayCount}; +use conversions::{fallible_float_or_none, float_or_none, AmountArray, PyDayCount}; use pyo3::{create_exception, exceptions, prelude::*, wrap_pyfunction}; mod broadcasting; @@ -94,11 +94,10 @@ fn xnpv( #[pyo3(text_signature = "(amounts, *, guess=0.1, silent=False)")] fn irr( py: Python, - amounts: &PyAny, + amounts: AmountArray, guess: Option, silent: Option, ) -> PyResult> { - let amounts = conversions::extract_amount_series(amounts)?; py.allow_threads(move || { let result = core::irr(&amounts, guess); fallible_float_or_none(result, silent.unwrap_or(false)) @@ -117,12 +116,11 @@ fn irr( fn npv( py: Python, rate: f64, - amounts: &PyAny, + amounts: AmountArray, start_from_zero: Option, ) -> PyResult> { - let payments = conversions::extract_amount_series(amounts)?; py.allow_threads(move || { - let result = core::npv(rate, &payments, start_from_zero); + let result = core::npv(rate, &amounts, start_from_zero); Ok(float_or_none(result)) }) } @@ -150,8 +148,7 @@ fn fv<'a>( /// Net Future Value. #[pyfunction] #[pyo3(text_signature = "(rate, nper, amounts)")] -fn nfv(py: Python, rate: f64, nper: f64, amounts: &PyAny) -> PyResult> { - let amounts = conversions::extract_amount_series(amounts)?; +fn nfv(py: Python, rate: f64, nper: f64, amounts: AmountArray) -> PyResult> { py.allow_threads(move || Ok(float_or_none(core::nfv(rate, nper, &amounts)))) } @@ -176,7 +173,7 @@ fn xfv( let day_count = day_count.map(|x| x.try_into()).transpose()?; py.allow_threads(move || { - Ok(float_or_none(core::xfv( + let result = core::xfv( &start_date, &cash_flow_date, &end_date, @@ -184,7 +181,8 @@ fn xfv( end_rate, cash_flow, day_count, - ))) + ); + Ok(float_or_none(result)) }) } @@ -234,14 +232,13 @@ fn pv<'a>( #[pyo3(text_signature = "(amounts, finance_rate, reinvest_rate, *, silent=False)")] fn mirr( py: Python, - amounts: &PyAny, + amounts: AmountArray, finance_rate: f64, reinvest_rate: f64, silent: Option, ) -> PyResult> { - let values = conversions::extract_amount_series(amounts)?; py.allow_threads(move || { - let result = core::mirr(&values, finance_rate, reinvest_rate); + let result = core::mirr(&amounts, finance_rate, reinvest_rate); fallible_float_or_none(result, silent.unwrap_or(false)) }) } @@ -401,10 +398,243 @@ fn days_between(d1: core::DateLike, d2: core::DateLike, day_count: PyDayCount) - Ok(core::days_between(&d1, &d2, day_count.try_into()?)) } +mod pe { + use crate::{conversions::AmountArray, core::private_equity}; + use pyo3::prelude::*; + + pub fn module(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(dpi, m)?)?; + m.add_function(wrap_pyfunction!(dpi_2, m)?)?; + m.add_function(wrap_pyfunction!(rvpi, m)?)?; + m.add_function(wrap_pyfunction!(tvpi, m)?)?; + m.add_function(wrap_pyfunction!(tvpi_2, m)?)?; + m.add_function(wrap_pyfunction!(moic, m)?)?; + m.add_function(wrap_pyfunction!(moic_2, m)?)?; + m.add_function(wrap_pyfunction!(ks_pme, m)?)?; + m.add_function(wrap_pyfunction!(ks_pme_2, m)?)?; + m.add_function(wrap_pyfunction!(ks_pme_flows, m)?)?; + m.add_function(wrap_pyfunction!(ks_pme_flows_2, m)?)?; + m.add_function(wrap_pyfunction!(pme_plus, m)?)?; + m.add_function(wrap_pyfunction!(pme_plus_2, m)?)?; + m.add_function(wrap_pyfunction!(pme_plus_flows, m)?)?; + m.add_function(wrap_pyfunction!(pme_plus_flows_2, m)?)?; + m.add_function(wrap_pyfunction!(pme_plus_lambda, m)?)?; + m.add_function(wrap_pyfunction!(pme_plus_lambda_2, m)?)?; + m.add_function(wrap_pyfunction!(ln_pme_nav, m)?)?; + m.add_function(wrap_pyfunction!(ln_pme_nav_2, m)?)?; + m.add_function(wrap_pyfunction!(ln_pme, m)?)?; + m.add_function(wrap_pyfunction!(ln_pme_2, m)?)?; + + Ok(()) + } + + #[pyfunction] + #[doc = include_str!("../docs/_inline/pe/dpi.md")] + fn dpi(py: Python, amounts: AmountArray) -> PyResult { + py.allow_threads(move || Ok(private_equity::dpi(&amounts))) + } + + #[pyfunction] + #[doc = include_str!("../docs/_inline/pe/dpi.md")] + fn dpi_2(py: Python, contributions: AmountArray, distributions: AmountArray) -> PyResult { + py.allow_threads(move || Ok(private_equity::dpi_2(&contributions, &distributions))) + } + + #[pyfunction] + #[doc = include_str!("../docs/_inline/pe/rvpi.md")] + fn rvpi(py: Python, contributions: AmountArray, nav: f64) -> PyResult { + py.allow_threads(move || Ok(private_equity::rvpi(&contributions, nav))) + } + + #[pyfunction] + #[doc = include_str!("../docs/_inline/pe/tvpi.md")] + pub fn tvpi(py: Python, amounts: AmountArray, nav: f64) -> f64 { + py.allow_threads(move || private_equity::tvpi(&amounts, nav)) + } + + #[pyfunction] + #[doc = include_str!("../docs/_inline/pe/tvpi.md")] + pub fn tvpi_2( + py: Python, + contributions: AmountArray, + distributions: AmountArray, + nav: f64, + ) -> f64 { + py.allow_threads(move || private_equity::tvpi_2(&contributions, &distributions, nav)) + } + + #[pyfunction] + pub fn moic(py: Python, amounts: AmountArray, nav: f64) -> f64 { + py.allow_threads(move || private_equity::moic(&amounts, nav)) + } + + #[pyfunction] + pub fn moic_2( + py: Python, + contributions: AmountArray, + distributions: AmountArray, + nav: f64, + ) -> f64 { + py.allow_threads(move || private_equity::moic_2(&contributions, &distributions, nav)) + } + + #[pyfunction] + fn ks_pme(py: Python, amounts: AmountArray, nav: f64, index: AmountArray) -> PyResult { + py.allow_threads(move || Ok(private_equity::ks_pme(&amounts, nav, &index)?)) + } + + #[pyfunction] + fn ks_pme_2( + py: Python, + contributions: AmountArray, + distributions: AmountArray, + nav: f64, + index: AmountArray, + ) -> PyResult { + py.allow_threads(move || { + Ok(private_equity::ks_pme_2(&contributions, &distributions, nav, &index)?) + }) + } + + #[pyfunction] + #[doc = include_str!("../docs/_inline/pe/ks_pme_flows.md")] + fn ks_pme_flows(py: Python, amounts: AmountArray, index: AmountArray) -> PyResult> { + py.allow_threads(move || Ok(private_equity::ks_pme_flows(&amounts, &index)?)) + } + + #[pyfunction] + #[doc = include_str!("../docs/_inline/pe/ks_pme_flows.md")] + fn ks_pme_flows_2( + py: Python, + contributions: AmountArray, + distributions: AmountArray, + index: AmountArray, + ) -> PyResult<(Vec, Vec)> { + py.allow_threads(move || { + Ok(private_equity::ks_pme_flows_2(&contributions, &distributions, &index)?) + }) + } + + #[pyfunction] + fn pme_plus(py: Python, amounts: AmountArray, nav: f64, index: AmountArray) -> PyResult { + py.allow_threads(move || Ok(private_equity::pme_plus(&amounts, nav, &index)?)) + } + + #[pyfunction] + fn pme_plus_2( + py: Python, + contributions: AmountArray, + distributions: AmountArray, + nav: f64, + index: AmountArray, + ) -> PyResult { + py.allow_threads(move || { + Ok(private_equity::pme_plus_2(&contributions, &distributions, nav, &index)?) + }) + } + + #[pyfunction] + #[doc = include_str!("../docs/_inline/pe/pme_plus_flows.md")] + fn pme_plus_flows( + py: Python, + amounts: AmountArray, + nav: f64, + index: AmountArray, + ) -> PyResult> { + py.allow_threads(move || Ok(private_equity::pme_plus_flows(&amounts, nav, &index)?)) + } + + #[pyfunction] + #[doc = include_str!("../docs/_inline/pe/pme_plus_flows.md")] + fn pme_plus_flows_2( + py: Python, + contributions: AmountArray, + distributions: AmountArray, + nav: f64, + index: AmountArray, + ) -> PyResult> { + py.allow_threads(move || { + Ok(private_equity::pme_plus_flows_2(&contributions, &distributions, nav, &index)?) + }) + } + + #[pyfunction] + fn pme_plus_lambda( + py: Python, + amounts: AmountArray, + nav: f64, + index: AmountArray, + ) -> PyResult { + py.allow_threads(move || Ok(private_equity::pme_plus_lambda(&amounts, nav, &index)?)) + } + + #[pyfunction] + fn pme_plus_lambda_2( + py: Python, + contributions: AmountArray, + distributions: AmountArray, + nav: f64, + index: AmountArray, + ) -> PyResult { + py.allow_threads(move || { + Ok(private_equity::pme_plus_lambda_2(&contributions, &distributions, nav, &index)?) + }) + } + + #[pyfunction] + #[doc = include_str!("../docs/_inline/pe/ln_pme_nav.md")] + fn ln_pme_nav(py: Python, amounts: AmountArray, index: AmountArray) -> PyResult { + py.allow_threads(move || Ok(private_equity::ln_pme_nav(&amounts, &index)?)) + } + + #[pyfunction] + #[doc = include_str!("../docs/_inline/pe/ln_pme_nav.md")] + fn ln_pme_nav_2( + py: Python, + contributions: AmountArray, + distributions: AmountArray, + index: AmountArray, + ) -> PyResult { + py.allow_threads(move || { + Ok(private_equity::ln_pme_nav_2(&contributions, &distributions, &index)?) + }) + } + + #[pyfunction] + fn ln_pme(py: Python, amounts: AmountArray, index: AmountArray) -> PyResult { + py.allow_threads(move || Ok(private_equity::ln_pme(&amounts, &index)?)) + } + + #[pyfunction] + fn ln_pme_2( + py: Python, + contributions: AmountArray, + distributions: AmountArray, + index: AmountArray, + ) -> PyResult { + py.allow_threads(move || { + Ok(private_equity::ln_pme_2(&contributions, &distributions, &index)?) + }) + } +} + +fn add_submodule(py: Python, parent: &PyModule, name: &str, mod_init: F) -> PyResult<()> +where + F: Fn(Python, &PyModule) -> PyResult<()>, +{ + let child_module = PyModule::new(py, name)?; + mod_init(py, child_module)?; + parent.add(name.split(".").last().unwrap(), child_module)?; + py.import("sys")?.getattr("modules")?.set_item(name, child_module)?; + Ok(()) +} + #[pymodule] pub fn pyxirr(py: Python, m: &PyModule) -> PyResult<()> { m.add("__version__", env!("CARGO_PKG_VERSION"))?; + add_submodule(py, m, "pyxirr.pe", pe::module)?; + m.add_class::()?; m.add_function(wrap_pyfunction!(year_fraction, m)?)?; m.add_function(wrap_pyfunction!(days_between, m)?)?;