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