Write your own policy¶
Any ordering rule can become a Stockcast policy: a heuristic from your
business, a rule from a paper, a machine-learning model that predicts order
quantities. Subclass BasePolicy, implement two methods, and your rule runs in
the engine and in comparisons like the built-in ones.
The contract¶
class MyPolicy(BasePolicy):
def fit(self, forecast_df, **kwargs): # bind data, return self
...
self.fitted_ = True
return self
def predict(self, inventory_state_df, *, current_period, **kwargs):
... # read the state, never change it
return OrderDecision(orders, lead_time=self.lead_time)
| Piece | What Stockcast expects |
|---|---|
__init__ |
Call super().__init__(lead_time, review_period=..., service_level=..., allow_backorders=..., schedule=...). This sets up the timing, the schedule, and the shortage rule. |
fit(...) |
Store what the policy needs, set self.fitted_ = True, return self. The engine only runs fitted policies. |
predict(state, *, current_period) |
Receives a copy of the InventoryStateDataFrame before demand; its get_history() lists the completed periods of the run. Returns an OrderDecision. |
The OrderDecision |
One row per SKU with order_quantity \(\ge 0\). For positive orders, order_period = current_period and expected_delivery_period = current_period + lead_time. Its lead_time must equal the policy's. |
The engine decides when predict is called (the policy's schedule), and
applies callbacks, constraints, and suppliers afterwards. Your policy only
answers "how much, right now?"
Example: days of cover¶
A common retail rule: keep enough stock for a number of days of forecast demand.
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 BasePolicy, OrderDecision, SimulationEngine
class DaysOfCover(BasePolicy):
"""Order up to `days` times the forecast daily demand."""
def __init__(self, days, **kwargs):
super().__init__(**kwargs)
self.days = days
self.policy_name = f"Days of cover ({days})"
def fit(self, forecast_df, **kwargs):
self.daily_mean_ = forecast_df.set_index("unique_id")["daily_mean"]
self.fitted_ = True
return self
def predict(self, inventory_state_df, *, current_period, **kwargs):
state = inventory_state_df.inventory_position()
level = state["unique_id"].map(self.daily_mean_) * self.days
orders = pd.DataFrame({
"unique_id": state["unique_id"],
"order_quantity": (level - state["inventory_position"]).clip(lower=0),
"target_level": level,
"inventory_position": state["inventory_position"],
"order_period": current_period,
"expected_delivery_period": current_period + self.lead_time,
})
return OrderDecision(orders, lead_time=self.lead_time)
cover = DaysOfCover(
days=7, lead_time=lead_time, review_period=review_period, allow_backorders=False,
).fit(pd.DataFrame({"unique_id": [sku], "daily_mean": [6.0]}))
comparison = SimulationEngine().run_comparison(
policies=[cover, tea_policy(0.95)],
labels=["7 days of cover", "95% quantile target"],
demand_source=demand, inventory=inventory, **run_settings,
)
comparison.summary()[["fill_rate", "stockout_periods", "mean_ending_on_hand_per_sku_period"]]
fill_rate stockout_periods mean_ending_on_hand_per_sku_period
policy
7 days of cover 1.0 0 14.750000
95% quantile target 1.0 0 18.607143
On this demand path, seven days of cover (\(42\) packs) served every sale with about four fewer packs on the shelf than the 95% quantile target (\(46\)). A decision would need more demand paths, or costs. The comparison itself is fair: same demand, same opening stock, same engine.
Example: extend a built-in policy¶
You can also subclass a built-in policy and change one step. This variant of \((R, S)\) never orders more than a supplier's daily limit:
from stockcast.policies import OrderUpToPolicy
class CappedOrderUpTo(OrderUpToPolicy):
"""(R, S), but at most `cap` units per order."""
def __init__(self, cap, **kwargs):
super().__init__(**kwargs)
self.cap = cap
def predict(self, inventory_state_df, *, current_period, **kwargs):
decision = super().predict(inventory_state_df, current_period=current_period, **kwargs)
frame = decision.get_dataframe()
frame["order_quantity"] = frame["order_quantity"].clip(upper=self.cap)
return OrderDecision(frame, lead_time=decision.lead_time)
Policy logic or a constraint?
A limit that belongs to the decision rule fits in the policy. A limit that belongs to the operation, and should apply whatever the policy, is better as an ordering constraint: it works with every policy and is recorded separately in the event table.
Optional extras¶
| Method | Why you might add it |
|---|---|
get_target_metadata() |
Return a dict with forecast_origin and forecast_frequency so the engine checks your forecast dates like it does for built-in policies. |
validate_decision_window(period, information_date, offset) |
Check, before the run, that the policy's information covers each scheduled decision. Raise to stop the run. |
validate_demand_window(demand, n_periods) |
Check the demand table before the run (for example, a season's boundaries). |
policy_name |
A readable name used as the default label in comparisons and the event table. |
See also: Notebook 05: custom policies · Notebook 05c: extension points