Functional Weave
Code in Rust

invest.fee-drag Unreviewed

What an annual charge costs an investment over the years: value with and without the fee, year by year, in minor units.

1.0.1 · published 2026-10-03 by charlie · Anterra

Pinned by 20 tests, run in TypeScript, Python and Rust.

Unreviewed. This capability’s implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified tax adviser has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.

Not professional advice. This capability calculates investment figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a tax adviser review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.

What it does

How much an ongoing charge (a fund's OCF, a platform fee, an adviser's percentage) costs over a number of years, by running the same investment twice, with and without the charge, and comparing.

## The convention

For example

  • fee_drag(£1,000.00, 5%, 1%, 2) → value without fee £1,102.50, value with fee £1,080.57, drag £21.93, drag basis points 1.99%, fees paid £21.41, schedule ×2 £1,000 at 5% growth and a 1% fee for two years: the drag (£21.93) exceeds the fees (£21.41)
  • fee_drag(£10,000.00, 0%, 1%, 3) → value without fee £10,000.00, value with fee £9,702.99, drag £297.01, drag basis points 2.97%, fees paid £297.01, schedule ×3 with no growth the drag is exactly the fees
  • fee_drag(£1,000.00, -10%, 0.5%, 2) → value without fee £810.00, value with fee £801.92, drag £8.08, drag basis points 1%, fees paid £8.53, schedule ×2 a falling market: 10% down a year with a 0.5% fee

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

pub fn fee_drag(start_value: &Money, annual_growth_basis_points: i64, annual_fee_basis_points: i64, years: i64) -> FeeDrag
start_valueMoneythe amount invested at the start, 0 or more
annual_growth_basis_pointsintgrowth before charges each year, 700 = 7%; may be negative, not below -10000
annual_fee_basis_pointsintthe ongoing charge, 0 to 10000; 45 = 0.45% OCF
yearsintwhole years, 0 to 100
returnsFeeDrag

The types it declares, generated into your project

/// The two values at the end of one year.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FeeDragYear {
    /// 1 for the end of the first year
    pub year: i64,
    pub value_without_fee: Money,
    pub value_with_fee: Money,
    /// the charge taken at the end of this year
    pub fee: Money,
}

/// The two outcomes side by side, and what the difference is made of.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FeeDrag {
    pub value_without_fee: Money,
    pub value_with_fee: Money,
    /// valueWithoutFee minus valueWithFee: the fees plus the growth they would have earned
    pub drag: Money,
    /// drag as a share of valueWithoutFee, half-up
    pub drag_basis_points: i64,
    /// the charges actually taken, less than the drag whenever growth is positive
    pub fees_paid: Money,
    /// one row per year
    pub schedule: Vec<FeeDragYear>,
}

Your code names it in one line, in the file that uses it

