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.
def weighted_average_valuation(movements: Sequence[StockMovement], currency: str, 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
@dataclass(frozen=True)
class StockMovement:
"""One line of the stock ledger."""
date: str
#: positive for a receipt, negative for an issue; never zero
quantity: int
#: the cost of one unit on a receipt; null on an issue, which is costed at the running average
unit_cost: Optional[Money]
@dataclass(frozen=True)
class AverageCostValuation:
"""What is left, what it is worth, and what the issues cost."""
closing_quantity: int
#: exact: receipts less the rounded issue costs
closing_value: Money
#: the cost of every issue together
cost_of_sales: Money
#: the cost of each issue, in ledger order
issue_costs: List[Money]
#: closing value / closing quantity, rounded by mode, for display; zero when nothing is left
average_unit_cost: Money
Your code names it in one line, in the file that uses it
from fune.inventory.valuation_weighted_average import weighted_average_valuation # 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.
from typing import List, Optional, Sequence
from .dates_add_days import epoch_day_from_iso ← from dates.add-days ^1.0.0 · built alongside by fune
from .inventory_valuation_weighted_average_types import AverageCostValuation, StockMovement
from .math_round_div import RoundingMode, round_div ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import Money, assert_same_currency, money ← from money.amount ^1.0.0 · built alongside by fune
def weighted_average_valuation(
movements: Sequence[StockMovement], currency: str, mode: RoundingMode
) -> AverageCostValuation:
"""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.
"""
zero = money(0, currency)
round_div(0, 1, mode) # an unknown mode fails even on a ledger with no issues
quantity = 0
value = 0
cost_of_sales = 0
issue_costs: List[Money] = []
previous_day: Optional[int] = None
previous_date = ""
for line in movements:
day = epoch_day_from_iso(line.date)
if previous_day is not None and day < previous_day:
raise ValueError("movements must be in date order: %s comes after %s" % (line.date, previous_date))
previous_day = day
previous_date = line.date
if isinstance(line.quantity, bool) or not isinstance(line.quantity, int) or line.quantity == 0:
raise ValueError(
"quantity must be a non-zero whole number, received %r on %s" % (line.quantity, line.date)
)
if line.quantity > 0:
if line.unit_cost is None:
raise ValueError("a receipt needs a unitCost: %d on %s" % (line.quantity, line.date))
assert_same_currency(zero, line.unit_cost)
if line.unit_cost.minor < 0:
raise ValueError(
"unitCost must not be negative, received %d on %s" % (line.unit_cost.minor, line.date)
)
quantity += line.quantity
value += line.quantity * line.unit_cost.minor
continue
if line.unit_cost is not None:
raise ValueError(
"an issue takes its cost from stock, so its unitCost must be null: %d on %s"
% (line.quantity, line.date)
)
issued = -line.quantity
if issued > quantity:
raise ValueError(
"insufficient stock: an issue of %d on %s exceeds the %d on hand" % (issued, line.date, quantity)
)
cost = round_div(value * issued, quantity, mode)
issue_costs.append(money(cost, currency))
cost_of_sales += cost
value -= cost
quantity -= issued
return AverageCostValuation(
closing_quantity=quantity,
closing_value=money(value, currency),
cost_of_sales=money(cost_of_sales, currency),
issue_costs=issue_costs,
average_unit_cost=money(0 if quantity == 0 else round_div(value, quantity, mode), currency),
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Python 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 Python implementation. Install it without the registry with fune add ./inventory.valuation-weighted-average-1.0.0-python.fune, or fetch it from a terminal with fune pull inventory.valuation-weighted-average@1.0.0:python.
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 |