From a weekly forecast to a weekly order¶
A shop replenishes a durable tea product every Monday. It has weekly demand history, some stock left from last week, and a forecast for the coming week. How does that forecast become an order?
This notebook shows how a forecaster and Stockcast work together, in three steps:
- One Monday, step by step. A forecast becomes a target, the target becomes an order, and the order updates the shop's stock.
- Through time. The same steps repeat every Monday, each time with a forecast fitted on only the demand observed so far.
- The simulator.
SimulationEngineruns all eight weeks in one call and gives the same result.
We use Smooth for the forecasts, but any forecasting library would do. One weekly row is one demand period, so no demand needs to be added across dates.
1. What is known on each Monday?¶
These are artificial, uncensored weekly demand observations for a durable product. The first 20 weeks are available at the first forecast origin, the Monday on which we make the first forecast. Eight later weeks are held out. During the tutorial, each held-out observation becomes available only after its week has passed.
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from smooth import ES
sku = "tea_250g"
frequency = "W-MON"
probability = 0.90
# Show floats with two decimals in every table below.
pd.options.display.float_format = "{:.2f}".format
weeks_1_to_10 = [18, 20, 17, 22, 19, 21, 16, 23, 20, 18]
weeks_11_to_20 = [21, 24, 17, 19, 22, 20, 18, 23, 21, 19]
history_values = weeks_1_to_10 + weeks_11_to_20
future_values = [20, 23, 26, 18, 31, 22, 25, 19]
dates = pd.date_range(
"2025-01-06",
periods=len(history_values) + len(future_values),
freq=frequency,
)
series = pd.DataFrame({"date": dates, "y": history_values + future_values})
history = series.iloc[: len(history_values)].copy()
held_out = series.iloc[len(history_values) :].copy().reset_index(drop=True)
assert history.date.max() + pd.offsets.Week(weekday=0) == held_out.date.min()
print("Initially observed weeks:", len(history), "| held-out weeks:", len(held_out))
# The eight held-out weeks, revealed one at a time later on.
held_out
Initially observed weeks: 20 | held-out weeks: 8
| date | y | |
|---|---|---|
| 0 | 2025-05-26 | 20 |
| 1 | 2025-06-02 | 23 |
| 2 | 2025-06-09 | 26 |
| 3 | 2025-06-16 | 18 |
| 4 | 2025-06-23 | 31 |
| 5 | 2025-06-30 | 22 |
| 6 | 2025-07-07 | 25 |
| 7 | 2025-07-14 | 19 |
HISTORY_COLOR = "#4C78A8"
HELD_OUT_COLOR = "#F58518"
UPPER_COLOR = "#54A24B"
ON_HAND_COLOR = "#F58518"
TARGET_COLOR = "#54A24B"
ORDER_COLOR = "#4C78A8"
fig, ax = plt.subplots(figsize=(10, 3.3), layout="constrained")
ax.plot(
history.date,
history.y,
marker="o",
color=HISTORY_COLOR,
label="Initially observed demand",
)
ax.plot(
held_out.date,
held_out.y,
marker="o",
color=HELD_OUT_COLOR,
label="Demand revealed later",
)
ax.axvline(
history.date.iloc[-1],
color="0.45",
linestyle=":",
label="First forecast origin",
)
ax.set(
title="Weekly demand and the first information cutoff",
ylabel="Units",
xlabel="Week ending Monday",
)
ax.set_ylim(bottom=0)
ax.legend(frameon=False, fontsize=8)
ax.grid(axis="y", alpha=0.2)
plt.show()
2. Forecast the coming week¶
The forecaster's job ends with a number. At the first forecast origin, 2025-05-19, Smooth fits ES(model="ANN") to the 20 weeks observed so far and returns a one-step upper prediction bound at the 90% level. We round the bound up because this product is ordered in whole units.
The rounded number is the stock-position target: how much stock we want to hold, counting stock on hand and stock on the way, once this week's order has been placed. It is not automatically the purchase quantity. Smooth's normal-error assumption is illustrative and would need checking on real demand.
origin = history.date.iloc[-1]
model = ES(model="ANN")
model.fit(history["y"])
forecast = model.predict(
h=1,
interval="prediction",
level=probability,
side="upper",
cumulative=False,
)
forecast_mean = float(forecast.mean.iloc[0])
upper_bound = float(forecast.upper.iloc[0, 0])
assert np.isfinite([forecast_mean, upper_bound]).all() and upper_bound >= 0
stock_target = int(np.ceil(upper_bound))
pd.DataFrame(
{
"forecast_origin": [origin],
"forecast_mean": [forecast_mean],
"upper_90": [upper_bound],
"stock_target": [stock_target],
}
)
| forecast_origin | forecast_mean | upper_90 | stock_target | |
|---|---|---|---|---|
| 0 | 2025-05-19 | 19.90 | 22.83 | 23 |
3. Hand the target to Stockcast¶
This is the handoff between the two packages. The forecaster supplies a number and the dates it applies to; Stockcast supplies the ordering rule. A target is one row per SKU: the target itself and target_end, the date of the last period it covers, here the Monday of the week being planned.
OrderUpToPolicy from stockcast.policies is the rule: it orders enough to raise the stock position to the target. Three settings describe this shop. lead_time=0 means an accepted order is available before that week's demand. review_period=1 allows a decision every week. The protection horizon, the number of weeks one order has to cover, is therefore 0 + 1 = 1 week, which is the horizon of our one-step forecast.
When we call fit, Stockcast checks the target's declared probability, forecast origin, horizon, frequency and end date against these settings. It stops if they disagree; it never guesses a missing input.
week_date = held_out.date.iloc[0] # the Monday of the week we are planning
target_table = pd.DataFrame(
{
"unique_id": [sku],
"target": [stock_target],
"target_end": [week_date],
}
)
target_table
| unique_id | target | target_end | |
|---|---|---|---|
| 0 | tea_250g | 23 | 2025-05-26 |
from stockcast.policies import OrderUpToPolicy
policy = OrderUpToPolicy(
lead_time=0,
review_period=1,
freq=frequency,
service_level=probability,
allow_backorders=False,
date_column="target_end",
)
policy.fit(
target_table,
target_column="target",
forecast_origin=origin,
)
# The fitted target level for each SKU.
policy.get_target_levels()
| unique_id | target_level | |
|---|---|---|
| 0 | tea_250g | 23.00 |
4. Open the week, order, and update the stock¶
InventoryStateDataFrame from stockcast.core holds the shop's stock. We declare the opening stock ourselves, 4 units counted on 2025-05-19, instead of deriving it from demand. The inventory position is on-hand stock plus orders on the way, minus any backlog. With a zero lead time and no backorders it equals on-hand stock.
One week takes four calls, in this order:
advance_periodopens the week: due stock arrives, and none of this week's demand has been seen.policy.predictreads the state and returns an order request.update_inventory_with_orders(fromstockcast.utils) applies the request to the state.fulfill_demandserves the week's demand.
from stockcast.core import InventoryStateDataFrame
opening_stock = pd.DataFrame({"unique_id": [sku], "on_hand": [4.0]})
empty_state = InventoryStateDataFrame([sku], max_lead_time=0, allow_backorders=False)
opening = empty_state.initialize_from_observed(
opening_stock,
on_hand_column="on_hand",
opening_date=origin,
)
# The opening stock position.
opening_columns = ["unique_id", "date", "on_hand", "inventory_position"]
opening.inventory_position()[opening_columns]
| unique_id | date | on_hand | inventory_position | |
|---|---|---|---|---|
| 0 | tea_250g | 2025-05-19 | 4.00 | 4.00 |
Ask for an order¶
advance_period opens the week, and predict returns the order request. The request is the target minus the inventory position: 23 − 4 = 19.
state = opening.advance_period(
freq=frequency,
is_review_period=policy.schedule.should_decide(0),
)
epoch = state.get_dataframe().iloc[0]
decision = policy.predict(state, current_period=int(epoch["period"]))
# The decision is only a request; the state has not changed yet.
decision.get_dataframe()
| unique_id | order_quantity | target_level | inventory_position | reorder_point | order_period | expected_delivery_period | |
|---|---|---|---|---|---|---|---|
| 0 | tea_250g | 19.00 | 23.00 | 4.00 | NaN | 1 | 1 |
Apply the order¶
predict only makes a request. update_inventory_with_orders puts the 19 units into the state. With a zero lead time they arrive at once, before any demand, so on-hand rises from 4 to 23.
from stockcast.utils import update_inventory_with_orders
state = update_inventory_with_orders(state, decision, policy=policy)
after_order_columns = [
"unique_id",
"on_hand",
"latest_order",
"latest_received",
"inventory_position",
"date",
]
state.inventory_position()[after_order_columns]
| unique_id | on_hand | latest_order | latest_received | inventory_position | date | |
|---|---|---|---|---|---|---|
| 0 | tea_250g | 23.00 | 19.00 | 19.00 | 23.00 | 2025-05-26 |
Serve the demand¶
Now the week's demand is revealed, 20 units, and fulfill_demand serves it from stock: 23 − 20 = 3 units are left, with nothing lost.
week_demand = pd.DataFrame(
{
"unique_id": [sku],
"date": [week_date],
"y": [float(held_out.y.iloc[0])],
}
)
state = state.fulfill_demand(week_demand)
# The week's stock balance: 4 on hand + 19 received - 20 sold = 3 left.
first_week = state.inventory_position().iloc[0]
assert first_week["on_hand"] == 4 + decision.get_dataframe().order_quantity.iloc[0] - 20
assert first_week["latest_shortage"] == 0
after_demand_columns = [
"unique_id",
"on_hand",
"latest_fulfilled",
"latest_shortage",
"date",
]
state.inventory_position()[after_demand_columns]
| unique_id | on_hand | latest_fulfilled | latest_shortage | date | |
|---|---|---|---|---|---|
| 0 | tea_250g | 3.00 | 20.00 | 0.00 | 2025-05-26 |
5. Move through time¶
A real shop does this every Monday, with one difference: by then it has seen one more week of demand. So each Monday starts with a fresh forecast fitted on only the demand observed so far, and then repeats the four calls from section 4.
plan_week packages the forecast-to-policy handoff from sections 2 and 3. It takes the forecast origin and the week to plan, forecasts from the demand up to that origin, and returns the fitted policy. The assert inside is the information cutoff: the week being planned is never part of the data used to forecast it.
def plan_week(origin, week_date):
"""Forecast from the demand known at `origin` and fit the policy for one week."""
assert origin < week_date # the week being planned is never in the fit data
known_demand = series.loc[series["date"] <= origin, "y"]
model = ES(model="ANN")
model.fit(known_demand)
forecast = model.predict(
h=1,
interval="prediction",
level=probability,
side="upper",
cumulative=False,
)
forecast_mean = float(forecast.mean.iloc[0])
upper_bound = float(forecast.upper.iloc[0, 0])
assert np.isfinite([forecast_mean, upper_bound]).all() and upper_bound >= 0
target_table = pd.DataFrame(
{
"unique_id": [sku],
"target": [int(np.ceil(upper_bound))],
"target_end": [week_date],
}
)
policy = OrderUpToPolicy(
lead_time=0,
review_period=1,
freq=frequency,
service_level=probability,
allow_backorders=False,
date_column="target_end",
)
policy.fit(target_table, target_column="target", forecast_origin=origin)
forecast_row = {
"forecast_mean": forecast_mean,
"upper_90": upper_bound,
"stock_target": int(target_table["target"].iloc[0]),
}
return policy, forecast_row
weekly_rows = []
state = opening
origin = history.date.iloc[-1]
for week_number, week in enumerate(held_out.itertuples(index=False)):
# Forecast with the demand known on this Monday, then fit the policy.
policy, forecast_row = plan_week(origin, week.date)
# Open the week and ask for an order.
state = state.advance_period(
freq=frequency,
is_review_period=policy.schedule.should_decide(week_number),
)
starting_position = float(state.inventory_position()["inventory_position"].iloc[0])
current_period = int(state.get_dataframe()["period"].iloc[0])
decision = policy.predict(state, current_period=current_period)
# Apply the order, reveal the week's demand and serve it.
state = update_inventory_with_orders(state, decision, policy=policy)
week_demand = pd.DataFrame(
{"unique_id": [sku], "date": [week.date], "y": [float(week.y)]}
)
state = state.fulfill_demand(week_demand)
after = state.inventory_position().iloc[0]
weekly_rows.append(
{
"date": week.date,
**forecast_row,
"starting_position": starting_position,
"demand": float(week.y),
"ordered": after["latest_order"],
"received": after["latest_received"],
"sold": after["latest_fulfilled"],
"end_on_hand": after["on_hand"],
"lost_sales": after["latest_shortage"],
}
)
origin = week.date # the next forecast may use this week's demand
weekly = pd.DataFrame(weekly_rows)
# Each row is one Monday. The order equals the target minus the starting
# position, and the stock balance holds in every week.
assert np.allclose(
weekly["ordered"],
np.maximum(0, weekly["stock_target"] - weekly["starting_position"]),
)
assert np.allclose(
weekly["starting_position"] + weekly["received"] - weekly["sold"],
weekly["end_on_hand"],
)
weekly
| date | forecast_mean | upper_90 | stock_target | starting_position | demand | ordered | received | sold | end_on_hand | lost_sales | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2025-05-26 | 19.90 | 22.83 | 23 | 4.00 | 20.00 | 19.00 | 19.00 | 20.00 | 3.00 | 0.00 |
| 1 | 2025-06-02 | 19.90 | 22.75 | 23 | 3.00 | 23.00 | 20.00 | 20.00 | 23.00 | 0.00 | 0.00 |
| 2 | 2025-06-09 | 20.05 | 22.95 | 23 | 0.00 | 26.00 | 23.00 | 23.00 | 23.00 | 0.00 | 3.00 |
| 3 | 2025-06-16 | 20.30 | 23.58 | 24 | 0.00 | 18.00 | 24.00 | 24.00 | 18.00 | 6.00 | 0.00 |
| 4 | 2025-06-23 | 20.21 | 23.46 | 24 | 6.00 | 31.00 | 18.00 | 18.00 | 24.00 | 0.00 | 7.00 |
| 5 | 2025-06-30 | 20.64 | 24.90 | 25 | 0.00 | 22.00 | 25.00 | 25.00 | 22.00 | 3.00 | 0.00 |
| 6 | 2025-07-07 | 22.06 | 26.27 | 27 | 3.00 | 25.00 | 24.00 | 24.00 | 25.00 | 2.00 | 0.00 |
| 7 | 2025-07-14 | 22.87 | 27.05 | 28 | 2.00 | 19.00 | 26.00 | 26.00 | 19.00 | 9.00 | 0.00 |
Each row is one Monday. The policy requests max(0, target − starting position), the order arrives before demand, and the week's stock balance holds: starting stock + received − sold = ending on-hand. The first row matches the single week we followed in section 4. A 90% forecast quantile does not promise a 90% realized fill rate.
Read forecast and inventory together¶
The upper line in the first panel is the one-week forecast bound; the later observed demand can fall above it. The lower panel shows the stock target, the actual order, and end-of-week on-hand. Order and receipt coincide here because the lead time is zero, but each order is still reduced by the stock already available. On June 23, six units remain, so the shop orders 18 toward a target of 24; demand of 31 then causes seven lost sales.
fig, axes = plt.subplots(2, 1, figsize=(11, 6.4), sharex=True, layout="constrained")
axes[0].plot(
weekly.date,
weekly.forecast_mean,
marker="o",
color=ORDER_COLOR,
label="Forecast mean",
)
axes[0].plot(
weekly.date,
weekly.upper_90,
marker="o",
color=UPPER_COLOR,
label="Forecast upper 90%",
)
axes[0].plot(
weekly.date,
weekly.demand,
marker="D",
color=HELD_OUT_COLOR,
label="Later observed demand",
)
axes[0].set(title="One-week forecasts and observed demand", ylabel="Units")
axes[0].legend(frameon=False, ncol=3, fontsize=8)
axes[1].step(
weekly.date,
weekly.stock_target,
where="mid",
color=TARGET_COLOR,
label="Stock-position target",
)
axes[1].plot(
weekly.date,
weekly.ordered,
marker="o",
color=ORDER_COLOR,
label="Units ordered before demand",
)
axes[1].plot(
weekly.date,
weekly.end_on_hand,
marker="s",
color=ON_HAND_COLOR,
label="End-of-week on hand",
clip_on=False, # markers at zero are drawn in full
)
axes[1].set(
title="Target becomes an order after accounting for stock",
ylabel="Units",
xlabel="Week ending Monday",
)
axes[1].legend(frameon=False, ncol=2, fontsize=8)
for ax in axes:
ax.set_ylim(bottom=0)
ax.grid(axis="y", alpha=0.2)
# One tick per week (each is a Monday).
axes[1].set_xticks(weekly.date)
axes[1].xaxis.set_major_formatter(mdates.DateFormatter("%b %d"))
axes[1].tick_params(axis="x", labelrotation=30)
plt.show()
6. Let the simulator run the loop¶
SimulationEngine from stockcast.core runs a complete simulation in one call: for every period it receives due stock, asks the policy for an order, applies it, serves demand, and records everything in a validated event table. It is the same loop as section 5, written once and checked for you.
The engine never fits a forecasting model. You give it fitted policy snapshots: one for the first week (policy) and one for each later decision, keyed by demand period (policy_schedule). Here we prepare all eight snapshots first, with the same plan_week, so the example runs in one pass. Each fit still uses only the demand known at its own Monday. In a live system, each snapshot would be created when its Monday arrives.
snapshots = []
origin = history.date.iloc[-1]
for week in held_out.itertuples(index=False):
snapshot, _ = plan_week(origin, week.date)
snapshots.append(snapshot)
origin = week.date
from stockcast.core import SimulationEngine
demand = pd.DataFrame(
{
"unique_id": sku,
"period": range(len(held_out)),
"date": held_out["date"],
"y": held_out["y"].astype(float),
}
)
engine_opening = empty_state.initialize_from_observed(
opening_stock,
on_hand_column="on_hand",
opening_date=history.date.iloc[-1],
)
result = SimulationEngine().run(
policy=snapshots[0], # the first week's target
policy_schedule={
period: snapshots[period] for period in range(1, len(snapshots))
}, # later weeks replace the target
demand_source=demand,
inventory=engine_opening,
)
# The engine's event table has the same orders, receipts, sales, ending stock
# and lost sales as the loop we wrote by hand.
events = result.to_event_frame()
period_events = events.loc[events["event_type"] == "period"].reset_index(drop=True)
assert np.allclose(period_events["order_quantity"], weekly["ordered"])
assert np.allclose(period_events["received_units"], weekly["received"])
assert np.allclose(period_events["fulfilled_units"], weekly["sold"])
assert np.allclose(period_events["ending_on_hand"], weekly["end_on_hand"])
assert np.allclose(period_events["lost_sales_units"], weekly["lost_sales"])
print(
"The engine's event table matches the hand-driven loop in all",
len(weekly),
"weeks.",
)
The engine's event table matches the hand-driven loop in all 8 weeks.
stockcast.evaluation turns that event table into metrics. InventoryEvaluator scores the run, and each metric answers a different question:
- Fill rate: the share of demand served from stock in the week it occurred.
- Demand-period service level: the share of weeks with demand in which nothing was short.
- Average on-hand: the mean ending stock per week.
- Order units: the total quantity ordered.
- Order event count: the number of weeks in which an order was placed.
Read together, they can tell different stories. Here the fill rate is high, 0.95, but the service level is 0.75: two of the eight weeks (June 9 and June 23) ran short, by 3 and 7 units. A quarter of the weeks fell short even though only about 5% of the demand was lost. The shop placed an order every week, 179 units in total, and ended the average week with 2.88 units on hand.
These eight artificial weeks show mechanics and realized outcomes only. Choosing and validating the forecasting model, target probability, and product assumptions for a real operation requires more held-out evidence.
from stockcast.evaluation import (
InventoryEvaluator,
avg_on_hand,
demand_period_service_level,
fill_rate,
order_event_count,
order_units,
)
evaluator = InventoryEvaluator()
evaluator.fit(simulation_result=result, window="scoring")
# groupby=[] pools every week into a single row.
evaluator.evaluate(
metrics=[
fill_rate,
demand_period_service_level,
avg_on_hand,
order_units,
order_event_count,
],
groupby=[],
)
| fill_rate | demand_period_service_level | avg_on_hand | order_units | order_event_count | |
|---|---|---|---|---|---|
| 0 | 0.95 | 0.75 | 2.88 | 179.00 | 8 |