Put costs on a run¶
Costs put service and stock on one scale. Stockcast computes costs from the event table with rates that you provide, so every euro in a result can be traced to a quantity and a rate.
Setup: the tea shop from the Walkthrough
import numpy as np
import pandas as pd
from stockcast.core import InventoryStateDataFrame, SimulationEngine
from stockcast.policies import OrderUpToPolicy
from stockcast.utils import DemandGenerator
sku = "tea_250g"
opening_date = pd.Timestamp("2026-01-05")
lead_time, review_period = 2, 4
horizon = lead_time + review_period
demand = DemandGenerator(
[sku], first_date=opening_date + pd.Timedelta(days=1), freq="D",
random_seed=3, negative_demand_handling="clip_zero",
).seasonal(n_periods=56, base=6.0, amplitude=2.0, season_length=7, std=2.0)
demand["y"] = demand["y"].round()
inventory = InventoryStateDataFrame.from_observed(
pd.DataFrame({"unique_id": [sku], "date": [opening_date], "on_hand": [30.0]}),
allow_backorders=False, # some pages step this state by hand
)
paths = np.random.default_rng(42).poisson(6.0, size=(10_000, horizon))
def tea_policy(probability):
"""Order-up-to policy whose target is the given quantile of 6-day demand."""
target = pd.DataFrame({
"unique_id": [sku],
"target": [np.quantile(paths.sum(axis=1), probability)],
"date": [opening_date + pd.Timedelta(days=horizon)], # the last day it covers
})
return OrderUpToPolicy(
lead_time=lead_time, review_period=review_period, freq="D",
service_level=probability, allow_backorders=False,
).fit(target, target_column="target")
policy = tea_policy(0.95)
run_settings = dict(random_seed=3)
result = SimulationEngine().run(
policy=policy, demand_source=demand, inventory=inventory, **run_settings,
)
Rates for the whole run¶
Pass rates in the evaluator's context, and list the parts of total_cost in
cost_components:
from stockcast.evaluation import (
InventoryEvaluator, holding_cost, ordering_cost, purchase_cost, total_cost,
)
context = {
"holding_cost_per_unit_period": 0.02, # per unit, per period on the shelf
"shortage_cost_per_unit": 2.00, # per unit short
"order_cost_per_sku_line": 5.00, # fixed fee per order line
"order_cost_per_unit": 0.00, # variable handling per unit ordered
"purchase_cost_per_unit": 1.50, # what the stock itself costs
"cost_components": ["holding", "shortage", "ordering", "purchase"],
}
InventoryEvaluator().fit(result, window="scoring").evaluate(
[holding_cost, ordering_cost, purchase_cost, total_cost], groupby=[], context=context,
)
Every component you list needs all of its rates, zeros included. Missing rates are not assumed, so the total contains only the components you list.
Rates per SKU or per period¶
A rate can also be a column of the event table. Add it, then fit the evaluator on the enriched table:
unit_prices = {"tea_250g": 1.50} # one entry per SKU
events = result.to_event_frame()
events["purchase_cost_per_unit"] = events["unique_id"].map(unit_prices)
events["holding_cost_per_unit_period"] = 0.01 * events["purchase_cost_per_unit"]
InventoryEvaluator().fit(event_frame=events, window="scoring").evaluate(
[holding_cost, purchase_cost], groupby=["unique_id"],
)
A column takes precedence over a context value with the same name. The same
approach works for rates that change over time: build the column from date.
Match rates to the period length¶
Rates are per period. If your holding cost is quoted per year and a period is a day, divide:
When you compare runs with different period lengths (daily against weekly review, say), convert every rate to its own period length first. Notebook 05b does this for a review-frequency study.
Choose the window¶
Costs are charged in the period where their quantity appears:
| Cost | Charged in the period… |
|---|---|
| purchase, ordering | the order is placed |
| holding | stock is on the shelf at the end of the period |
| shortage | demand goes unserved |
| backlog | backorders are open at the end of the period |
| waste | stock expires |
Evaluating the "scoring" window therefore counts orders placed in scoring,
even if they arrive later. For a finite-horizon comparison (a season, a
planning horizon), decide how to treat what is left at the end:
| Component | Charges or credits |
|---|---|
terminal_backlog |
backorders still open at the end, at terminal_backlog_cost_per_unit |
terminal_pipeline |
orders still on their way, at terminal_pipeline_cost_per_unit |
salvage |
a credit for leftover stock and pipeline, at on_hand_salvage_per_unit and pipeline_salvage_per_unit |
season_end = dict(context,
on_hand_salvage_per_unit=0.50,
pipeline_salvage_per_unit=0.50,
cost_components=["holding", "shortage", "ordering", "purchase", "salvage"],
)
InventoryEvaluator().fit(result, window="scoring").evaluate(
[total_cost], groupby=[], context=season_end,
)
The 25 packs left on the shelf are credited at €0.50 each.
Costs per unit¶
cost_per_demand_unit and cost_per_fulfilled_unit divide total_cost by
total demand or by units served: useful for comparing SKUs of very different
volumes.
See also: Evaluation and metrics · Compare forecasts and policies