fune!(invest.fee-drag@^1);  // then call fee_drag(…)
impl/rust.rs · 119 lines · open · 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::money_amount::{money, money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune
use super::money_apply_rate::apply_rate;  ← from money.apply-rate ^1.0.0 · built alongside by fune

/// The same investment grown twice, with and without an annual charge taken
/// on the year-end value, rounding to minor units every year as a statement
/// would.
///
/// # Panics
/// Panics on a negative start value, growth below -10000, a fee outside 0 to
/// 10000, or years outside 0 to 100.
pub fn fee_drag(start_value: &Money, annual_growth_basis_points: i64, annual_fee_basis_points: i64, years: i64) -> FeeDrag {
    if start_value.minor < 0 {
        panic!("startValue must be whole minor units, 0 or more; received {}", start_value.minor);
    }
    if annual_growth_basis_points < -10000 {
        panic!(
            "annualGrowthBasisPoints must be a whole number, -10000 or more; received {}",
            annual_growth_basis_points
        );
    }
    if !(0..=10000).contains(&annual_fee_basis_points) {
        panic!(
            "annualFeeBasisPoints must be a whole number from 0 to 10000; received {}",
            annual_fee_basis_points
        );
    }
    if !(0..=100).contains(&years) {
        panic!("years must be a whole number from 0 to 100; received {}", years);
    }
    let currency = start_value.currency.as_str();
    let mut without = start_value.minor;
    let mut with_fee = start_value.minor;
    let mut fees: i64 = 0;
    let mut schedule: Vec<FeeDragYear> = Vec::new();
    for year in 1..=years {
        without += apply_rate(&money(without, currency), annual_growth_basis_points, "half-up").minor;
        let grown = with_fee + apply_rate(&money(with_fee, currency), annual_growth_basis_points, "half-up").minor;
        let fee = apply_rate(&money(grown, currency), annual_fee_basis_points, "half-up").minor;
        with_fee = grown - fee;
        fees += fee;
        schedule.push(FeeDragYear {
            year,
            value_without_fee: money(without, currency),
            value_with_fee: money(with_fee, currency),
            fee: money(fee, currency),
        });
    }
    let drag = without - with_fee;
    let mut drag_bp: i64 = 0;
    if without > 0 {
        let n = drag as i128 * 10000;
        let d = without as i128;
        let a = n.abs();
        let mut q = a / d;
        if 2 * (a - q * d) >= d {
            q += 1;
        }
        drag_bp = (if n < 0 { -q } else { q }) as i64;
    }
    FeeDrag {
        value_without_fee: money(without, currency),
        value_with_fee: money(with_fee, currency),
        drag: money(drag, currency),
        drag_basis_points: drag_bp,
        fees_paid: money(fees, currency),
        schedule,
    }
}

pub fn fee_drag_to_value(result: &FeeDrag) -> Value {
    Value::obj(vec![
        ("valueWithoutFee", money_to_value(&result.value_without_fee)),
        ("valueWithFee", money_to_value(&result.value_with_fee)),
        ("drag", money_to_value(&result.drag)),
        ("dragBasisPoints", Value::Int(result.drag_basis_points)),
        ("feesPaid", money_to_value(&result.fees_paid)),
        (
            "schedule",
            Value::Arr(
                result
                    .schedule
                    .iter()
                    .map(|y| {
                        Value::obj(vec![
                            ("year", Value::Int(y.year)),
                            ("valueWithoutFee", money_to_value(&y.value_without_fee)),
                            ("valueWithFee", money_to_value(&y.value_with_fee)),
                            ("fee", money_to_value(&y.fee)),
                        ])
                    })
                    .collect(),
            ),
        ),
    ])
}

fn fraction(v: &Value) -> Option<f64> {
    match v {
        Value::Float(f) if f.fract() != 0.0 => Some(*f),
        _ => None,
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    if let Some(f) = fraction(args[0].get("minor")) {
        panic!("startValue must be whole minor units, 0 or more; received {}", f);
    }
    if let Some(f) = fraction(&args[1]) {
        panic!("annualGrowthBasisPoints must be a whole number, -10000 or more; received {}", f);
    }
    if let Some(f) = fraction(&args[2]) {
        panic!("annualFeeBasisPoints must be a whole number from 0 to 10000; received {}", f);
    }
    if let Some(f) = fraction(&args[3]) {
        panic!("years must be a whole number from 0 to 100; received {}", f);
    }
    fee_drag_to_value(&fee_drag(&money_from_value(&args[0]), args[1].as_i64(), args[2].as_i64(), args[3].as_i64()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add invest.fee-drag
Download for Rust invest.fee-drag-1.0.1-rust.fune · 35,796 bytes sha256 9794756101410fe4bc2c72d4e1b42e7ad68a4f269e8cd85699bb6410f642a9ab

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./invest.fee-drag-1.0.1-rust.fune, or fetch it from a terminal with fune pull invest.fee-drag@1.0.1:rust.

The whole function, every language, is one file too: invest.fee-drag-1.0.1.fune, 40,953 bytes, sha256 2e0eed6bc13576141691424f434b8f8bdf9ccba04127a58213167c7939abfdeb. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before invest.fee-drag

after — your function gets the result and the arguments, and returns the final result.

// fune: after invest.fee-drag

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace money.amount in invest.fee-drag
// fune: replace money.apply-rate in invest.fee-drag

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show invest.fee-drag --steps.

// fune: step invest.fee-drag after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
£1,000 at 5% growth and a 1% fee for two years: the drag (£21.93) exceeds the fees (£21.41) £1,000.00, 5%, 1%, 2 → value without fee £1,102.50, value with fee £1,080.57, drag £21.93, drag basis points 1.99%, fees paid £21.41, schedule ×2
with no growth the drag is exactly the fees £10,000.00, 0%, 1%, 3 → value without fee £10,000.00, value with fee £9,702.99, drag £297.01, drag basis points 2.97%, fees paid £297.01, schedule ×3
a falling market: 10% down a year with a 0.5% fee £1,000.00, -10%, 0.5%, 2 → value without fee £810.00, value with fee £801.92, drag £8.08, drag basis points 1%, fees paid £8.53, schedule ×2
no fee, no drag £1,000.00, 5%, 0%, 1 → value without fee £1,050.00, value with fee £1,050.00, drag £0.00, drag basis points 0%, fees paid £0.00, schedule ×1
zero years changes nothing £1,000.00, 5%, 1%, 0 → value without fee £1,000.00, value with fee £1,000.00, drag £0.00, drag basis points 0%, fees paid £0.00, schedule
£10,000 at 5% over 10 years with a 0.75% platform fee £10,000.00, 5%, 0.75%, 10 → value without fee £16,288.94, value with fee £15,107.71, drag £1,181.23, drag basis points 7.25%, fees paid £954.84, schedule ×10
£10,000 at 7% over 30 years: 1% costs far more than 30 x 1% £10,000.00, 7%, 1%, 30 → value without fee £76,122.60, value with fee £56,307.80, drag £19,814.80, drag basis points 26.03%, fees paid £8,355.73, schedule ×30
a total loss in year one leaves nothing either way £500.00, -100%, 1%, 2 → value without fee £0.00, value with fee £0.00, drag £0.00, drag basis points 0%, fees paid £0.00, schedule ×2
a penny grows by half-penny roundings, year by year £0.01, 50%, 0%, 3 → value without fee £0.05, value with fee £0.05, drag £0.00, drag basis points 0%, fees paid £0.00, schedule ×3
a 100% fee takes everything after the first year £100.00, 10%, 100%, 1 → value without fee £110.00, value with fee £0.00, drag £110.00, drag basis points 100%, fees paid £110.00, schedule ×1
Show the other 10 tests
CaseArgumentsExpected
nothing invested £0.00, 5%, 1%, 2 → value without fee £0.00, value with fee £0.00, drag £0.00, drag basis points 0%, fees paid £0.00, schedule ×2
negative start value is an error -£0.01, 5%, 1%, 1 → error: startValue must be whole minor units, 0 or more
fractional minor units are an error £10..5, 5%, 1%, 1 → error: startValue must be whole minor units, 0 or more
growth below -100% is an error £1.00, -100.01%, 1%, 1 → error: annualGrowthBasisPoints must be a whole number, -10000 or more
a fractional growth rate is an error £1.00, 5.125%, 1%, 1 → error: annualGrowthBasisPoints must be a whole number, -10000 or more
a negative fee is an error £1.00, 5%, -0.01%, 1 → error: annualFeeBasisPoints must be a whole number from 0 to 10000
a fee over 100% is an error £1.00, 5%, 100.01%, 1 → error: annualFeeBasisPoints must be a whole number from 0 to 10000
101 years is an error £1.00, 5%, 1%, 101 → error: years must be a whole number from 0 to 100
negative years are an error £1.00, 5%, 1%, -1 → error: years must be a whole number from 0 to 100
fractional years are an error £1.00, 5%, 1%, 2.5 → error: years must be a whole number from 0 to 100

More from the author

Each year, in this order, in whole minor units:

1. growth = value x `annualGrowthBasisPoints` / 10000, rounded half away from zero (`money.apply-rate`, half-up), and added; 2. fee = the grown value x `annualFeeBasisPoints` / 10000, rounded the same way, and taken off (the "with fee" run only).

So the fee is charged once a year on the year-end value, as an annual management charge on a statement is. Real platforms often charge monthly on the average value; the difference is small and this capability does not model it. Growth is a constant rate you choose; it is an illustration, not a forecast.

## Why the drag is more than the fees

`feesPaid` is the charges taken. `drag` is the whole difference in the final value, which also includes the growth the charged money would have earned. With no growth they are equal; with positive growth the drag is larger, and the gap widens with the years. That gap is the point of the capability, and the reason an answer computed as "fee x years" is wrong.

## Edge cases

- `years` 0 returns the start value twice and an empty schedule. - Growth of -10000 (a total loss) is allowed; anything lower is an error. - `dragBasisPoints` is 0 when the value without the fee is 0. - Errors: negative or fractional start value, years outside 0-100, a fee outside 0-10000, growth below -10000.

## Before you rely on this

**Not professional advice.** This capability calculates investment figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed above, and have a tax adviser review how you use it, before anyone relies on the output. Provided "as is" under its licence, without warranty.

**Unreviewed.** This capability's implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified tax adviser has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.

1.0.1 marks it unreviewed. The code and the tests are unchanged.

Files

PathBytes
README.md2,654
impl/python.py2,470
impl/rust.rs4,540
impl/typescript.ts2,500
vectors.json21,510