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$:

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:

This uses random.binomial() and random.integer().

Interpreting the output

Each row is one marginal +1 unit:

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
User Contributed Notes
0 notes + add a note