Functional Weave
Code in Rust

invest.time-weighted-return@1.0.1

impl/rust.rs

4,668 bytes · the Rust implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::epoch_day_from_iso;  ← from dates.add-days ^1.0.0 · built alongside by fune
use super::math_big_integer::BigInt;  ← from math.big-integer ^1.0.0 · built alongside by fune
use super::math_fractional_power::{fixed_scale, fractional_power_fixed};  ← from math.fractional-power ^1.0.0 · built alongside by fune
use super::money_amount::money_from_value;  ← from money.amount ^1.0.0 · built alongside by fune

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<i64> = 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<i64> = 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<i64> = 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<ValuationPoint> = args[0].as_arr().iter().map(valuation_point_from_value).collect();
    twr_result_to_value(&time_weighted_return(&points))
}