Callbacks: changing orders and stock during a run¶
A callback lets your own code step into a run at fixed points of every period, without taking over the engine's accounting. A callback subclasses SimulationCallback and overrides one or both hooks:
on_after_prediction(decision, context)runs after the policy proposes an order and before ordering constraints. It can replace order quantities by returning anOrderAdjustmentResult.on_after_demand(context)runs after demand is served. It can add or remove stock on hand by returning anInventoryAdjustmentResult.
A callback receives copies of the state and the decision, so it cannot change them directly. The engine checks what the callback returns, applies it, and records it twice: in the event table, and in a separate audit table from to_callback_audit_frame().
This notebook writes three callbacks and shows:
- an order hook that holds small orders;
- a stock hook that records a weekly stock-count correction;
- how the order of callbacks in the list matters, and how ordering constraints apply after them;
- what each one changes in the results.
Notebook 05c uses two built-in callbacks, ScheduledOrderHold and ScheduledInventoryAdjustment, for one-off changes on given dates. A recurring physical flow such as expiry or a write-off that should appear as its own named flow is an inventory process instead (Notebook 05e). The data are synthetic: five items with Poisson demand.
import numpy as np
import pandas as pd
# Show every number with two decimals.
pd.options.display.float_format = "{:.2f}".format
1. Setup: demand, policy and a baseline run¶
Four weeks of daily Poisson demand for five items. The policy is a reorder-point rule, ReorderPointPolicy: every R = 2 days, if an item's inventory position (stock on hand plus stock on order) is at or below its reorder point s, it orders up to the level S. Deliveries take L = 2 days and unmet demand is lost. The levels are set by hand: s is four days and S seven days of each item's mean demand.
opening_date = pd.Timestamp("2026-03-01") # a Sunday; demand starts on Monday
rates = {"tea": 9.0, "coffee": 7.0, "pasta": 6.0, "cereal": 5.0, "soup": 4.0}
skus = list(rates)
n_days = 28
rng = np.random.default_rng(8)
dates = pd.date_range(opening_date + pd.Timedelta(days=1), periods=n_days, freq="D")
demand = pd.DataFrame(
[
{
"unique_id": sku,
"period": period,
"date": date,
"y": float(rng.poisson(rate)),
}
for sku, rate in rates.items()
for period, date in enumerate(dates)
]
)
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
SKU_COLORS = ["#4C78A8", "#F58518", "#54A24B", "#B279A2", "#72B7B2"]
def style_axis(ax):
ax.set_ylim(0, ax.get_ylim()[1] * 1.05)
ax.grid(axis="y", alpha=0.2)
ax.spines[["top", "right"]].set_visible(False)
ax.xaxis.set_major_formatter(mdates.DateFormatter("%d %b"))
fig, ax = plt.subplots(figsize=(10, 3.4), layout="constrained")
for sku, color in zip(skus, SKU_COLORS):
rows = demand.loc[demand["unique_id"].eq(sku)]
ax.plot(
rows["date"],
rows["y"],
marker="o",
markersize=2.5,
linewidth=1.2,
color=color,
label=sku,
)
ax.set(title="Daily demand", ylabel="Units")
style_axis(ax)
ax.legend(frameon=False, ncol=5, loc="upper center", bbox_to_anchor=(0.5, -0.1))
plt.show()
from stockcast.core import InventoryStateDataFrame, SimulationEngine
from stockcast.policies import ReorderPointPolicy
levels = pd.DataFrame(
{
"s": [4 * rates[sku] for sku in skus],
"S": [7 * rates[sku] for sku in skus],
},
index=pd.Index(skus, name="unique_id"),
)
policy = ReorderPointPolicy(
lead_time=2,
review_period=2,
allow_backorders=False,
)
policy.fit(reorder_point=levels["s"], order_up_to_level=levels["S"])
opening = InventoryStateDataFrame.from_observed(
pd.DataFrame({"unique_id": skus, "on_hand": [5 * rates[sku] for sku in skus]}),
opening_date=opening_date,
)
baseline = SimulationEngine().run(
policy=policy,
demand_source=demand,
inventory=opening,
freq="D",
)
Every run below uses this same policy, demand and opening state; the engine copies the opening state for each run. Only the callbacks argument (and, in section 4, order_constraints) changes.
2. An order hook: hold orders below a batch minimum¶
Suppose the supplier accepts an order only if all items together add up to at least 60 units. The rule needs the whole order, not one line at a time, which is exactly what on_after_prediction receives: decision.get_dataframe() has one row per item with the proposed order_quantity.
When the total is too small, the callback returns an OrderAdjustmentResult that sets those lines to zero. The quantities in it are absolute (the new order), not changes, and items it does not list keep their quantity. Returning None leaves the order as it is. The optional reason and source columns label the change in the audit table.
from stockcast.core import OrderAdjustmentResult, SimulationCallback
class BatchMinimum(SimulationCallback):
"""Hold the whole order when it adds up to less than `minimum` units."""
def __init__(self, minimum):
self.minimum = minimum
def on_after_prediction(self, decision, context):
orders = decision.get_dataframe()
ordered = orders[orders["order_quantity"] > 0]
if ordered.empty or ordered["order_quantity"].sum() >= self.minimum:
return None # nothing to hold
held = pd.DataFrame(
{
"unique_id": ordered["unique_id"],
"order_quantity": 0.0,
"reason": "order below the batch minimum",
}
)
return OrderAdjustmentResult(held)
batch_minimum = BatchMinimum(minimum=60)
with_batch = SimulationEngine().run(
policy=policy,
demand_source=demand,
inventory=opening,
freq="D",
callbacks=[batch_minimum],
)
The event table keeps both quantities: requested_order_quantity is what the policy proposed, and order_quantity what was placed after the callback. Summed over the items, at each review:
HELD_COLOR = "#E45756"
PLACED_COLOR = "#4C78A8"
batch_events = with_batch.to_event_frame()
per_review = (
batch_events[batch_events["is_review_period"]]
.groupby("date")[["requested_order_quantity", "order_quantity"]]
.sum()
)
fig, ax = plt.subplots(figsize=(10, 3.4), layout="constrained")
ax.bar(
per_review.index,
per_review["requested_order_quantity"],
width=1.2,
color=HELD_COLOR,
alpha=0.35,
label="Requested by the policy",
)
ax.bar(
per_review.index,
per_review["order_quantity"],
width=1.2,
color=PLACED_COLOR,
label="Placed after the callback",
)
ax.axhline(60, color="black", linestyle="--", linewidth=1, label="Batch minimum: 60")
ax.set(title="Total order at each review, all items", ylabel="Units")
style_axis(ax)
ax.legend(
frameon=False, fontsize=8, ncol=3, loc="upper center", bbox_to_anchor=(0.5, -0.1)
)
plt.show()
What to notice: at four reviews (10, 14, 18 and 22 March) the policy asks for less than 60 units in total (pale bars), and the callback holds the order, so nothing is placed. The held request is not remembered anywhere: at the next review the policy computes a new order from the current inventory position, which now includes the missed replenishment. After the 30 units held on 10 March, the policy asks for 128 units on 12 March.
batch_audit = with_batch.to_callback_audit_frame()
batch_audit[
[
"date",
"callback_class",
"phase",
"unique_id",
"before_value",
"after_value",
"reason",
]
].head(6)
| date | callback_class | phase | unique_id | before_value | after_value | reason | |
|---|---|---|---|---|---|---|---|
| 0 | 2026-03-10 | BatchMinimum | on_after_prediction | pasta | 30.00 | 0.00 | order below the batch minimum |
| 1 | 2026-03-14 | BatchMinimum | on_after_prediction | soup | 16.00 | 0.00 | order below the batch minimum |
| 2 | 2026-03-18 | BatchMinimum | on_after_prediction | pasta | 23.00 | 0.00 | order below the batch minimum |
| 3 | 2026-03-18 | BatchMinimum | on_after_prediction | cereal | 17.00 | 0.00 | order below the batch minimum |
| 4 | 2026-03-22 | BatchMinimum | on_after_prediction | soup | 14.00 | 0.00 | order below the batch minimum |
The audit table has one row per item that a callback changed: which callback, in which hook, the quantity it received (before_value) and the one it returned (after_value). Together with the event table it shows both what the callback did and what was finally placed.
3. A stock hook: a weekly stock count¶
Suppose stock is counted every Sunday evening, and the count finds 5% fewer units than the records show (rounded down to whole units), for example through damage or theft. on_after_demand sees the state after the day's demand in context.inventory, one row per item. It returns an InventoryAdjustmentResult with a signed quantity_delta: negative removes stock, positive adds it.
The engine refuses a change that would leave negative stock. The correction lowers the stock that later decisions see; the event table records it in inventory_adjustment_units.
from stockcast.core import InventoryAdjustmentResult
class WeeklyCount(SimulationCallback):
"""On Sundays, record that `share` of the stock on hand is missing."""
def __init__(self, share):
self.share = share
def on_after_demand(self, context):
if context.date.day_name() != "Sunday":
return None # no count today
stock = context.inventory
correction = pd.DataFrame(
{
"unique_id": stock["unique_id"],
"quantity_delta": -np.floor(self.share * stock["on_hand"]),
"reason": "weekly stock count",
}
)
return InventoryAdjustmentResult(correction)
weekly_count = WeeklyCount(share=0.05)
with_count = SimulationEngine().run(
policy=policy,
demand_source=demand,
inventory=opening,
freq="D",
callbacks=[batch_minimum, weekly_count],
)
count_events = with_count.to_event_frame()
count_events.loc[
count_events["unique_id"].eq("tea")
& count_events["date"].dt.day_name().eq("Sunday"),
[
"date",
"starting_on_hand",
"received_units",
"fulfilled_units",
"inventory_adjustment_units",
"ending_on_hand",
],
]
| date | starting_on_hand | received_units | fulfilled_units | inventory_adjustment_units | ending_on_hand | |
|---|---|---|---|---|---|---|
| 30 | 2026-03-08 | 31.00 | 0.00 | 6.00 | -1.00 | 24.00 |
| 65 | 2026-03-15 | 39.00 | 0.00 | 9.00 | -1.00 | 29.00 |
| 100 | 2026-03-22 | 6.00 | 34.00 | 4.00 | -1.00 | 35.00 |
| 135 | 2026-03-29 | 18.00 | 0.00 | 10.00 | 0.00 | 8.00 |
What to notice: every Sunday the count removes 5% of tea's remaining stock, rounded down: 1 unit on 8, 15 and 22 March. On 29 March, 5% of 8 units rounds down to zero, so nothing is recorded. Each correction lowers the stock and the inventory position that the next decision sees, and it stays visible in the event table instead of disappearing into the stock numbers.
4. Callback order, and constraints after callbacks¶
Callbacks in the callbacks list run in list order within a hook, and each one receives the order as the previous one left it. Ordering constraints run after all order callbacks.
To see this, add a third callback: tea and soup are priority items. Whenever their inventory position is below S and the policy proposes nothing (because the position is still above s), the callback orders them up to S. The decision frame already holds inventory_position and target_level (S) for every item. We also cap every order line at 30 units with the built-in MaximumOrderQuantity constraint.
Then we run the three callbacks twice, changing only their order: priority before the batch minimum, and after it.
from stockcast.core import MaximumOrderQuantity, OrderingConstraints
class PriorityTopUp(SimulationCallback):
"""Order priority items up to S at every review, even above s."""
def __init__(self, priority_skus):
self.priority_skus = priority_skus
def on_after_prediction(self, decision, context):
orders = decision.get_dataframe()
top_up = orders[
orders["unique_id"].isin(self.priority_skus)
& orders["order_quantity"].eq(0)
& orders["inventory_position"].lt(orders["target_level"])
]
if top_up.empty:
return None
raised = pd.DataFrame(
{
"unique_id": top_up["unique_id"],
"order_quantity": top_up["target_level"] - top_up["inventory_position"],
"reason": "priority item topped up to S",
}
)
return OrderAdjustmentResult(raised)
priority_top_up = PriorityTopUp(priority_skus=["tea", "soup"])
line_cap = OrderingConstraints([MaximumOrderQuantity(30.0, mode="adjust")])
priority_first = SimulationEngine().run(
policy=policy,
demand_source=demand,
inventory=opening,
freq="D",
callbacks=[priority_top_up, batch_minimum, weekly_count],
order_constraints=line_cap,
)
priority_last = SimulationEngine().run(
policy=policy,
demand_source=demand,
inventory=opening,
freq="D",
callbacks=[batch_minimum, priority_top_up, weekly_count],
order_constraints=line_cap,
)
The event table records every step of the chain, per item and review: requested_order_quantity (the policy), callback_adjusted_order_quantity (after all callbacks), constrained_order_quantity (after the constraints) and order_quantity (placed). The batch minimum is a rule about the total order, so the table sums the items, for the first five reviews of both runs:
chain = [
"requested_order_quantity",
"callback_adjusted_order_quantity",
"order_quantity",
]
both_orders = pd.concat(
{
"Priority, then batch minimum": priority_first.to_event_frame(),
"Batch minimum, then priority": priority_last.to_event_frame(),
},
names=["callback order", "row"],
)
reviews = both_orders[
both_orders["is_review_period"] & both_orders["date"].le(dates[8])
]
reviews.groupby(["callback order", "date"], sort=False)[chain].sum()
| requested_order_quantity | callback_adjusted_order_quantity | order_quantity | ||
|---|---|---|---|---|
| callback order | date | |||
| Priority, then batch minimum | 2026-03-02 | 0.00 | 0.00 | 0.00 |
| 2026-03-04 | 129.00 | 129.00 | 121.00 | |
| 2026-03-06 | 0.00 | 0.00 | 0.00 | |
| 2026-03-08 | 105.00 | 105.00 | 89.00 | |
| 2026-03-10 | 31.00 | 61.00 | 60.00 | |
| Batch minimum, then priority | 2026-03-02 | 0.00 | 26.00 | 26.00 |
| 2026-03-04 | 74.00 | 103.00 | 103.00 | |
| 2026-03-06 | 0.00 | 19.00 | 19.00 | |
| 2026-03-08 | 53.00 | 25.00 | 25.00 | |
| 2026-03-10 | 105.00 | 127.00 | 110.00 |
Reading the table:
- 2 March. The policy orders nothing. With priority first, the top-ups are added, but the batch minimum then holds the 26-unit total and nothing is placed. With the batch minimum first, there is nothing to hold when it runs; the top-ups are added afterwards, and a 26-unit order is placed, below the minimum.
- 8 March, batch minimum first. The policy's own order adds up to 53 units, so the batch minimum holds it. The 25 units of top-ups added afterwards go out on their own.
- 10 March, priority first. The policy asks for 31 units; the top-ups lift the total to 61, so the order passes the minimum.
- The cap acts last. On 4 March (priority first), 129 units after the callbacks become 121 placed, because every line above 30 units is cut to 30.
A callback can only check what the callbacks before it have produced. A rule that must hold for the final order belongs last in the list, or in an ordering constraint, which always runs after all callbacks.
5. What each callback changed¶
The same evaluation for every run: fill rate, lost sales, average stock per item and day, and the number of order lines placed.
from stockcast.evaluation import (
InventoryEvaluator,
avg_on_hand,
fill_rate,
lost_sales_units,
sku_order_line_count,
)
runs = {
"Baseline": baseline,
"Batch minimum": with_batch,
"Batch minimum + count": with_count,
"Priority, then batch minimum (+ cap)": priority_first,
"Batch minimum, then priority (+ cap)": priority_last,
}
score_rows = []
for name, result in runs.items():
evaluator = InventoryEvaluator()
evaluator.fit(result)
scores = evaluator.evaluate(
[fill_rate, lost_sales_units, avg_on_hand, sku_order_line_count],
groupby=[],
)
score_rows.append(scores.assign(run=name))
pd.concat(score_rows).set_index("run")
| fill_rate | lost_sales_units | avg_on_hand | sku_order_line_count | |
|---|---|---|---|---|
| run | ||||
| Baseline | 0.99 | 12.00 | 16.14 | 33 |
| Batch minimum | 0.97 | 28.00 | 15.81 | 30 |
| Batch minimum + count | 0.96 | 30.00 | 15.61 | 30 |
| Priority, then batch minimum (+ cap) | 0.98 | 17.00 | 14.66 | 36 |
| Batch minimum, then priority (+ cap) | 0.95 | 42.00 | 17.01 | 46 |
What to notice:
- The batch minimum saves three order lines (30 against 33) but raises lost sales from 12 to 28 units, because held orders arrive two days later than they would have.
- The weekly count lowers average stock from 15.81 to 15.61 and adds 2 units of lost sales: the stock it removes is not there to sell.
- The same three callbacks in a different order give 17 or 42 units of lost sales. With the batch minimum first, it holds the policy's own orders, as on 8 March, and the catch-up orders that follow are cut by the 30-unit cap (127 units after callbacks, 110 placed on 10 March), so the gaps close slowly.
The audit table and the event table together show each of these steps, so a result can always be traced back to the callback that caused it.