Aperiodic Rate of Return
This script estimates the aperiodic rate of return (RoR) of adding one extra unit of inventory (per SKU), under uncertain demand and uncertain lead time. The aperiodic RoR is introduced in Joannes Vermorel’s Introduction to Supply Chain: instead of imposing an arbitrary horizon (month/quarter/year), it slides a cursor along the cash-flow timeline and keeps the best (fastest-compounding) RoR; the date that achieves it becomes the option’s implicit horizon.
Check this script on the playground
The output is an inventory investment table: each row is “+1 unit for this SKU”, evaluated in three different starting stock contexts (Inc = 1..3) to illustrate diminishing returns.
Economic definition used in the script
The baseline RoR definition in the book is:
$$ \mathtt{RoR}=\frac{\text{revenues} - \text{spending}}{\text{spending} \times \text{period}} $$
with period expressed in time units (days in this script).
Aperiodic RoR (cursor method)
For each day $t\ge 1$, we compute the RoR as if we liquidated the position at day $t$:
- Cumulated discounted spending up to day $t$: $$ S(t)=\sum_{u=0}^{t} \mathbb{E}[\text{Spend}(u)] \cdot DF(u) $$
- Cumulated discounted sales revenues up to day $t$: $$ R_{\text{sell}}(t)=\sum_{u=0}^{t} \mathbb{E}[\text{SellRev}(u)] \cdot DF(u) $$
- Liquidation value (salvage) if we close at day $t$: $$ R_{\text{salv}}(t)=\mathbb{E}[\text{InvEnd}(t)] \cdot \text{Salvage} \cdot DF(t) $$
- Close-out revenues: $$ R(t)=R_{\text{sell}}(t)+R_{\text{salv}}(t) $$
Then: $$ \mathtt{RoR}(t)=\frac{R(t)-S(t)}{S(t)\cdot t} $$
Finally, the aperiodic RoR is: $$ \mathtt{RoR}^{\star}=\max_{t\in{1..H}} \mathtt{RoR}(t) \quad,\quad t^{\star}=\arg\max_{t} \mathtt{RoR}(t) $$
This “max over cursor positions” is exactly the operational description given in the book.
Note: the script also reports a simple “annualized (linear)” proxy $\mathtt{RoR}^{\star}\times 365$ for readability. True compounding can explode numerically when daily RoR is large (which is common at the micro-option level).
Uncertainty model
Demand: a minimal ISSM (Innovation State Space Model)
Demand is generated via a minimal ISSM trajectory per scenario (per SKU), using negative binomial innovations (a common choice for intermittent / overdispersed demand). The pattern follows the same approach as the “Demand over lead time” gallery example and the Monte Carlo documentation’s ISSM section.
Lead time: bimodal “perfect + stockout tail”
Lead time is sampled as:
- with probability
PPerfect:LtMode(perfect / on-time mode) - otherwise:
LtMode + U, whereUis uniform integer on[1..LtTail]
This uses random.binomial() and random.integer().
Interpreting the output
Each row is one marginal +1 unit:
SaleProbis the probability the marginal unit is sold within the horizon (here: expected units sold; it equals a probability because at most 1 unit can be sold).BestHorizonDaysis the implicit horizon $t^\star$ selected by the cursor.RorDayis $\mathtt{RoR}^\star$ per day. Positive means profitable; larger means faster capital recycling (given this model).ProfitPVis the expected discounted profit over the fixed horizon (sales + end-of-horizon salvage − buy − holding).
Full Envision script
/// Aperiodic RoR of a +1 inventory unit (no base arrivals)
// Demand: ISSM (negative binomial innovations)
// Lead time: bimodal (perfect mode) + uniform tail (stockout)
// Uses: montecarlo + sample accumulators
present = date(2025, 1, 1)
horizonDays = 60
keep span date = [present .. present + horizonDays]
// Day index and discount factor
Day.t = date - present
dailyDiscount = 0.0005
Day.DF = (1 + dailyDiscount) ^ (-Day.t)
///////////////////////////////////////////////////////////
// Tiny master data: 3 SKUs (kept from previous tuned version)
///////////////////////////////////////////////////////////
table Sku[sku] = with
[| as sku, as Buy, as Sell, as Salvage, as Hold, as Init, as MeanDaily, as LtMode, as LtTail, as PPerfect |]
[| "SKU-A", 18, 23, 8, 0.07, 3, 1.5, 22, 18, 0.75 |]
[| "SKU-B", 20, 32, 10, 0.10, 6, 2.2, 9, 30, 0.65 |]
[| "SKU-C", 18, 24, 4, 0.08, 12, 1.0, 12, 40, 0.55 |]
///////////////////////////////////////////////////////////
// Baseline ("theta") per SKU per day for ISSM
///////////////////////////////////////////////////////////
table SkuDay = cross(Sku, Day)
SkuDay.Theta = Sku.MeanDaily * random.uniform(0.8 into SkuDay, 1.2)
///////////////////////////////////////////////////////////
// Monte-Carlo valuation of the marginal unit (+1)
///////////////////////////////////////////////////////////
scenarios = 1000
alpha = 0.30
dispersion = 2.0
minLevel = 0.10
Sku.RorDay, Sku.RorYearLin, Sku.RorYearComp, Sku.BestHorizonDays, Sku.TotalSpendPV, Sku.TotalRevPV, Sku.ProfitPV, Sku.SaleProb, Sku.ExpSaleDay, Sku.ExpLeadTime = each Sku
// Slice theta series for current SKU
Day.Theta = SkuDay.Theta
montecarlo scenarios with
// Lead time: bimodal
lead = if random.binomial(Sku.PPerfect) then
Sku.LtMode
else
Sku.LtMode + random.integer(1, Sku.LtTail)
lead = if lead > horizonDays then horizonDays else lead
sample avgLead = avg(lead)
// ISSM demand trajectory
level = 1.0
Day.Demand = each Day scan date
keep level
mean = level * Day.Theta
dev = random.negativeBinomial(mean, dispersion)
level = alpha * dev / Day.Theta + (1 - alpha) * level
level = max(minLevel, level)
return dev
///////////////////////////////////////////////////////
// Inventory simulation (lost sales), marginal unit
// No base arrivals at lead time.
///////////////////////////////////////////////////////
baseInv = Sku.Init
extraInv = 0
Day.SoldExtra = each Day scan date
keep baseInv
keep extraInv
// the +1 unit arrives at 'lead'
extraInv = extraInv + (if Day.t == lead then 1 else 0)
dem = Day.Demand
soldBase = min(baseInv, dem)
baseInv = baseInv - soldBase
rem = dem - soldBase
soldExtra = min(extraInv, rem)
extraInv = extraInv - soldExtra
return soldExtra
// Second pass: remaining extra inventory
baseInv = Sku.Init
extraInv = 0
Day.ExtraInvEnd = each Day scan date
keep baseInv
keep extraInv
extraInv = extraInv + (if Day.t == lead then 1 else 0)
dem = Day.Demand
soldBase = min(baseInv, dem)
baseInv = baseInv - soldBase
rem = dem - soldBase
soldExtra = min(extraInv, rem)
extraInv = extraInv - soldExtra
return extraInv
// Cashflows (present-valued later)
Day.Spend = (if Day.t == 0 then Sku.Buy else 0) + Sku.Hold * Day.ExtraInvEnd
Day.Rev = Sku.Sell * Day.SoldExtra
+ (if Day.t == horizonDays then Sku.Salvage * Day.ExtraInvEnd else 0)
// Expected discounted profiles
sample Day.ExpSpendPV = avg(Day.Spend * Day.DF)
sample Day.ExpRevPV = avg(Day.Rev * Day.DF)
sample Day.ExpSold = avg(Day.SoldExtra)
// Scalars from expected profiles
totalSpendPV = sum(Day.ExpSpendPV)
totalRevPV = sum(Day.ExpRevPV)
profitPV = totalRevPV - totalSpendPV
// Aperiodic RoR (safe: no /0)
Day.CumRevPV = sum(Day.ExpRevPV) scan Day.t
safeSpendPV = max(0.000001, totalSpendPV)
Day.t1 = max(1, Day.t)
Day.Speed = (Day.CumRevPV / safeSpendPV - 1) / Day.t1
Day.Speed = Day.Speed - (if Day.t == 0 then 1e9 else 0)
rorDay = max(Day.Speed)
bestH = argmax(Day.Speed, Day.t)
// Annualizations
rorYearLin = 365 * rorDay
safePlus = max(0.000001, 1 + rorDay)
rorYearComp = safePlus ^ 365 - 1
// Diagnostics
pSold = sum(Day.ExpSold)
expSaleDay = sum(Day.t * Day.ExpSold) / max(0.000001, pSold)
return (rorDay, rorYearLin, rorYearComp, bestH,
totalSpendPV, totalRevPV, profitPV,
pSold, expSaleDay, avgLead)
///////////////////////////////////////////////////////////
// Output (inventory investment table)
///////////////////////////////////////////////////////////
show table "Inventory investment: +1 unit per SKU (aperiodic RoR)" a1f10 with
Sku.sku
Sku.ExpLeadTime
Sku.SaleProb
Sku.ExpSaleDay
Sku.TotalSpendPV
Sku.TotalRevPV
Sku.ProfitPV
Sku.BestHorizonDays
Sku.RorDay
Sku.RorYearLin
Sku.RorYearComp
order by Sku.RorDay desc