Skip to content

Callbacks

Real operations have exceptions: a supplier closes for a holiday, a buyer doubles an order before a promotion, a stock count finds missing units. Callbacks let you add such events to a simulation without changing the policy or the engine. Like callbacks in Keras, they are called at fixed moments of each period, and every change they make is written to an audit table.

Hooks

Hook When What it may change Returns
on_after_prediction(decision, context) after the policy proposes an order, before constraints the order quantity per SKU (absolute values) OrderAdjustmentResult or None
on_after_demand(context) after demand is served, before the period is recorded on-hand stock per SKU (signed changes) InventoryAdjustmentResult or None

A callback proposes; the engine validates and applies. An order change passes through the ordering constraints afterwards. A stock change affects the next decisions and is recorded in the event table as inventory_adjustment_units. It cannot undo today's lost sales, because those customers have already left.

Both hooks receive a CallbackContext with a copy of the state (inventory), the period and date, the run_window, and the phase.

Built-in scheduled callbacks

For planned, dated events, write a small table and use a built-in callback. Rows match on date or period (the state period, see Timing). Optional reason and source columns label each row in the audit trail. A table may reach beyond the run, so you can reuse one calendar for several runs, but at least one row must fall inside the run; otherwise the run stops before it starts and names both date ranges.

Callback Hook Extra column Effect
ScheduledOrderOverride after prediction order_quantity set the order to this value
ScheduledOrderMultiplier after prediction multiplier multiply the proposed order
ScheduledOrderHold after prediction – set the order to zero
ScheduledInventoryAdjustment after demand quantity_delta (and optional received_date) add or remove stock
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,
)
from stockcast.core import (
    ScheduledInventoryAdjustment, ScheduledOrderMultiplier, SimulationEngine,
)

promotion = ScheduledOrderMultiplier(pd.DataFrame({
    "unique_id": [sku],
    "date": [pd.Timestamp("2026-01-22")],
    "multiplier": [1.5],
    "reason": ["stock up before the tea festival"],
    "source": ["marketing calendar"],
}))
stock_count = ScheduledInventoryAdjustment(pd.DataFrame({
    "unique_id": [sku],
    "date": [pd.Timestamp("2026-01-25")],
    "quantity_delta": [-3.0],
    "reason": ["damaged packs found in count"],
    "source": ["weekly stock count"],
}))

result = SimulationEngine().run(
    policy=policy, demand_source=demand, inventory=inventory,
    callbacks=[promotion, stock_count], **run_settings,
)
result.to_callback_audit_frame()[["phase", "date", "before_value", "after_value", "reason"]]
                 phase       date  before_value  after_value                            reason
0  on_after_prediction 2026-01-22          17.0         25.5  stock up before the tea festival
1      on_after_demand 2026-01-25          28.5         25.5      damaged packs found in count

The audit table records each accepted effect: which callback, when, the value before and after, and why. On 22 January the policy asked for 17 packs and the promotion turned that into 25.5; on 25 January the count removed 3 packs from the shelf.

Custom callbacks

Subclass SimulationCallback and implement the hook you need. Return None when there is nothing to change. This callback expedites orders when the shelf is nearly empty:

from stockcast.core import OrderAdjustmentResult, SimulationCallback


class TopUpWhenLow(SimulationCallback):
    """Add `extra` units to any order placed while on-hand stock is below `floor`."""

    def __init__(self, floor, extra):
        self.floor, self.extra = floor, extra

    def on_after_prediction(self, decision, context):
        orders = decision.get_dataframe()
        stock = context.inventory.set_index(context.sku_column)["on_hand"]
        low = orders["unique_id"].map(stock) < self.floor
        if not low.any():
            return None
        return OrderAdjustmentResult(pd.DataFrame({
            "unique_id": orders.loc[low, "unique_id"],
            "order_quantity": orders.loc[low, "order_quantity"] + self.extra,
            "reason": "shelf below floor",
            "source": "store manager rule",
        }))

    def get_config(self):
        return {"floor": self.floor, "extra": self.extra}


result = SimulationEngine().run(
    policy=policy, demand_source=demand, inventory=inventory,
    callbacks=[TopUpWhenLow(floor=18, extra=6)], **run_settings,
)
result.to_callback_audit_frame()[["date", "before_value", "after_value", "reason"]]
        date  before_value  after_value             reason
0 2026-01-18          32.0         38.0  shelf below floor
1 2026-01-30          29.0         35.0  shelf below floor
2 2026-02-23          29.0         35.0  shelf below floor

On three review days the shelf was below 18 packs, and each of those orders got 6 extra packs.

Rules the engine applies

  • Order adjustments name SKUs present in the decision and give finite, non-negative absolute quantities. Optional reason and source labels go to the audit trail (empty when left out, never blank).
  • Stock adjustments give a signed quantity_delta per SKU. A removal cannot exceed on-hand stock, and stock cannot be added while the SKU has backorders. With shelf life, added stock needs a received_date so its age is known.
  • Callbacks run in list order; each sees the result of the ones before it.
  • reset(context) runs before every run (and every comparison branch), so keep run-local state there. get_config() returns JSON-serialisable settings for the run manifest.
  • An error inside a callback stops the run with a CallbackError naming the callback, the hook, the period, and the date.

Callback, constraint, or process?

  • A one-off or dated exception to an order or to stock: a callback.
  • A rule every order must satisfy: an ordering constraint.
  • A recurring physical mechanism (expiry, inspections, returns): an inventory process.

See also: Notebook 08: callbacks and audit · Notebook 05c: extension points