Rolling cumulative forecast targets¶
Notebook 04c made one forecast at the start of the run and kept its target for all twelve days. A real shop refits its forecast as new demand arrives. Here the forecast is refitted at three dates, and each refit gives a new stock target. The policy and its settings stay the same; only its target changes.
demand seen so far → forecast → stock target → fitted policy → used by the engine from that date on
SimulationEngine never fits a forecast. It receives policies that are already fitted, one for each update date, and we call each of them a snapshot. A snapshot is an ordinary OrderUpToPolicy that holds one target and no forecasting model, so choosing it later triggers no refit.
Timing matters. A decision comes before that day's demand, so a snapshot used on a given day may be built only from demand dated before that day. In a live shop a scheduler would build each snapshot when its date arrives. Here we build all three up front, but each one is fitted only on the demand that was known at its date.
import pandas as pd
# Show every number with two decimals.
pd.options.display.float_format = "{:.2f}".format
# The SKU and opening date identify the one series and its first decision point.
sku = "tea_250g"
opening_date = pd.Timestamp("2026-02-14")
period_frequency = "D"
# Orders take two days to arrive, and inventory is reviewed every two days.
lead_time = 2
review_period = 2
# Every direct target represents the 95% upper bound supplied by the forecaster.
service_level = 0.95
# An (R, S) target protects lead-time demand plus the next review interval.
protection_horizon = lead_time + review_period
# Period 0 is the first review. Periods 4 and 8 are later reviews
# where the external workflow supplies refreshed target snapshots.
forecast_update_periods = [0, 4, 8]
1. One demand path, revealed over time¶
Ten days of history are known at the opening date, and every forecast can use them. The twelve later values play two roles. They are the realized demand the simulation will use, and they also become forecast history one day at a time as their dates pass. A forecast fitted on a given date may see only the rows dated on or before it.
# These ten observations are the complete information set at period 0.
history_values = [5.0, 6.0, 4.0, 7.0, 6.0, 5.0, 8.0, 6.0, 5.0, 7.0]
history = pd.DataFrame(
{
"unique_id": sku,
"date": pd.date_range(
end=opening_date,
periods=len(history_values),
freq=period_frequency,
),
"y": history_values,
}
)
# These values are declared realized demand, not forecast output.
demand_values = [7.0, 5.0, 8.0, 6.0, 4.0, 7.0, 6.0, 9.0, 5.0, 6.0, 7.0, 5.0]
simulation_demand = pd.DataFrame(
{
"unique_id": sku,
# Demand input periods are zero-based; engine decision periods advance to 1..12.
"period": range(len(demand_values)),
"date": pd.date_range(
opening_date + pd.Timedelta(days=1),
periods=len(demand_values),
freq=period_frequency,
),
"y": demand_values,
}
)
# The later demand begins the day after the history ends and never overlaps it.
assert history["date"].max() == opening_date
assert simulation_demand["date"].min() == opening_date + pd.Timedelta(days=1)
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
HISTORY_COLOR = "#4C78A8"
DEMAND_COLOR = "#54A24B"
UPDATE_COLOR = "#E45756"
def style_axis(ax):
ax.set_ylim(bottom=0)
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)
# Translate engine decision periods into the calendar dates shown on the plot.
update_dates = [opening_date + pd.Timedelta(days=p) for p in forecast_update_periods]
fig, ax = plt.subplots(figsize=(11, 4), layout="constrained")
ax.plot(
history["date"],
history["y"],
marker="o",
color=HISTORY_COLOR,
label="Original history",
)
ax.plot(
simulation_demand["date"],
simulation_demand["y"],
marker="o",
color=DEMAND_COLOR,
label="Demand revealed during the run",
)
# Vertical lines show the information cutoff of each forecast refit.
for index, update_date in enumerate(update_dates):
ax.axvline(
update_date,
color=UPDATE_COLOR,
linestyle="--",
alpha=0.8,
label="Forecast update date" if index == 0 else None,
)
ax.set(
title="Demand information arrives before each forecast update",
xlabel="Date",
ylabel="Demand (units)",
)
style_axis(ax)
all_dates = pd.concat([history["date"], simulation_demand["date"]])
format_dates(ax, pd.date_range(all_dates.min(), all_dates.max(), freq="3D"))
fig.legend(frameon=False, loc="outside lower center", ncol=3)
plt.show()
2. One refit, step by step¶
Take the update at demand period 4. Its forecast origin is Feb 18, so the forecast may use the ten original rows plus the first four revealed ones, fourteen rows in all. The last row it sees is the origin itself.
decision_period = 4
forecast_origin = opening_date + pd.Timedelta(days=decision_period)
# Include a revealed demand row only after its date has arrived.
newly_observed = simulation_demand.loc[
simulation_demand["date"].le(forecast_origin),
["unique_id", "date", "y"],
]
available_history = pd.concat([history, newly_observed], ignore_index=True)
assert available_history["date"].max() == forecast_origin
available_history.tail()
| unique_id | date | y | |
|---|---|---|---|
| 9 | tea_250g | 2026-02-14 | 7.00 |
| 10 | tea_250g | 2026-02-15 | 7.00 |
| 11 | tea_250g | 2026-02-16 | 5.00 |
| 12 | tea_250g | 2026-02-17 | 8.00 |
| 13 | tea_250g | 2026-02-18 | 6.00 |
Smooth, an external forecasting package, fits the same simple ES(model="ANN") model it used in 04c. We ask it directly for the distribution of total demand over the four-day protection horizon and take the 95% upper bound as the target S. Notebook 04c explains why this is a cumulative bound and not a sum of daily bounds.
from smooth import ES
model = ES(model="ANN")
model.fit(available_history["y"])
cumulative_forecast = model.predict(
h=protection_horizon,
interval="prediction",
level=service_level,
side="upper",
cumulative=True,
)
# The final cumulative upper bound is the externally calculated target S.
protection_target = float(cumulative_forecast.upper.iloc[-1, 0])
target_end_date = forecast_origin + pd.Timedelta(days=protection_horizon)
print(f"Rows in the fit: {len(available_history)}")
print(
f"Target S for {forecast_origin:%b %d} to {target_end_date:%b %d}: {protection_target:.2f}"
)
Rows in the fit: 14 Target S for Feb 18 to Feb 22: 28.42
The target goes to Stockcast as a one-row table. Fitting OrderUpToPolicy from stockcast.policies on it gives the snapshot. The fit also checks that target_end_date lies exactly protection_horizon days after the forecast origin.
from stockcast.policies import OrderUpToPolicy
target_for_fit = pd.DataFrame(
{
"unique_id": [sku],
"protection_target": [protection_target],
"target_end_date": [target_end_date],
}
)
# Fit a new snapshot; do not mutate or refit a policy already used elsewhere.
snapshot = OrderUpToPolicy(
lead_time=lead_time,
review_period=review_period,
freq=period_frequency,
service_level=service_level,
allow_backorders=False,
date_column="target_end_date",
)
snapshot.fit(
target_for_fit,
target_column="protection_target",
forecast_origin=forecast_origin,
)
target_for_fit
| unique_id | protection_target | target_end_date | |
|---|---|---|---|
| 0 | tea_250g | 28.42 | 2026-02-22 |
3. Repeat it at every update date¶
The same steps go into one function. For a decision period it keeps only the demand known at that date, refits the forecast, takes the cumulative upper bound, and fits a snapshot. It also returns the forecast's daily means added up, which we only use for the figure, and the handoff row.
def fit_snapshot(decision_period):
"""Refit the forecast on the demand known at one date and fit a policy snapshot."""
forecast_origin = opening_date + pd.Timedelta(days=decision_period)
newly_observed = simulation_demand.loc[
simulation_demand["date"].le(forecast_origin),
["unique_id", "date", "y"],
]
available_history = pd.concat([history, newly_observed], ignore_index=True)
assert available_history["date"].max() == forecast_origin
model = ES(model="ANN")
model.fit(available_history["y"])
daily_forecast = model.predict(
h=protection_horizon,
interval="prediction",
level=service_level,
side="upper",
cumulative=False,
)
cumulative_forecast = model.predict(
h=protection_horizon,
interval="prediction",
level=service_level,
side="upper",
cumulative=True,
)
forecast_dates = pd.date_range(
forecast_origin + pd.Timedelta(days=1),
periods=protection_horizon,
freq=period_frequency,
)
protection_target = float(cumulative_forecast.upper.iloc[-1, 0])
target_end_date = forecast_dates[-1]
snapshot = OrderUpToPolicy(
lead_time=lead_time,
review_period=review_period,
freq=period_frequency,
service_level=service_level,
allow_backorders=False,
date_column="target_end_date",
)
snapshot.fit(
pd.DataFrame(
{
"unique_id": [sku],
"protection_target": [protection_target],
"target_end_date": [target_end_date],
}
),
target_column="protection_target",
forecast_origin=forecast_origin,
)
target_row = {
"decision_period": decision_period,
"forecast_origin": forecast_origin,
"rows_used": len(available_history),
"protection_target": protection_target,
"target_probability": service_level,
"protection_horizon": protection_horizon,
"target_end_date": target_end_date,
}
# Adding daily means is valid for an expectation; it only draws the figure.
mean_path = pd.DataFrame(
{
"decision_period": decision_period,
"date": forecast_dates,
"cumulative_mean": daily_forecast.mean.to_numpy().cumsum(),
}
)
return snapshot, target_row, mean_path
policy_snapshots = {}
target_rows = []
mean_paths = []
for decision_period in forecast_update_periods:
snapshot, target_row, mean_path = fit_snapshot(decision_period)
policy_snapshots[decision_period] = snapshot
target_rows.append(target_row)
mean_paths.append(mean_path)
targets = pd.DataFrame(target_rows)
# Each fit used the ten original rows plus the demand revealed up to its origin.
assert targets["rows_used"].tolist() == [10, 14, 18]
targets
| decision_period | forecast_origin | rows_used | protection_target | target_probability | protection_horizon | target_end_date | |
|---|---|---|---|---|---|---|---|
| 0 | 0 | 2026-02-14 | 10 | 27.78 | 0.95 | 4 | 2026-02-18 |
| 1 | 4 | 2026-02-18 | 14 | 28.42 | 0.95 | 4 | 2026-02-22 |
| 2 | 8 | 2026-02-22 | 18 | 29.36 | 0.95 | 4 | 2026-02-26 |
rows_used grows from 10 to 14 to 18 as demand is revealed, and target_end_date always sits four days after the forecast origin. The target changes with the information available. Notebook 04c shows the other columns of the handoff row, such as the probability and the source of the target. In the left panel below, each diamond is the direct cumulative upper bound of its origin, drawn above the line of cumulative daily means.
forecast_paths = pd.concat(mean_paths, ignore_index=True)
COLORS = ["#4C78A8", "#F58518", "#54A24B"]
fig, axes = plt.subplots(1, 2, figsize=(13, 4.6), layout="constrained")
# Left: what each successive information set predicted for its next four days.
for color, row in zip(COLORS, targets.itertuples(index=False)):
path = forecast_paths.loc[forecast_paths["decision_period"].eq(row.decision_period)]
axes[0].plot(
path["date"],
path["cumulative_mean"],
marker="o",
color=color,
label=f"{row.forecast_origin:%b %d} cumulative mean",
)
# The diamond is the direct cumulative upper bound, not the final mean.
axes[0].scatter(
row.target_end_date,
row.protection_target,
marker="D",
s=60,
color=color,
zorder=3,
)
axes[0].set(
title="Each origin produces a new four-day forecast",
xlabel="Protection-horizon date",
ylabel="Cumulative demand (units)",
)
format_dates(axes[0], list(forecast_paths["date"])[::2])
# Right: the only policy input that changes between snapshots.
axes[1].step(
targets["forecast_origin"],
targets["protection_target"],
where="post",
color=UPDATE_COLOR,
linewidth=2,
)
axes[1].scatter(
targets["forecast_origin"],
targets["protection_target"],
color=UPDATE_COLOR,
zorder=3,
clip_on=False,
)
for row in targets.itertuples(index=False):
axes[1].annotate(
f"{row.protection_target:.1f}",
(row.forecast_origin, row.protection_target),
xytext=(0, 8),
textcoords="offset points",
ha="center",
fontsize=8,
)
axes[1].set(
title="The cumulative target changes as history grows",
xlabel="Forecast origin",
ylabel="Target S (units)",
)
axes[1].set_ylim(0, targets["protection_target"].max() * 1.15)
format_dates(axes[1], list(targets["forecast_origin"]))
for ax in axes:
ax.grid(axis="y", alpha=0.2)
ax.spines[["top", "right"]].set_visible(False)
axes[0].set_ylim(bottom=0)
# The diamonds mark the upper bound, so name them once for the left panel.
axes[0].legend(frameon=False, fontsize=8, loc="lower right")
plt.show()
4. Build the policy schedule¶
All three snapshots are the same (R, S) policy: same lead time, review period, service level, and lost-sales mode. This matters because policy_schedule updates the target; it does not switch policy behaviour during a run, and the engine rejects snapshots whose settings differ from the base policy.
The snapshot for period 0 is the base policy and covers the first decision. The schedule holds only the replacements, keyed by zero-based demand period. Period 4 is the fifth demand day, Feb 19, and its snapshot was fitted on demand through Feb 18. It stays active through period 6, and the period-8 snapshot takes over from Feb 23.
base_policy = policy_snapshots[0]
# Only replacement snapshots belong in policy_schedule.
policy_schedule = {
period: policy_snapshots[period] for period in forecast_update_periods[1:]
}
schedule_table = targets[
["decision_period", "forecast_origin", "protection_target"]
].copy()
schedule_table.insert(
1,
"engine_role",
["base policy", "scheduled update", "scheduled update"],
)
schedule_table["first_demand_date"] = schedule_table["forecast_origin"] + pd.Timedelta(
days=1
)
schedule_table
| decision_period | engine_role | forecast_origin | protection_target | first_demand_date | |
|---|---|---|---|---|---|
| 0 | 0 | base policy | 2026-02-14 | 27.78 | 2026-02-15 |
| 1 | 4 | scheduled update | 2026-02-18 | 28.42 | 2026-02-19 |
| 2 | 8 | scheduled update | 2026-02-22 | 29.36 | 2026-02-23 |
5. Run the engine with the scheduled snapshots¶
Opening stock is an independent scenario input; it is not estimated from the history or the forecast. We start with twelve units on hand, no backorders, and an empty pipeline. SimulationEngine from stockcast.core receives the demand, the opening inventory, the base policy, and the two scheduled snapshots. It never receives ES or a model, and it never refits anything. All twelve demand days are scored.
# Opening stock is a declared physical-state input, independent of the forecast.
opening_stock = pd.DataFrame({"unique_id": [sku], "observed_on_hand": [12.0]})
opening_stock
| unique_id | observed_on_hand | |
|---|---|---|
| 0 | tea_250g | 12.00 |
from stockcast.core import InventoryStateDataFrame
# max_lead_time sizes the pipeline that will hold accepted two-day orders.
empty_state = InventoryStateDataFrame(
[sku],
max_lead_time=lead_time,
allow_backorders=False,
)
opening_inventory = empty_state.initialize_from_observed(
opening_stock,
on_hand_column="observed_on_hand",
opening_date=opening_date,
)
opening_inventory
InventoryStateDataFrame(n_skus=1, total_on_hand=12, total_backorders=0, has_stockout=False, has_backorder=False)
from stockcast.core import SimulationEngine
result = SimulationEngine().run(
# The base snapshot supplies the opening target and remains active until period 4.
policy=base_policy,
# Scheduled snapshots replace only the fitted target at periods 4 and 8.
policy_schedule=policy_schedule,
# The engine validates and materializes this complete one-SKU daily calendar.
demand_source=simulation_demand,
# The engine deep-copies this state; the caller's opening object is preserved.
inventory=opening_inventory,
)
6. Read the result¶
The event table is Stockcast's record of the run, with one row per day. Its target_level column shows the target S that drove each decision, including reviews where no new snapshot arrived. order_quantity is accepted into the two-day pipeline when it is placed, and received_units shows when that quantity becomes physical stock. The table counts days from 1, while the schedule counts demand periods from 0, so we add demand_period beside date to match them.
events = result.to_event_frame(window="all")
period_events = events.loc[events["event_type"].eq("period")].copy()
period_events["demand_period"] = period_events["period"] - 1
event_columns = [
"demand_period",
"date",
"is_review_period",
"demand",
"target_level",
"order_quantity",
"received_units",
"ending_on_hand",
"on_order_end",
"shortage_units",
]
period_events[event_columns].reset_index(drop=True)
| demand_period | date | is_review_period | demand | target_level | order_quantity | received_units | ending_on_hand | on_order_end | shortage_units | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 2026-02-15 | True | 7.00 | 27.78 | 15.78 | 0.00 | 5.00 | 15.78 | 0.00 |
| 1 | 1 | 2026-02-16 | False | 5.00 | 27.78 | 0.00 | 0.00 | 0.00 | 15.78 | 0.00 |
| 2 | 2 | 2026-02-17 | True | 8.00 | 27.78 | 12.00 | 15.78 | 7.78 | 12.00 | 0.00 |
| 3 | 3 | 2026-02-18 | False | 6.00 | 27.78 | 0.00 | 0.00 | 1.78 | 12.00 | 0.00 |
| 4 | 4 | 2026-02-19 | True | 4.00 | 28.42 | 14.64 | 12.00 | 9.78 | 14.64 | 0.00 |
| 5 | 5 | 2026-02-20 | False | 7.00 | 28.42 | 0.00 | 0.00 | 2.78 | 14.64 | 0.00 |
| 6 | 6 | 2026-02-21 | True | 6.00 | 28.42 | 11.00 | 14.64 | 11.42 | 11.00 | 0.00 |
| 7 | 7 | 2026-02-22 | False | 9.00 | 28.42 | 0.00 | 0.00 | 2.42 | 11.00 | 0.00 |
| 8 | 8 | 2026-02-23 | True | 5.00 | 29.36 | 15.94 | 11.00 | 8.42 | 15.94 | 0.00 |
| 9 | 9 | 2026-02-24 | False | 6.00 | 29.36 | 0.00 | 0.00 | 2.42 | 15.94 | 0.00 |
| 10 | 10 | 2026-02-25 | True | 7.00 | 29.36 | 11.00 | 15.94 | 11.36 | 11.00 | 0.00 |
| 11 | 11 | 2026-02-26 | False | 5.00 | 29.36 | 0.00 | 0.00 | 6.36 | 11.00 | 0.00 |
Read the targets by date. Feb 15 and Feb 17 order up to 27.78, the opening target. The period-4 snapshot takes over on Feb 19, so Feb 19 and Feb 21 order up to 28.42, and Feb 23 and Feb 25 order up to 29.36. Orders are placed only on review days, every second day, and each arrives two days later: the 15.78 units ordered on Feb 15 are received on Feb 17. No day ran short.
The run manifest also records which updates the engine applied, and the forecast origin and target end date each snapshot carried. It confirms that updates were selected at periods 4 and 8, with origins Feb 18 and Feb 22, the day before each snapshot's first demand day.
run_settings = result.run_manifest["run_settings"]
assert run_settings["policy_update_periods"] == forecast_update_periods[1:]
manifest_updates = pd.json_normalize(run_settings["policy_update_log"]).rename(
columns={
"target_metadata.forecast_origin": "forecast_origin",
"target_metadata.target_end_date": "target_end_date",
}
)
manifest_updates[["decision_period", "forecast_origin", "target_end_date"]]
| decision_period | forecast_origin | target_end_date | |
|---|---|---|---|
| 0 | 4 | 2026-02-18T00:00:00 | 2026-02-22T00:00:00 |
| 1 | 8 | 2026-02-22T00:00:00 | 2026-02-26T00:00:00 |
stockcast.evaluation scores the run from the event table. Fill rate is the share of demand served from stock on the day it occurred, and demand-period service level is the share of days with demand and no shortage. The other three are the average ending stock, the total units ordered, and the number of days with an order. Every day was served, so fill rate and service level are both 1.00. The shop held 5.79 units on average and ordered 80.36 units in 6 orders.
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 day 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 | 1.00 | 1.00 | 5.79 | 80.36 | 6 |
Follow the updated targets through operations¶
The figure reads from the same event table. The middle panel shows each snapshot's target as a step with the orders placed and the receipts two days later, and the bottom panel keeps physical stock apart from the stock still in the pipeline. These twelve artificial days show mechanics only: the information cutoff belongs to the outer workflow, which must enforce it before a snapshot reaches the engine.
ORDER_COLOR = "#F58518"
STOCK_COLOR = "#2C7FB8"
PIPELINE_COLOR = "#7F7F7F"
dates = period_events["date"]
fig, axes = plt.subplots(3, 1, figsize=(11, 8), sharex=True, layout="constrained")
axes[0].bar(dates, period_events["demand"], color=HISTORY_COLOR)
axes[0].set(title="Demand revealed to the inventory system", ylabel="Units")
axes[1].step(
dates,
period_events["target_level"],
where="mid",
color=UPDATE_COLOR,
linewidth=2,
label="Active protection target S",
)
axes[1].step(
dates,
period_events["order_quantity"],
where="mid",
color=ORDER_COLOR,
linewidth=2,
label="Orders placed",
)
axes[1].step(
dates,
period_events["received_units"],
where="mid",
color=DEMAND_COLOR,
linewidth=2,
linestyle="--",
label="Receipts",
)
axes[1].set(title="Rolling targets drive review-day orders", ylabel="Units")
axes[2].step(
dates,
period_events["ending_on_hand"],
where="mid",
color=STOCK_COLOR,
linewidth=2,
label="End-of-day on hand",
)
axes[2].step(
dates,
period_events["on_order_end"],
where="mid",
color=PIPELINE_COLOR,
linewidth=2,
label="End-of-day pipeline",
)
axes[2].set(
title="Physical stock remains separate from pipeline stock",
xlabel="Date",
ylabel="Units",
)
# Keep every legend outside the data area so no order or state is hidden.
axes[1].legend(frameon=False, loc="upper left", bbox_to_anchor=(1.01, 1.0))
axes[2].legend(frameon=False, loc="upper left", bbox_to_anchor=(1.01, 1.0))
for ax in axes:
style_axis(ax)
format_dates(axes[2], list(dates[::2]))
plt.show()