Skip to content

Obsolete stock share

How much of our inventory value has stopped moving?

Current value (inventory case data): 27.5% - ✕ Act. Open the case.

Field Value
Definition Value of stock with no movement for more than the age limit divided by total stock value.
Formula sum(on_hand_value where days_since_last_movement > age_limit_days) / sum(on_hand_value)
Unit %
Granularity SKU; snapshot at month end.
Direction Lower is better ↓
Target and thresholds ≤ 5.0% (amber ≤ 10.0%)
Parameters age_limit_days = 180
Owner Inventory Controller
Source Synthetic stock simulation on public demand
Input table inventory_snapshot, columns on_hand_value, days_since_last_movement
Recommended chart bar - Value by age bucket (sorted) - shows how much sits beyond the limit.
Related KPIs Forecast bias, Days of cover, Inventory turnover

How to read it

Capital at risk of write-off. It grows quietly after over-forecast seasons, so read it with forecast bias.

Common misreadings

  • Counting a single unit sold as "movement" resets the clock on a pallet of dead stock; consider coverage too.

Worked example (from the test suite)

on_hand_value days_since_last_movement
500.0 10
300.0 200
200.0 181

Parameters: age_limit_days = 180

Expected result: 50.00%. Limit 180 days. Older: 300 + 200 = 500 of 1000 total value = 0.5.

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("obsolescence_share")
def obsolescence_share(df: pd.DataFrame, age_limit_days: float) -> float:
    old = df.loc[df["days_since_last_movement"] > age_limit_days, "on_hand_value"].sum()
    return safe_ratio(old, df["on_hand_value"].sum())

Reference SQL (tested on SQLite):

SELECT SUM(CASE WHEN days_since_last_movement > 180 THEN on_hand_value ELSE 0 END) / SUM(on_hand_value)
FROM inventory_snapshot