inventory.valuation-weighted-average
Perpetual weighted average cost: stock value and cost of sales from a movement ledger, rounding once per issue.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 17 tests, run in TypeScript, Python and Rust.
What it does
Perpetual weighted average cost (AVCO): every receipt is blended into a running average, and every issue is costed at the average in force when it happens. IAS 2 and FRS 102 section 13 allow it alongside FIFO.
**Where the rounding happens.** The ledger keeps the stock's total value in exact minor units, never an average unit cost. An issue of `q` units from `Q` on hand worth `V` costs `V x q / Q`, rounded once to minor units by `mode` (`math.round-div`), and exactly that is taken off the value. So:
For example
weightedAverageValuation(movements ×5, GBP, half-up)→ closing quantity 70, closing value £382.31, cost of sales £967.69, issue costs £640.00, £327.69, average unit cost £5.46 two receipts blended, an exact issue, then an issue rounded once: 71000 x 60 / 130 = 32769.23weightedAverageValuation(movements ×6, GBP, half-up)→ closing quantity 0, closing value £0.00, cost of sales £1,350.00, issue costs £640.00, £327.69, £382.31, average unit cost £0.00 issuing the rest takes exactly what is left: 38231, where 70 x 546p would leave 11p behindweightedAverageValuation(movements ×5, GBP, up)→ closing quantity 70, closing value £382.30, cost of sales £967.70, issue costs £640.00, £327.70, average unit cost £5.47 rounding up costs the second issue 32770
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 weightedAverageValuation(movements: readonly StockMovement[], currency: string, mode: RoundingMode): AverageCostValuation
| movements | StockMovement[] | the ledger for one item, in date order; same-day lines in the order they happened |
| currency | string | the valuation currency, so an empty ledger still has one |
| mode | RoundingMode | how each issue's cost is rounded to minor units |
| returns | AverageCostValuation |
The types it declares, generated into your project
/** One line of the stock ledger. */
export interface StockMovement {
readonly date: string;
/** positive for a receipt, negative for an issue; never zero */
readonly quantity: number;
/** the cost of one unit on a receipt; null on an issue, which is costed at the running average */
readonly unitCost: Money | null;
}
/** What is left, what it is worth, and what the issues cost. */
export interface AverageCostValuation {
readonly closingQuantity: number;
/** exact: receipts less the rounded issue costs */
readonly closingValue: Money;
/** the cost of every issue together */
readonly costOfSales: Money;
/** the cost of each issue, in ledger order */
readonly issueCosts: readonly Money[];
/** closing value / closing quantity, rounded by mode, for display; zero when nothing is left */
readonly averageUnitCost: Money;
}
Your code names it in one line, in the file that uses it
import { weightedAverageValuation } from "#fune/inventory.valuation-weighted-average@^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 { type RoundingMode, roundDiv } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { type Money, assertSameCurrency, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { type AverageCostValuation, type StockMovement } from "./inventory_valuation_weighted_average_types.ts";
/**
* Perpetual weighted average cost. The stock's value is held exactly and each
* issue takes value x issued / on hand, rounded once, so the last issue takes
* exactly what is left and an empty item is worth nothing.
*/
export function weightedAverageValuation(movements: readonly StockMovement[], currency: string, mode: RoundingMode): AverageCostValuation {
const zero = money(0, currency);
roundDiv(0, 1, mode); // an unknown mode fails even on a ledger with no issues
let quantity = 0;
let value = 0;
let costOfSales = 0;
const issueCosts: Money[] = [];
let previousDay: number | null = null;
let previousDate = "";
for (const line of movements) {
const day = epochDayFromIso(line.date);
if (previousDay !== null && day < previousDay) {
throw new RangeError(`movements must be in date order: ${line.date} comes after ${previousDate}`);
}
previousDay = day;
previousDate = line.date;
if (!Number.isInteger(line.quantity) || line.quantity === 0) {
throw new RangeError(`quantity must be a non-zero whole number, received ${line.quantity} on ${line.date}`);
}
if (line.quantity > 0) {
if (line.unitCost === null || line.unitCost === undefined) {
throw new RangeError(`a receipt needs a unitCost: ${line.quantity} on ${line.date}`);
}
assertSameCurrency(zero, line.unitCost);
if (line.unitCost.minor < 0) {
throw new RangeError(`unitCost must not be negative, received ${line.unitCost.minor} on ${line.date}`);
}
quantity += line.quantity;
value += line.quantity * line.unitCost.minor;
continue;
}
if (line.unitCost !== null && line.unitCost !== undefined) {
throw new RangeError(`an issue takes its cost from stock, so its unitCost must be null: ${line.quantity} on ${line.date}`);
}
const issued = -line.quantity;
if (issued > quantity) {
throw new RangeError(`insufficient stock: an issue of ${issued} on ${line.date} exceeds the ${quantity} on hand`);
}
const cost = roundDiv(value * issued, quantity, mode);
issueCosts.push(money(cost, currency));
costOfSales += cost;
value -= cost;
quantity -= issued;
}
return {
closingQuantity: quantity,
closingValue: money(value, currency),
costOfSales: money(costOfSales, currency),
issueCosts,
averageUnitCost: money(quantity === 0 ? 0 : roundDiv(value, quantity, mode), currency),
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 3 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 inventory.valuation-weighted-average
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./inventory.valuation-weighted-average-1.0.0-typescript.fune, or fetch it from a terminal with fune pull inventory.valuation-weighted-average@1.0.0:typescript.
The whole function, every language, is one file too: inventory.valuation-weighted-average-1.0.0.fune, 23,998 bytes, sha256 d29d4868187162269f70922eaf8fe32cac4705b4ab4223ef9cc1a33082de5428. 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 inventory.valuation-weighted-average
after — your function gets the result and the arguments, and returns the final result.
// fune: after inventory.valuation-weighted-average
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 inventory.valuation-weighted-average
// fune: replace math.round-div in inventory.valuation-weighted-average
// fune: replace money.amount in inventory.valuation-weighted-average
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 inventory.valuation-weighted-average --steps.
// fune: step inventory.valuation-weighted-average 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 | |
|---|---|---|---|
| two receipts blended, an exact issue, then an issue rounded once: 71000 x 60 / 130 = 32769.23 | movements ×5, GBP, half-up | → | closing quantity 70, closing value £382.31, cost of sales £967.69, issue costs £640.00, £327.69, average unit cost £5.46 |
| issuing the rest takes exactly what is left: 38231, where 70 x 546p would leave 11p behind | movements ×6, GBP, half-up | → | closing quantity 0, closing value £0.00, cost of sales £1,350.00, issue costs £640.00, £327.69, £382.31, average unit cost £0.00 |
| rounding up costs the second issue 32770 | movements ×5, GBP, up | → | closing quantity 70, closing value £382.30, cost of sales £967.70, issue costs £640.00, £327.70, average unit cost £5.47 |
| an exact half, half-up: 2 units worth 1001p, issue 1 costs 501 | movements ×3, GBP, half-up | → | closing quantity 1, closing value £5.00, cost of sales £5.01, issue costs £5.01, average unit cost £5.00 |
| an exact half, half-even: 2 units worth 1001p, issue 1 costs 500 | movements ×3, GBP, half-even | → | closing quantity 1, closing value £5.01, cost of sales £5.00, issue costs £5.00, average unit cost £5.01 |
| down: 3 units worth 1000p, issue 1 costs 333 and the remaining 2 are worth 667 | movements ×3, GBP, down | → | closing quantity 2, closing value £6.67, cost of sales £3.33, issue costs £3.33, average unit cost £3.33 |
| receipts only: the average is for display, 3 at 999p and 2 at 1001p is 999.8, so 1000 | movements ×2, GBP, half-up | → | closing quantity 5, closing value £49.99, cost of sales £0.00, issue costs , average unit cost £10.00 |
| restocking after running out starts a fresh average | movements ×4, GBP, half-up | → | closing quantity 3, closing value £3.90, cost of sales £7.60, issue costs £5.00, £2.60, average unit cost £1.30 |
| an empty ledger is nothing, in the given currency | , EUR, half-up | → | closing quantity 0, closing value €0.00, cost of sales €0.00, issue costs , average unit cost €0.00 |
| an issue larger than the stock on hand is an error | movements ×2, GBP, half-up | → | error: insufficient stock: an issue of 11 on 2026-01-02 exceeds the 10 on hand |
Show the other 7 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a ledger out of date order is an error | movements ×2, GBP, half-up | → | error: movements must be in date order |
| a receipt with no unit cost is an error | movements ×1, GBP, half-up | → | error: a receipt needs a unitCost |
| an issue with a unit cost is an error | movements ×2, GBP, half-up | → | error: its unitCost must be null |
| a zero quantity is an error | movements ×1, GBP, half-up | → | error: quantity must be a non-zero whole number |
| a negative unit cost is an error | movements ×1, GBP, half-up | → | error: unitCost must not be negative |
| a cost in another currency is an error | movements ×1, GBP, half-up | → | error: currency mismatch |
| an unknown rounding mode is an error, even with no issues | movements ×1, GBP, nearest | → | error: unknown rounding mode |
More from the author
- the closing value plus the cost of sales always equals the receipts, to the penny; - issuing the last unit takes exactly what is left, so an empty item is worth exactly zero. The common shortcut of rounding the average to a unit price and multiplying (546p x 60) drifts, and leaves pence of value on an item with no stock; one of the vectors shows it.
`averageUnitCost` is the closing value divided by the closing quantity, rounded by `mode`, for display only; it is not used to cost anything.
Receipts carry a unit cost in `Money`; an issue carries none. An issue larger than the stock on hand is an error, as are a ledger out of date order, a zero quantity, a missing or negative receipt cost, an issue with a cost, and a cost in another currency. Returns are not modelled.
Sources: IAS 2 *Inventories*, paragraph 27 ("the weighted average may be calculated on a periodic basis, or as each additional shipment is received"); FRS 102 section 13, paragraph 13.18.
Files
| Path | Bytes |
|---|---|
| README.md | 1,523 |
| impl/python.py | 2,963 |
| impl/rust.rs | 4,400 |
| impl/typescript.ts | 2,758 |
| vectors.json | 7,636 |