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
weighted_average_valuation(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.23weighted_average_valuation(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 behindweighted_average_valuation(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.
pub fn weighted_average_valuation(movements: &[StockMovement], currency: &str, mode: &str) -> 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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StockMovement {
pub date: String,
/// positive for a receipt, negative for an issue; never zero
pub quantity: i64,
/// the cost of one unit on a receipt; null on an issue, which is costed at the running average
pub unit_cost: Option<Money>,
}
/// What is left, what it is worth, and what the issues cost.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AverageCostValuation {
pub closing_quantity: i64,
/// exact: receipts less the rounded issue costs
pub closing_value: Money,
/// the cost of every issue together
pub cost_of_sales: Money,
/// the cost of each issue, in ledger order
pub issue_costs: Vec<Money>,
/// closing value / closing quantity, rounded by mode, for display; zero when nothing is left
pub average_unit_cost: Money,
}
Your code names it in one line, in the file that uses it
fune!(inventory.valuation-weighted-average@^1); // then call weighted_average_valuation(…)
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_round_div::round_div; ← from math.round-div ^1.0.0 · built alongside by fune
use super::money_amount::{assert_same_currency, money, money_from_value, money_to_value}; ← from money.amount ^1.0.0 · built alongside by fune
/// 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.
///
/// # Panics
/// Panics on a ledger out of date order, a zero quantity, a receipt without a
/// unit cost or with a negative one, an issue with a unit cost, a cost in
/// another currency, an issue larger than the stock on hand, or an unknown mode.
pub fn weighted_average_valuation(movements: &[StockMovement], currency: &str, mode: &str) -> AverageCostValuation {
let zero = money(0, currency);
round_div(0, 1, mode); // an unknown mode fails even on a ledger with no issues
let mut quantity: i64 = 0;
let mut value: i64 = 0;
let mut cost_of_sales: i64 = 0;
let mut issue_costs = Vec::new();
let mut previous: Option<(i64, &str)> = None;
for line in movements {
let day = epoch_day_from_iso(&line.date);
if let Some((previous_day, previous_date)) = previous {
if day < previous_day {
panic!("movements must be in date order: {} comes after {}", line.date, previous_date);
}
}
previous = Some((day, &line.date));
if line.quantity == 0 {
panic!("quantity must be a non-zero whole number, received 0 on {}", line.date);
}
if line.quantity > 0 {
let unit_cost = match &line.unit_cost {
Some(c) => c,
None => panic!("a receipt needs a unitCost: {} on {}", line.quantity, line.date),
};
assert_same_currency(&zero, unit_cost);
if unit_cost.minor < 0 {
panic!("unitCost must not be negative, received {} on {}", unit_cost.minor, line.date);
}
quantity += line.quantity;
value += line.quantity * unit_cost.minor;
continue;
}
if line.unit_cost.is_some() {
panic!(
"an issue takes its cost from stock, so its unitCost must be null: {} on {}",
line.quantity, line.date
);
}
let issued = -line.quantity;
if issued > quantity {
panic!(
"insufficient stock: an issue of {} on {} exceeds the {} on hand",
issued, line.date, quantity
);
}
let cost = round_div(value * issued, quantity, mode);
issue_costs.push(money(cost, currency));
cost_of_sales += cost;
value -= cost;
quantity -= issued;
}
AverageCostValuation {
closing_quantity: quantity,
closing_value: money(value, currency),
cost_of_sales: money(cost_of_sales, currency),
issue_costs,
average_unit_cost: money(if quantity == 0 { 0 } else { round_div(value, quantity, mode) }, currency),
}
}
pub fn stock_movement_from_value(v: &Value) -> StockMovement {
let cost = v.get("unitCost");
StockMovement {
date: v.get("date").as_str().to_string(),
quantity: v.get("quantity").as_i64(),
unit_cost: if cost.is_null() { None } else { Some(money_from_value(cost)) },
}
}
pub fn average_cost_valuation_to_value(result: &AverageCostValuation) -> Value {
Value::obj(vec![
("closingQuantity", Value::Int(result.closing_quantity)),
("closingValue", money_to_value(&result.closing_value)),
("costOfSales", money_to_value(&result.cost_of_sales)),
("issueCosts", Value::Arr(result.issue_costs.iter().map(money_to_value).collect())),
("averageUnitCost", money_to_value(&result.average_unit_cost)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let movements: Vec<StockMovement> = args[0]
.as_arr()
.iter()
.map(|v| {
if let Value::Float(f) = v.get("quantity") {
panic!("quantity must be a non-zero whole number, received {} on {}", f, v.get("date").as_str());
}
stock_movement_from_value(v)
})
.collect();
average_cost_valuation_to_value(&weighted_average_valuation(&movements, args[1].as_str(), args[2].as_str()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 3 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 inventory.valuation-weighted-average
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./inventory.valuation-weighted-average-1.0.0-rust.fune, or fetch it from a terminal with fune pull inventory.valuation-weighted-average@1.0.0:rust.
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 |