A daily newsvendor: choose the quantity from economics¶
A kiosk buys sandwiches once before a selling day. Unsold sandwiches have an end-of-day salvage value; unmet demand is lost. How many should it buy today?
This notebook uses artificial daily demand history as a simple forecast. Unit economics picks the quantile of that forecast, SingleOrderPolicy makes one purchase before demand, and the engine's event table explains what happened on a held-out day.
1. Forecast one selling day¶
The 60 historical observations are uncensored demand, not sales capped by stock. The declared artificial generator is stationary Poisson daily demand with mean 40; the forecaster is not given that mean. It uses an empirical predictive distribution that gives each historical day equal weight. This is a transparent baseline for comparable days, not a claim that real kiosk demand has no weekday or seasonal effects.
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
# Show floats with two decimals in every table below.
pd.options.display.float_format = "{:.2f}".format
seed = 42
rng = np.random.default_rng(seed)
series = pd.DataFrame(
{
"date": pd.date_range("2026-03-01", periods=61, freq="D"),
"demand": rng.poisson(lam=40, size=61),
}
)
history = series.iloc[:-1].copy()
held_out = series.iloc[-1:].copy()
origin = history.date.iloc[-1]
selling_date = held_out.date.iloc[0]
forecast_samples = history.demand.to_numpy(copy=True)
assert origin + pd.Timedelta(days=1) == selling_date
print("History ends:", origin.date(), "| selling day:", selling_date.date())
# The last five observed days.
history.tail()
History ends: 2026-04-29 | selling day: 2026-04-30
| date | demand | |
|---|---|---|
| 55 | 2026-04-25 | 42 |
| 56 | 2026-04-26 | 56 |
| 57 | 2026-04-27 | 46 |
| 58 | 2026-04-28 | 40 |
| 59 | 2026-04-29 | 52 |
FORECAST_COLOR = "#4C78A8"
TARGET_COLOR = "#F58518"
SOLD_COLOR = "#54A24B"
LEFTOVER_COLOR = "#B79A20"
LOST_COLOR = "#E45756"
fig, ax = plt.subplots(figsize=(10, 3.2), layout="constrained")
ax.plot(
history.date,
history.demand,
marker=".",
color=FORECAST_COLOR,
label="Observed daily demand",
)
ax.axvline(origin, color="0.45", linestyle=":", label="Forecast origin")
ax.set(
title="Historical daily demand available before the purchase",
ylabel="Sandwiches",
xlabel="Date",
)
ax.set_ylim(bottom=0)
ax.legend(frameon=False, fontsize=8)
ax.grid(axis="y", alpha=0.2)
plt.show()
2. Let price, cost, and salvage select the quantile¶
A sandwich sells for 10, costs 4 to buy, and can be salvaged for 2 if unsold. There is no opening stock, backlog, capacity limit, additional holding or shortage cost, or second purchase today.
Being wrong costs different amounts in the two directions. One sandwich too few loses the profit we would have made: 10 − 4 = 6, the underage cost. One sandwich too many loses what the leftover cost net of its salvage value: 4 − 2 = 2, the overage cost. The balance of the two gives the critical probability 6 / (6 + 2) = 0.75: we want enough sandwiches to cover demand with probability 0.75. The costlier it is to run short compared with having a leftover, the higher that probability.
newsvendor_critical_fractile from stockcast.policies computes this ratio. For whole sandwiches we then choose the smallest historical-demand value whose empirical CDF reaches 0.75. We also calculate expected profit over candidate quantities using the forecast samples only, before revealing the held-out day.
from stockcast.policies import newsvendor_critical_fractile
price = 10.0
cost = 4.0
salvage = 2.0
critical_probability = newsvendor_critical_fractile(
selling_price=price,
purchase_cost=cost,
salvage_value=salvage,
)
purchase_quantity = int(
np.quantile(forecast_samples, critical_probability, method="inverted_cdf")
)
pd.DataFrame(
{
"critical_probability": [critical_probability],
"empirical_forecast_mean": [forecast_samples.mean()],
"purchase_target": [purchase_quantity],
}
)
| critical_probability | empirical_forecast_mean | purchase_target | |
|---|---|---|---|
| 0 | 0.75 | 40.20 | 44 |
# Profit for every candidate quantity on every historical day: we sell what
# demand allows, pay for everything we buy, and salvage the leftovers.
candidates = np.arange(int(forecast_samples.max()) + 11)
scenario_profit = (
price * np.minimum(candidates[:, None], forecast_samples)
- cost * candidates[:, None]
+ salvage * np.maximum(candidates[:, None] - forecast_samples, 0)
)
expected_profit = scenario_profit.mean(axis=1)
assert np.isclose(expected_profit[purchase_quantity], expected_profit.max())
pd.DataFrame(
{
"purchase_target": [purchase_quantity],
"forecast_expected_profit": [expected_profit[purchase_quantity]],
}
)
| purchase_target | forecast_expected_profit | |
|---|---|---|
| 0 | 44 | 226.80 |
fig, axes = plt.subplots(1, 2, figsize=(11, 3.7), layout="constrained")
values, counts = np.unique(forecast_samples, return_counts=True)
axes[0].step(
values,
counts.cumsum() / len(forecast_samples),
where="post",
color=FORECAST_COLOR,
label="Empirical forecast CDF",
)
axes[0].axhline(
critical_probability,
color="0.45",
linestyle=":",
label=f"Critical probability {critical_probability:.2f}",
)
axes[0].axvline(
purchase_quantity,
color=TARGET_COLOR,
linestyle="--",
label=f"Target {purchase_quantity}",
)
axes[0].set(
title="The economics selects a forecast quantile",
xlabel="Daily demand",
ylabel="Cumulative probability",
)
axes[0].legend(frameon=False, fontsize=8)
axes[1].plot(
candidates,
expected_profit,
color=FORECAST_COLOR,
label="Forecast expected profit",
)
axes[1].scatter(
[purchase_quantity],
[expected_profit[purchase_quantity]],
color=TARGET_COLOR,
zorder=3,
label=f"Target {purchase_quantity}",
)
axes[1].set(
title="Check the quantity against expected profit",
xlabel="Purchase quantity",
ylabel="Expected profit",
)
axes[1].legend(frameon=False, fontsize=8)
for ax in axes:
ax.grid(axis="y", alpha=0.2)
plt.show()
3. Make the one-day purchase¶
The empirical forecaster has finished its work. We hand Stockcast the selected quantity together with the probability, the information origin, the daily frequency, the one-day end date, and the external source. SingleOrderPolicy from stockcast.policies has one decision opportunity; with zero lead time its accepted order arrives before demand on the selling day. No salvage transaction is performed by the engine.
sku = "sandwich"
# One row: the quantity to buy and the day it covers.
target = pd.DataFrame(
{
"unique_id": [sku],
"target": [purchase_quantity],
"target_end": [selling_date],
}
)
target
| unique_id | target | target_end | |
|---|---|---|---|
| 0 | sandwich | 44 | 2026-04-30 |
from stockcast.policies import SingleOrderPolicy
policy = SingleOrderPolicy(
lead_time=0,
freq="D",
selling_horizon=1,
service_level=critical_probability,
allow_backorders=False,
date_column="target_end",
)
policy.fit(
target,
target_column="target",
forecast_origin=origin,
)
# What Stockcast recorded about the target: its source, probability and timing.
pd.DataFrame([policy.get_target_metadata()])
| representation | target_probability | protection_horizon | target_source | forecast_origin | forecast_frequency | target_end_date | coverage_start_date | selling_horizon | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | single_season_target | 0.75 | 1 | external_direct | 2026-04-29T00:00:00 | D | 2026-04-30T00:00:00 | 2026-04-30T00:00:00 | 1 |
4. Reveal the held-out day and account for it¶
The held-out demand is used only now. InventoryStateDataFrame from stockcast.core holds the kiosk's stock, which starts at zero, and SimulationEngine from stockcast.core runs the one selling day. Stockcast records the purchase, immediate receipt, sales, lost sales, and leftover stock in the event table. We compute revenue and terminal salvage explicitly from that row; leftover stock is still present in the returned inventory state.
from stockcast.core import InventoryStateDataFrame, SimulationEngine
empty_state = InventoryStateDataFrame([sku], max_lead_time=0, allow_backorders=False)
opening = empty_state.initialize_zero(opening_date=origin)
actual_demand = int(held_out.demand.iloc[0])
demand = pd.DataFrame(
{
"unique_id": [sku],
"period": [0],
"date": [selling_date],
"y": [actual_demand],
}
)
result = SimulationEngine().run(
policy=policy,
demand_source=demand,
inventory=opening,
random_seed=seed,
)
event = result.to_event_frame().iloc[0]
realized_profit = (
price * event.fulfilled_units
- cost * event.order_quantity
+ salvage * event.ending_on_hand
)
assert event.order_quantity == event.received_units == purchase_quantity
assert event.fulfilled_units == min(purchase_quantity, actual_demand)
assert event.ending_on_hand == max(purchase_quantity - actual_demand, 0)
assert event.lost_sales_units == max(actual_demand - purchase_quantity, 0)
pd.DataFrame(
[
{
"target": purchase_quantity,
"demand": actual_demand,
"ordered": event.order_quantity,
"received": event.received_units,
"sold": event.fulfilled_units,
"lost_sales": event.lost_sales_units,
"leftover": event.ending_on_hand,
"revenue": price * event.fulfilled_units,
"purchase_cost": cost * event.order_quantity,
"salvage_value": salvage * event.ending_on_hand,
"realized_profit": realized_profit,
}
]
)
| target | demand | ordered | received | sold | lost_sales | leftover | revenue | purchase_cost | salvage_value | realized_profit | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 44 | 39 | 44.00 | 44.00 | 39.00 | 0.00 | 5.00 | 390.00 | 176.00 | 10.00 | 224.00 |
The kiosk bought 44, demand was 39, so 39 sold, 5 were left over, and nothing was lost. The expected-profit check from section 2 holds under the empirical forecast, and this event row verifies one realized day. Neither shows that the forecast is calibrated or that this quantity is best for a real kiosk.
fig, ax = plt.subplots(figsize=(8, 3.1), layout="constrained")
labels = ["Forecast target", "Observed demand", "Sold", "Leftover", "Lost sales"]
values = [
purchase_quantity,
actual_demand,
event.fulfilled_units,
event.ending_on_hand,
event.lost_sales_units,
]
bars = ax.bar(
labels,
values,
color=[FORECAST_COLOR, TARGET_COLOR, SOLD_COLOR, LEFTOVER_COLOR, LOST_COLOR],
)
ax.bar_label(bars, fmt="%.0f", padding=3)
ax.set(
title="One held-out day: the decision and its physical outcome",
ylabel="Sandwiches",
)
ax.set_ylim(0, max(values) * 1.18)
ax.grid(axis="y", alpha=0.2)
ax.set_axisbelow(True)
plt.show()