Inventory turnover¶
How many times a year does the stock we hold get sold?
Current value (inventory case data): 5.6x / year - ! Watch. Open the case.
| Field | Value |
|---|---|
| Definition | Annualised cost of goods sold divided by average inventory value. |
| Formula | (sum(cogs_value) * 365 / days) / mean_daily(sum(on_hand_value)) |
| Unit | times per year |
| Granularity | SKU x day; reported monthly by SKU class. |
| Direction | Higher is better ↑ |
| Target and thresholds | ≥ 8.0x / year (amber ≥ 5.0x / year) |
| Parameters | – |
| Owner | Inventory Controller |
| Source | Synthetic stock simulation on public demand |
| Input | table inventory_daily, columns date, on_hand_value, cogs_value |
| Recommended chart | bar - By SKU class (A/B/C) - the comparison that drives policy. |
| Related KPIs | Days of cover, Obsolete stock share, Fill rate (units) |
How to read it¶
Capital efficiency. Higher is better only up to the point where stockouts start: read it together with fill rate.
Common misreadings¶
- Using sales at price instead of cost in the numerator inflates turnover by the margin.
- Using year-end inventory instead of the average flatters companies that run down stock before closing.
Worked example (from the test suite)¶
| date | sku | on_hand_value | cogs_value |
|---|---|---|---|
| 2024-01-01 | a | 100.0 | 5.0 |
| 2024-01-01 | b | 100.0 | 5.0 |
| 2024-01-02 | a | 50.0 | 5.0 |
| 2024-01-02 | b | 150.0 | 5.0 |
Expected result: 18.25x / year. COGS 20 over 2 days, annualised: 20 * 365 / 2 = 3650. Inventory value per date: 100 + 100 = 200 and 50 + 150 = 200, average 200. Turnover = 3650 / 200 = 18.25x / year.
This example is asserted in tests/test_formulas.py against the Python function and in tests/test_sql.py against the SQL below.
Implementations¶
Python function (the single implementation used by the site):
@kpi("inventory_turnover")
def inventory_turnover(df: pd.DataFrame) -> float:
days = df["date"].nunique()
if not days:
return float("nan")
avg_inventory = df.groupby("date")["on_hand_value"].sum().mean()
return safe_ratio(df["cogs_value"].sum() * 365 / days, avg_inventory)
Reference SQL (tested on SQLite):
SELECT (SUM(cogs_value) * 365.0 / COUNT(DISTINCT date))
/ (SELECT AVG(v) FROM (SELECT SUM(on_hand_value) AS v FROM inventory_daily GROUP BY date))
FROM inventory_daily