invest.time-weighted-return Unreviewed
Time-weighted return from valuations taken at each external cash flow, geometrically linked exactly.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 22 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
The time-weighted return (TWR) of a portfolio: the growth of one unit invested at the start, with the effect of money the client adds or withdraws taken out. It measures the manager, not the timing of the client's deposits, and is the return the CFA Institute's Global Investment Performance Standards require for most portfolios (GIPS 2020; https://www.gipsstandards.org/wp-content/uploads/2021/02/2020_gips_standards_asset_owners.pdf, read 2026-09-23).
## The calculation
For example
timeWeightedReturn(points ×2)→ rate 10%, sub period basis points 10%, days 365, annualised basis points 10% one year, no flows: the simple return, annualised to itselftimeWeightedReturn(points ×3)→ rate 15.5%, sub period basis points 10%, 5%, days 365, annualised basis points 15.5% a mid-year deposit is taken out: 10% then 5% links to 15.5%, not 18%timeWeightedReturn(points ×3)→ rate 8%, sub period basis points -10%, 20%, days 273, annualised basis points — a withdrawal after a loss: -10% then +20% is +8%, not annualised under a year
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.
export function timeWeightedReturn(points: readonly ValuationPoint[]): TwrResult
| points | ValuationPoint[] | in date order: the first is the opening valuation, the last the closing one |
| returns | TwrResult |
The types it declares, generated into your project
/** The portfolio's value on a date, just before that date's external cash flow. */
export interface ValuationPoint {
readonly date: string;
/** the market value before the flow; 0 or more */
readonly value: Money;
/** money added (positive) or withdrawn (negative) straight after the valuation */
readonly flow: Money;
}
/** The linked return, each sub-period's return, and the annualised figure when the period is a year or more. */
export interface TwrResult {
/** the cumulative time-weighted return: 1550 = 15.5% */
readonly basisPoints: number;
/** one per sub-period between consecutive points */
readonly subPeriodBasisPoints: readonly number[];
/** first date to last date */
readonly days: number;
/** (1 + R)^(365 / days) − 1; null under 365 days */
readonly annualisedBasisPoints: number | null;
}
Your code names it in one line, in the file that uses it
import { timeWeightedReturn } from "#fune/invest.time-weighted-return@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { epochDayFromIso } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { FIXED_SCALE, fractionalPowerFixed } from "./math_fractional_power.ts"; ← from math.fractional-power ^1.0.0 · built alongside by fune
import { type TwrResult, type ValuationPoint } from "./invest_time_weighted_return_types.ts";
const MAX_SPAN_DAYS = 36500;
/** n / d rounded half away from zero; d > 0. */
function roundHalfAway(n: bigint, d: bigint): bigint {
const magnitude = ((n < 0n ? -n : n) * 2n + d) / (2n * d);
return n < 0n ? -magnitude : magnitude;
}
function whole(minor: number): bigint {
if (!Number.isInteger(minor)) throw new RangeError(`amounts must be whole minor units, received ${minor}`);
return BigInt(minor);
}
/**
* 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.
*/
export function timeWeightedReturn(points: readonly ValuationPoint[]): TwrResult {
if (points.length < 2) throw new RangeError("at least two valuation points are needed");
const currency = points[0].value.currency;
const days: number[] = [];
for (const point of points) {
for (const amount of [point.value, point.flow]) {
if (amount.currency !== currency) throw new RangeError(`currency mismatch: ${currency} and ${amount.currency}`);
}
if (whole(point.value.minor) < 0n) {
throw new RangeError(`valuations must not be negative, received ${point.value.minor} on ${point.date}`);
}
whole(point.flow.minor);
const day = epochDayFromIso(point.date);
if (days.length > 0 && day <= days[days.length - 1]) {
throw new RangeError("dates must be strictly increasing, one point per date");
}
days.push(day);
}
const span = days[days.length - 1] - days[0];
if (span > MAX_SPAN_DAYS) throw new RangeError(`points must fall within ${MAX_SPAN_DAYS} days`);
let numerator = 1n;
let denominator = 1n;
const subPeriodBasisPoints: number[] = [];
for (let i = 1; i < points.length; i++) {
const invested = BigInt(points[i - 1].value.minor) + BigInt(points[i - 1].flow.minor);
if (invested <= 0n) {
throw new RangeError(`nothing is invested at the start of the sub-period from ${points[i - 1].date}`);
}
const value = BigInt(points[i].value.minor);
subPeriodBasisPoints.push(Number(roundHalfAway((value - invested) * 10000n, invested)));
numerator *= value;
denominator *= invested;
}
let annualisedBasisPoints: number | null = null;
if (span >= 365) {
if (numerator === 0n) annualisedBasisPoints = -10000;
else {
const growth = fractionalPowerFixed((numerator * FIXED_SCALE) / denominator, 365, span);
annualisedBasisPoints = Number(roundHalfAway((growth - FIXED_SCALE) * 10000n, FIXED_SCALE));
}
}
return {
basisPoints: Number(roundHalfAway((numerator - denominator) * 10000n, denominator)),
subPeriodBasisPoints,
days: span,
annualisedBasisPoints,
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the TypeScript 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. Or pin a range in fune.project and build in one step:
fune add invest.time-weighted-return
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./invest.time-weighted-return-1.0.1-typescript.fune, or fetch it from a terminal with fune pull invest.time-weighted-return@1.0.1:typescript.
The whole function, every language, is one file too: invest.time-weighted-return-1.0.1.fune, 32,416 bytes, sha256 7d64059a234d6464f3e546ed8903c2babb7b2a1f0c1bd3df47b357d88585a42a. 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.time-weighted-return
after — your function gets the result and the arguments, and returns the final result.
// fune: after invest.time-weighted-return
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 dates.add-days in invest.time-weighted-return
// fune: replace math.big-integer in invest.time-weighted-return
// fune: replace math.fractional-power in invest.time-weighted-return
// fune: replace money.amount in invest.time-weighted-return
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.time-weighted-return --steps.
// fune: step invest.time-weighted-return 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.
| Case | Arguments | Expected | |
|---|---|---|---|
| one year, no flows: the simple return, annualised to itself | points ×2 | → | rate 10%, sub period basis points 10%, days 365, annualised basis points 10% |
| a mid-year deposit is taken out: 10% then 5% links to 15.5%, not 18% | points ×3 | → | rate 15.5%, sub period basis points 10%, 5%, days 365, annualised basis points 15.5% |
| a withdrawal after a loss: -10% then +20% is +8%, not annualised under a year | points ×3 | → | rate 8%, sub period basis points -10%, 20%, days 273, annualised basis points — |
| an account opened by its first flow from a value of zero | points ×2 | → | rate 5%, sub period basis points 5%, days 59, annualised basis points — |
| the last point's flow does not change the return | points ×2 | → | rate 10%, sub period basis points 10%, days 365, annualised basis points 10% |
| three years to 1.331 annualise over 1096 days, a shade under 10% | points ×2 | → | rate 33.1%, sub period basis points 33.1%, days 1,096, annualised basis points 9.99% |
| three sub-periods with deposits and a withdrawal | points ×4 | → | rate 7.17%, sub period basis points 5%, -2.61%, 4.8%, days 423, annualised basis points 6.16% |
| a total loss is -100%, annualised too | points ×2 | → | rate -100%, sub period basis points -100%, days 517, annualised basis points -100% |
| 364 days is not annualised | points ×2 | → | rate 10%, sub period basis points 10%, days 364, annualised basis points — |
| -0.5 bp rounds away from zero to -1 | points ×2 | → | rate -0.01%, sub period basis points -0.01%, days 31, annualised basis points — |
Show the other 12 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| +0.5 bp rounds away from zero to 1 | points ×2 | → | rate 0.01%, sub period basis points 0.01%, days 31, annualised basis points — |
| a sub-period with no change is 0 | points ×3 | → | rate -5%, sub period basis points 0%, -5%, days 243, annualised basis points — |
| repeating fractions are linked exactly before rounding | points ×3 | → | rate 77.78%, sub period basis points 33.33%, 33.33%, days 517, annualised basis points 50.11% |
| one point is not a period | points ×1 | → | error: at least two valuation points are needed |
| dates out of order | points ×2 | → | error: dates must be strictly increasing |
| two points on one date | points ×2 | → | error: dates must be strictly increasing |
| a negative valuation | points ×2 | → | error: valuations must not be negative |
| everything withdrawn before the end leaves nothing to measure | points ×3 | → | error: nothing is invested at the start of the sub-period from 2025-06-01 |
| a flow in another currency | points ×3 | → | error: currency mismatch |
| fractional minor units | points ×2 | → | error: amounts must be whole minor units |
| an impossible date | points ×2 | → | error: is not a real calendar date |
| more than 36500 days | points ×2 | → | error: points must fall within 36500 days |
More from the author
The portfolio is valued on the date of every external cash flow, **just before** the flow. Between two consecutive points the sub-period return is
r_i = value_i / (value_(i−1) + flow_(i−1)) − 1
and the sub-periods are linked geometrically: 1 + R = Π (1 + r_i). This is the "true" TWR; it needs a valuation at every flow, which is what the points are. (Approximations such as Modified Dietz, which avoid the valuations, are not this capability.)
The product is formed exactly, as a fraction of big integers, and rounded once: every figure is in basis points, **half away from zero** (+0.5 bp is 1, −0.5 bp is −1). A naive "gain over opening value" gets deposits wrong: £100,000 that grows 10%, receives £50,000 and then grows 5% ends at £168,000, a TWR of 15.5%, not 18%.
## Annualised
When the first and last dates are 365 days or more apart, `annualisedBasisPoints` is (1 + R)^(365 / days) − 1, taken in `math.fractional-power`'s 18-place fixed point (actual days over 365, as XIRR does, so a leap year counts as 366/365). Under 365 days it is null: GIPS says returns for periods of less than one year must not be annualised. A total loss annualises to −100%.
## Edge cases
- The first point's value may be zero when its flow opens the account. - The last point's flow does not affect the return: nothing is measured after it. It is still checked for currency. - Errors: fewer than two points; dates not strictly increasing; a negative valuation; a sub-period that starts with nothing invested (value plus flow zero or less); mixed currencies; fractional minor units; more than 36,500 days from first to last.
## 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
| Path | Bytes |
|---|---|
| README.md | 3,236 |
| impl/python.py | 2,904 |
| impl/rust.rs | 4,668 |
| impl/typescript.ts | 2,971 |
| vectors.json | 13,135 |