use super::funejson::Value; use super::dates_add_days::epoch_day_from_iso; use super::math_big_integer::BigInt; use super::math_fractional_power::{fixed_scale, fractional_power_fixed}; use super::money_amount::money_from_value; const MAX_SPAN_DAYS: i64 = 36500; /// n / d rounded half away from zero; d > 0. fn round_half_away(n: &BigInt, d: &BigInt) -> BigInt { let two = BigInt::from_i64(2); let magnitude = n.abs().mul(&two).add(d).div(&two.mul(d)); if n.is_negative() { magnitude.neg() } else { magnitude } } /// True time-weighted return: each sub-period's growth, value_i over the /// previous value plus the previous flow, linked geometrically as an exact /// fraction and rounded once to basis points, half away from zero. /// /// # Panics /// Panics on fewer than two points, dates out of order, negative valuations, /// an empty sub-period start, mixed currencies or a span over 36500 days. pub fn time_weighted_return(points: &[ValuationPoint]) -> TwrResult { if points.len() < 2 { panic!("at least two valuation points are needed"); } let currency = points[0].value.currency.clone(); let mut days: Vec = Vec::new(); for point in points { for amount in [&point.value, &point.flow] { if amount.currency != currency { panic!("currency mismatch: {} and {}", currency, amount.currency); } } if point.value.minor < 0 { panic!("valuations must not be negative, received {} on {}", point.value.minor, point.date); } let day = epoch_day_from_iso(&point.date); if let Some(previous) = days.last() { if day <= *previous { panic!("dates must be strictly increasing, one point per date"); } } days.push(day); } let span = days[days.len() - 1] - days[0]; if span > MAX_SPAN_DAYS { panic!("points must fall within {} days", MAX_SPAN_DAYS); } let ten_thousand = BigInt::from_i64(10000); let mut numerator = BigInt::from_i64(1); let mut denominator = BigInt::from_i64(1); let mut sub_period_basis_points: Vec = Vec::new(); for i in 1..points.len() { let invested = BigInt::from_i128(points[i - 1].value.minor as i128 + points[i - 1].flow.minor as i128); if invested <= BigInt::zero() { panic!("nothing is invested at the start of the sub-period from {}", points[i - 1].date); } let value = BigInt::from_i64(points[i].value.minor); sub_period_basis_points.push(round_half_away(&value.sub(&invested).mul(&ten_thousand), &invested).to_i64()); numerator = numerator.mul(&value); denominator = denominator.mul(&invested); } let mut annualised: Option = None; if span >= 365 { if numerator.is_zero() { annualised = Some(-10000); } else { let scale = fixed_scale(); let growth = fractional_power_fixed(&numerator.mul(&scale).div(&denominator), 365, span); annualised = Some(round_half_away(&growth.sub(&scale).mul(&ten_thousand), &scale).to_i64()); } } TwrResult { basis_points: round_half_away(&numerator.sub(&denominator).mul(&ten_thousand), &denominator).to_i64(), sub_period_basis_points, days: span, annualised_basis_points: annualised, } } fn whole(v: &Value) { if let Value::Float(f) = v { if f.fract() != 0.0 { panic!("amounts must be whole minor units, received {}", f); } } } pub fn valuation_point_from_value(v: &Value) -> ValuationPoint { whole(v.get("value").get("minor")); whole(v.get("flow").get("minor")); ValuationPoint { date: v.get("date").as_str().to_string(), value: money_from_value(v.get("value")), flow: money_from_value(v.get("flow")), } } pub fn twr_result_to_value(result: &TwrResult) -> Value { Value::obj(vec![ ("basisPoints", Value::Int(result.basis_points)), ( "subPeriodBasisPoints", Value::Arr(result.sub_period_basis_points.iter().map(|b| Value::Int(*b)).collect()), ), ("days", Value::Int(result.days)), ( "annualisedBasisPoints", match result.annualised_basis_points { Some(b) => Value::Int(b), None => Value::Null, }, ), ]) } pub fn fune_vector(args: &[Value]) -> Value { let points: Vec = args[0].as_arr().iter().map(valuation_point_from_value).collect(); twr_result_to_value(&time_weighted_return(&points)) }