One forecast-to-order workflow on an irregular calendar¶
A tea buyer can order on only three dates during an eleven-day planning window. Delivery takes one day. How do the forecast, decision calendar, policy, and simulator work together?
This example joins the pieces introduced earlier:
DecisionSchedule + lead time → dated coverage window
observed demand + window → Smooth's cumulative forecast → stock target
schedule + target → OrderUpToPolicy → SimulationEngine
→ event table and metrics
The schedule says when a decision is allowed. A fitted policy snapshot supplies the target available at that decision. The engine applies the policy, moves accepted orders through the delivery pipeline, serves demand, and records what happened.
import pandas as pd
# Show every number with two decimals.
pd.options.display.float_format = "{:.2f}".format
1. Declare the demand path and order opportunities¶
These artificial observations are uncensored demand, not sales limited by stock. The first 24 days are available at the opening date. Eleven later days are held out for the inventory replay and become forecast history only after they occur. One daily row is one engine demand period.
sku = "tea_250g"
frequency = "D"
lead_time = 1
service_level = 0.90
history_values = [
5,
6,
4,
7,
6,
5,
8,
6,
5,
7,
6,
5,
7,
8,
5,
6,
7,
5,
6,
8,
7,
6,
5,
7,
]
future_values = [6, 7, 8, 5, 7, 9, 6, 8, 5, 7, 6]
opening_date = pd.Timestamp("2026-03-01")
history = pd.DataFrame(
{
"date": pd.date_range(
end=opening_date,
periods=len(history_values),
freq=frequency,
),
"y": history_values,
}
)
holdout = pd.DataFrame(
{
"unique_id": sku,
# Zero-based demand periods within this run.
"period": range(len(future_values)),
"date": pd.date_range(
opening_date + pd.Timedelta(days=1),
periods=len(future_values),
freq=frequency,
),
"y": future_values,
}
)
# The held-out days begin the day after the history ends.
assert history["date"].max() == opening_date
assert holdout["date"].min() == opening_date + pd.Timedelta(days=1)
An ExplicitSchedule from stockcast.core lists the only demand periods at which an order can be requested. Period 0 is the first held-out day, and periods 3 and 7 are later opportunities. A decision happens before that period's demand, using observations through the preceding date. A decision opportunity can still produce a zero order.
from stockcast.core import ExplicitSchedule
decision_periods = (0, 3, 7)
schedule = ExplicitSchedule(decision_periods)
assert [t for t in range(len(holdout)) if schedule.should_decide(t)] == list(
decision_periods
)
holdout.loc[holdout["period"].isin(decision_periods), ["period", "date"]].reset_index(
drop=True
)
| period | date | |
|---|---|---|
| 0 | 0 | 2026-03-02 |
| 1 | 3 | 2026-03-05 |
| 2 | 7 | 2026-03-09 |
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
HISTORY_COLOR = "#4C78A8"
DEMAND_COLOR = "#F58518"
DECISION_COLOR = "#6B7280"
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)
def format_dates(ax, dates):
ax.set_xticks(dates)
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d"))
ax.tick_params(axis="x", rotation=30)
decision_dates = holdout.set_index("period").loc[list(decision_periods), "date"]
fig, ax = plt.subplots(figsize=(10.5, 3.8), layout="constrained")
ax.plot(
history["date"],
history["y"],
marker=".",
color=HISTORY_COLOR,
label="Initially observed demand",
)
ax.plot(
holdout["date"],
holdout["y"],
marker="o",
color=DEMAND_COLOR,
label="Demand revealed during replay",
)
for index, day in enumerate(decision_dates):
ax.axvline(
day,
color=DECISION_COLOR,
linestyle=":",
alpha=0.75,
label="Decision opportunity" if index == 0 else None,
)
ax.set(
title="The three order opportunities and the demand revealed over time",
xlabel="Date",
ylabel="Units",
)
all_dates = pd.concat([history["date"], holdout["date"]])
format_dates(ax, pd.date_range(all_dates.min(), all_dates.max(), freq="4D"))
style_axis(ax)
fig.legend(frameon=False, loc="outside lower center", ncol=3)
plt.show()
2. Match each forecast to its decision window¶
For a decision at period t, let u be the next opportunity. With one-day delivery, today's stock position must cover H = (u − t) + 1 demand periods, because the next order cannot arrive until period u + 1. The first two windows are therefore (3 − 0) + 1 = 4 days and (7 − 3) + 1 = 5 days. At the final decision there is no later opportunity in this run, so we explicitly cover the four remaining demand periods through day 11. That last choice is a finite run assumption, not a horizon Stockcast infers.
window_rows = []
for t in decision_periods:
next_t = schedule.next_decision_period(t)
horizon = (next_t - t + lead_time) if next_t is not None else len(holdout) - t
origin = opening_date + pd.Timedelta(days=t)
window_rows.append(
{
"decision_period": t,
"next_opportunity": str(next_t) if next_t is not None else "end of run",
"coverage_days": horizon,
"forecast_origin": origin,
"target_end_date": origin + pd.Timedelta(days=horizon),
}
)
windows = pd.DataFrame(window_rows)
assert windows["coverage_days"].tolist() == [4, 5, 4]
windows
| decision_period | next_opportunity | coverage_days | forecast_origin | target_end_date | |
|---|---|---|---|---|---|
| 0 | 0 | 3 | 4 | 2026-03-01 | 2026-03-05 |
| 1 | 3 | 7 | 5 | 2026-03-04 | 2026-03-09 |
| 2 | 7 | end of run | 4 | 2026-03-08 | 2026-03-12 |
The window table is a forecasting requirement, not an order. For each window the function below does four things:
- keeps only the demand already observed at the forecast origin, so nothing from the decision day or later enters the fit;
- fits Smooth's
ES(model="ANN")model, an external forecasting package, and asks it directly for the cumulative upper bound of the window's demand. We never add daily upper bounds, and Notebook 04c explains why; - rounds that bound up to whole stock units, which is the target;
- fits an
OrderUpToPolicyfromstockcast.policieson the target. The schedule and lead time stay fixed, while the forecast origin, target value, coverage length and end date change. Stockcast validates those declarations when fitting and again against the actual decision windows before the run.
For the figure only, it also adds up the daily forecast means to show the expected running total. Smooth's normal-error assumption and the 90% probability are illustrative, not validated for a real product.
import numpy as np
from smooth import ES
from stockcast.policies import OrderUpToPolicy
def plan_window(window):
"""Forecast from the demand known at one decision and fit its policy snapshot."""
t, horizon, origin = (
window.decision_period,
window.coverage_days,
window.forecast_origin,
)
observed_so_far = holdout.loc[holdout["period"].lt(t), ["date", "y"]]
available = pd.concat([history, observed_so_far], ignore_index=True)
assert available["date"].max() == origin
model = ES(model="ANN")
model.fit(available["y"])
daily_forecast = model.predict(
h=horizon,
interval="prediction",
level=service_level,
side="upper",
cumulative=False,
)
cumulative_forecast = model.predict(
h=horizon,
interval="prediction",
level=service_level,
side="upper",
cumulative=True,
)
upper_bound = float(cumulative_forecast.upper.iloc[-1, 0])
assert np.isfinite(upper_bound) and upper_bound >= 0
stock_target = int(np.ceil(upper_bound))
forecast_dates = pd.date_range(
origin + pd.Timedelta(days=1),
periods=horizon,
freq=frequency,
)
assert forecast_dates[-1] == window.target_end_date
policy = OrderUpToPolicy(
lead_time=lead_time,
freq=frequency,
schedule=schedule,
service_level=service_level,
allow_backorders=False,
date_column="target_end_date",
)
policy.fit(
pd.DataFrame(
{
"unique_id": [sku],
"protection_target": [stock_target],
"target_end_date": [window.target_end_date],
}
),
target_column="protection_target",
protection_horizon=horizon,
forecast_origin=origin,
)
forecast_row = {
"decision_period": t,
"fit_rows": len(available),
"cumulative_upper_90": upper_bound,
"stock_target": stock_target,
}
mean_path = pd.DataFrame(
{
"decision_period": t,
"date": forecast_dates,
"cumulative_mean": daily_forecast.mean.to_numpy().cumsum(),
}
)
return policy, forecast_row, mean_path
policies = {}
forecast_rows = []
mean_paths = []
for window in windows.itertuples(index=False):
policy, forecast_row, mean_path = plan_window(window)
policies[window.decision_period] = policy
forecast_rows.append(forecast_row)
mean_paths.append(mean_path)
forecasts = pd.DataFrame(forecast_rows)
paths = pd.concat(mean_paths, ignore_index=True)
# Each fit used the 24 original rows plus the demand revealed before its decision.
assert forecasts["fit_rows"].tolist() == [24, 27, 31]
forecasts
| decision_period | fit_rows | cumulative_upper_90 | stock_target | |
|---|---|---|---|---|
| 0 | 0 | 24 | 27.42 | 28 |
| 1 | 3 | 27 | 34.39 | 35 |
| 2 | 7 | 31 | 28.26 | 29 |
fit_rows grows from 24 to 27 to 31 as demand is revealed. The cumulative upper bounds 27.42, 34.39 and 28.26 are rounded up to the whole-unit targets 28, 35 and 29. The middle target is the largest because its window is five days long, one day longer than the other two.
from matplotlib.lines import Line2D
WINDOW_COLORS = {0: "#4C78A8", 3: "#F58518", 7: "#54A24B"}
fig, ax = plt.subplots(figsize=(10.5, 4.2), layout="constrained")
for window in windows.itertuples(index=False):
t = window.decision_period
path = paths.loc[paths["decision_period"].eq(t)]
target = forecasts.loc[forecasts["decision_period"].eq(t), "stock_target"].iloc[0]
ax.plot(
path["date"],
path["cumulative_mean"],
marker="o",
color=WINDOW_COLORS[t],
label=f"{window.forecast_origin:%b %d}: {len(path)}-day mean",
)
ax.scatter(
path["date"].iloc[-1],
target,
marker="D",
s=64,
color=WINDOW_COLORS[t],
zorder=3,
clip_on=False,
)
ax.set(
title="Different decision gaps require different cumulative forecast windows",
xlabel="Protected demand dates",
ylabel="Cumulative units",
)
format_dates(ax, list(paths["date"].drop_duplicates()))
style_axis(ax)
# The diamond marks the stock target of its window.
handles, labels = ax.get_legend_handles_labels()
handles.append(Line2D([], [], marker="D", linestyle="", color="0.35"))
labels.append("Upper target (rounded up)")
ax.legend(handles, labels, frameon=False, fontsize=8, loc="upper left")
plt.show()
The target is a stock-position level. At each allowed date, OrderUpToPolicy requests max(0, target − inventory position), accounting for physical stock and any order still in transit. policy_schedule supplies the later fitted target snapshots; it does not create new decision dates, because the explicit schedule does that.
3. Give the simulator the schedule, policies, and demand¶
Opening stock is an observed scenario input of four units, independent of the forecast. SimulationEngine from stockcast.core receives the complete held-out demand table for a reproducible replay, the initial fitted policy for period 0, and replacement targets for periods 3 and 7. Each forecast above used only demand that would already have been observed at its own decision date. The engine checks policy compatibility, timing and target coverage before running, and it never fits Smooth itself.
from stockcast.core import InventoryStateDataFrame, SimulationEngine
empty_state = InventoryStateDataFrame(
[sku],
max_lead_time=lead_time,
allow_backorders=False,
)
opening = empty_state.initialize_from_observed(
pd.DataFrame({"unique_id": [sku], "on_hand": [4.0]}),
on_hand_column="on_hand",
opening_date=opening_date,
)
updates = {t: policies[t] for t in decision_periods[1:]}
result = SimulationEngine().run(
policy=policies[0],
policy_schedule=updates,
demand_source=holdout,
inventory=opening,
)
# The run record repeats the declared schedule and the target update periods.
run_settings = result.run_manifest["run_settings"]
assert run_settings["decision_schedule"]["periods"] == list(decision_periods)
assert run_settings["policy_update_periods"] == list(decision_periods[1:])
4. Follow decisions into physical stock¶
The event table is the record of what the engine did, one row per day. A scheduled decision is visible even if its requested order is zero. The order at period 0 enters the pipeline. With lead time one it is received before period 1 demand, not before period 0 demand, so that first day can have lost sales despite a positive opening order.
events = result.to_event_frame(window="all")
periods = events.loc[events["event_type"].eq("period")].copy()
columns = [
"demand_period",
"date",
"decision_flag",
"target_level",
"demand",
"order_quantity",
"received_units",
"lost_sales_units",
"ending_on_hand",
]
periods[columns].reset_index(drop=True)
| demand_period | date | decision_flag | target_level | demand | order_quantity | received_units | lost_sales_units | ending_on_hand | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 2026-03-02 | True | 28.00 | 6.00 | 24.00 | 0.00 | 2.00 | 0.00 |
| 1 | 1 | 2026-03-03 | False | 28.00 | 7.00 | 0.00 | 24.00 | 0.00 | 17.00 |
| 2 | 2 | 2026-03-04 | False | 28.00 | 8.00 | 0.00 | 0.00 | 0.00 | 9.00 |
| 3 | 3 | 2026-03-05 | True | 35.00 | 5.00 | 26.00 | 0.00 | 0.00 | 4.00 |
| 4 | 4 | 2026-03-06 | False | 35.00 | 7.00 | 0.00 | 26.00 | 0.00 | 23.00 |
| 5 | 5 | 2026-03-07 | False | 35.00 | 9.00 | 0.00 | 0.00 | 0.00 | 14.00 |
| 6 | 6 | 2026-03-08 | False | 35.00 | 6.00 | 0.00 | 0.00 | 0.00 | 8.00 |
| 7 | 7 | 2026-03-09 | True | 29.00 | 8.00 | 21.00 | 0.00 | 0.00 | 0.00 |
| 8 | 8 | 2026-03-10 | False | 29.00 | 5.00 | 0.00 | 21.00 | 0.00 | 16.00 |
| 9 | 9 | 2026-03-11 | False | 29.00 | 7.00 | 0.00 | 0.00 | 0.00 | 9.00 |
| 10 | 10 | 2026-03-12 | False | 29.00 | 6.00 | 0.00 | 0.00 | 0.00 | 3.00 |
actual_decisions = periods.loc[periods["decision_flag"], "demand_period"].tolist()
assert actual_decisions == list(decision_periods)
# Every order is received one day (the lead time) after it is placed.
by_period = periods.set_index("demand_period")
for t in decision_periods:
assert np.isclose(
by_period.loc[t + lead_time, "received_units"],
by_period.loc[t, "order_quantity"],
)
first = by_period.loc[0]
assert first["received_units"] == 0 and first["lost_sales_units"] == 2
print(
f"First day: {first['starting_on_hand']:.0f} opening + "
f"{first['received_units']:.0f} received - {first['fulfilled_units']:.0f} sold = "
f"{first['ending_on_hand']:.0f} on hand; {first['lost_sales_units']:.0f} lost "
f"sales. The {first['order_quantity']:.0f}-unit order arrives next day."
)
First day: 4 opening + 0 received - 4 sold = 0 on hand; 2 lost sales. The 24-unit order arrives next day.
The upper target and the actual order have different meanings. The target covers a dated forecast window, while the order fills the gap from inventory position at a decision. The figure connects those decisions to later receipts and end-of-day stock. Vertical lines mark opportunities, including dates when the engine may choose to order nothing. The red part of the first bar is the lost sales on the first day.
FULFILLED_COLOR = "#4C78A8"
LOST_COLOR = "#E45756"
ORDER_COLOR = "#F58518"
RECEIPT_COLOR = "#54A24B"
dates = periods["date"]
fig, axes = plt.subplots(3, 1, figsize=(11, 7.6), sharex=True, layout="constrained")
# Demand served from stock, with the unserved part stacked in red.
axes[0].bar(
dates,
periods["fulfilled_units"],
color=FULFILLED_COLOR,
label="Served from stock",
)
axes[0].bar(
dates,
periods["lost_sales_units"],
bottom=periods["fulfilled_units"],
color=LOST_COLOR,
label="Lost sales",
)
axes[0].set(title="Demand arriving after each pre-demand decision", ylabel="Units")
order_days = periods.loc[periods["order_quantity"].gt(0)]
receipt_days = periods.loc[periods["received_units"].gt(0)]
axes[1].vlines(
order_days["date"],
0,
order_days["order_quantity"],
color=ORDER_COLOR,
linewidth=2,
)
axes[1].scatter(
order_days["date"],
order_days["order_quantity"],
color=ORDER_COLOR,
s=45,
label="Order placed",
)
axes[1].scatter(
receipt_days["date"],
receipt_days["received_units"],
color=RECEIPT_COLOR,
marker="s",
s=45,
label="Receipt before demand",
)
axes[1].set(title="Orders and one-day-later receipts", ylabel="Units")
axes[2].step(
dates,
periods["ending_on_hand"],
where="mid",
color=FULFILLED_COLOR,
linewidth=2,
label="End-of-day on hand",
)
axes[2].step(
dates,
periods["on_order_end"],
where="mid",
color=DECISION_COLOR,
linewidth=2,
label="End-of-day pipeline",
)
axes[2].set(
title="Physical stock remains separate from outstanding orders",
ylabel="Units",
xlabel="Date",
)
for ax in axes:
for index, t in enumerate(decision_periods):
ax.axvline(
by_period.loc[t, "date"],
color="0.55",
linestyle=":",
alpha=0.55,
label="Decision opportunity" if ax is axes[0] and index == 0 else None,
)
style_axis(ax)
ax.legend(frameon=False, fontsize=8, loc="upper left", bbox_to_anchor=(1.01, 1.0))
format_dates(axes[2], list(dates[::2]))
plt.show()
stockcast.evaluation scores the run from the event table. Fill rate is the share of demand served from stock on the day it occurred: 72 of 74 units, 0.97. Demand-period service level is the share of days with demand and no shortage: 10 of 11 days, 0.91, because only the first day ran short. The shop held 9.36 units on average and placed 3 orders totalling 71 units.
These measures describe eleven artificial days under the declared schedule, lead time, opening stock and targets. A 90% target is not a promise of a 90% fill rate, and neither the forecast's calibration nor a good real-world order calendar is shown here. Both need separate 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")
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.97 | 0.91 | 9.36 | 71.00 | 3 |