A daily orders model has seven one-hot columns for the day of the week and a
boolean called is_holiday. It behaves well for months, then forecasts an
ordinary Thursday in April and the warehouse ships nothing. Easter moved. The
boolean fired on Good Friday and applied one flat correction, the same
correction it applies to Christmas Day. It fired on none of the four days
around it, which is where most of the error was.
Trend gets the attention, but on most business series the calendar does the work. Which day it is, whether the offices are shut, how many days until the shops close — those explain more of tomorrow than any smooth upward line. A calendar column is also cheap to add and hard to remove once someone has seen it in a feature-importance chart, so decide up front which ones are real.
Find the cycles before encoding them
Daily data has three candidate cycles: within the week, within the month, and within the year. Each one needs evidence before it gets columns. The cheapest check is the mean per level expressed as a multiplier of the overall mean, and computed per year so you can see whether the shape holds.
The series below is the daily event count from the public dataset — 724 days, August 2024 to July 2026.
import numpy as npimport pandas as pd
events = pd.read_csv("https://dataacademy.ai/data/events.csv", parse_dates=["occurred_at"])y = events.set_index("occurred_at").resample("D").size() # daily counts
shape = y.groupby([y.index.year, y.index.dayofweek]).mean().unstack(0)print(shape.div(shape.mean()).round(2)) # weekly shape, year by yearprint(f"weekday {y[y.index.dayofweek < 5].mean():.1f} " f"weekend {y[y.index.dayofweek >= 5].mean():.1f}")occurred_at 2024 2025 2026occurred_at0 1.09 1.14 1.091 1.26 1.17 1.212 1.24 1.14 1.163 1.16 1.24 1.264 1.16 1.27 1.175 0.47 0.53 0.546 0.62 0.50 0.56weekday 65.6 weekend 29.2Read across each row. If Saturday is 0.42, 0.44, 0.41 in three consecutive years, the weekly cycle is real and stable. If it is 0.42, 0.71, 0.55, either the behaviour changed or there is not enough data per level to say anything. Row 5 above is Saturday at 0.47, 0.53, 0.54, and row 6 is Sunday at 0.62, 0.50, 0.56: weekdays average 65.6 events a day and weekend days 29.2. Two of those three years are part years, so read the rows for agreement rather than for trend — and they agree. The cycle earns its columns.
The autocorrelation function is the second look, with one detail that saves confusion: yearly seasonality in business data is weekday-aligned, so the useful lag is 364, not 365.
print(f"lag 7 {y.autocorr(7):.3f}")print(f"lag 364 {y.autocorr(364):.3f}")print(f"lag 365 {y.autocorr(365):.3f}")lag 7 0.606lag 364 0.432lag 365 0.159One day of offset, and the correlation falls by a factor of nearly three. Lag 365 lands on the wrong weekday, so it compares Saturdays with Fridays and gets the fall for its trouble. Read that pair as a warning about alignment, not as evidence of a yearly cycle: with two years of history, most of what lag 364 picks up is the weekly shape repeating, and this series has no second full year to test an annual claim against.
One warning before encoding. A strong day-of-month pattern is almost never about the number on the calendar. It is payday, or a billing run. Encode the cause; the number is a proxy that breaks the moment the schedule changes. Check whether there is a pattern at all before looking for its cause:
dom = y.groupby(y.index.day).mean() / y.mean()print(f"{dom.min():.2f} {dom.max():.2f}")0.90 1.15Every day of the month sits within 15% of average, against a weekend day that runs 55% below a weekday. There is no payday here, so there is nothing to encode — which is the answer that saves you thirty columns.
Fourier terms when the cycle is long
Yearly seasonality invites 52 week-of-year dummies. With three years of daily data that is 52 parameters fitted on three observations each, so every coefficient is a three-point average and the fitted annual curve comes out jagged. Worse, the encoding throws away the one thing you know for certain about weeks 27 and 28: they resemble each other. Dummies treat them as unrelated categories.
Fourier terms
impose that smoothness instead of learning it. Each pair of sine
and cosine columns describes one harmonic of the annual cycle, so the whole
year costs 2k parameters.
def fourier(index, period, k): """k sine/cosine pairs for a cycle of `period` days.""" t = (index - index[0]).days.to_numpy().astype(float) cols = {} for i in range(1, k + 1): cols[f"sin{i}"] = np.sin(2 * np.pi * i * t / period) cols[f"cos{i}"] = np.cos(2 * np.pi * i * t / period) return pd.DataFrame(cols, index=index)
X = fourier(y.index, period=365.25, k=3)Two or three pairs give a broad summer-to-winter swing. Six to ten give a
shape with real structure in it. Pick k by rolling-origin backtest rather
than by eye, because a curve that hugs the training years is exactly what
an honest backtest is there to
catch.
The limitation is built into the method: Fourier terms are smooth, so they can never produce a one-day spike. Christmas is not seasonality. It is an event, and it needs the treatment further down.
With ten years of history, week dummies become defensible again. The choice is about observations per parameter, nothing else.
Day of week, and the money calendar
The weekly cycle is the one case where dummies are clearly right: seven levels, hundreds of observations each. What is usually wrong is the model wrapped around them.
Wrong assumption one is additivity. A dummy adds a constant, so it claims
Saturday is 300 orders below average. In most series Saturday is 40% below
average, and 40% of a growing series is not a constant. Model log(y), or use
a model that is multiplicative by construction. The per-year table above tells
you which: check whether the ratios hold steady or the differences do.
Wrong assumption two is that the weekly shape is fixed. December Saturdays in retail are not January Saturdays. Interact the day of week with the season if the evidence supports it, and only then, because the interaction multiplies the parameter count.
Wrong assumption three is that the day of week and the holiday flag are independent. They are not. A public holiday Monday behaves like a Sunday, and the dummy still insists it is a Monday, so the model applies a Monday correction and a holiday correction on top of each other. The clean fix is a single effective day type: normal weekdays, weekend days, and holidays as their own levels, one column instead of two overlapping ones.
The rest of the short-cycle signal is money.
cal = pd.DataFrame(index=y.index)cal["days_to_month_end"] = y.index.days_in_month - y.index.daycal["last_business_day"] = (y.index + pd.offsets.BMonthEnd(0) == y.index)cal["day_type"] = np.where(is_holiday, "hol", np.where(y.index.dayofweek >= 5, "wknd", "wd"))cal = pd.get_dummies(cal, columns=["day_type"], drop_first=True)Day of month has 31 levels and most of them carry no information. Days to month end, the last business day, and days since the last payday carry all of it in three columns.
Moving holidays and the days around them
Easter swings across five weeks, from 22 March to 25 April. Ramadan and Eid drift about eleven days earlier each Gregorian year and take roughly thirty-three years to come back round. Chinese New Year lands anywhere between 21 January and 20 February. No fixed-date column represents any of them, and a week-of-year dummy is worse than useless: the slot that contained the holiday last year contains an ordinary week this year, so the coefficient averages one extreme year with several plain ones.
Encode distance to the event, not the date of it.
def signed_offset(index, events): """Signed days from each date to the nearest event date.""" ev = np.sort(pd.DatetimeIndex(events).to_numpy()) d = (index.to_numpy()[:, None] - ev[None, :]) / np.timedelta64(1, "D") return pd.Series(d[np.arange(len(index)), np.abs(d).argmin(axis=1)], index=index)
off = signed_offset(y.index, easter_dates) # dates through the horizonfor k in range(-7, 8): cal[f"easter{k:+d}"] = (off == k).astype(int)Fifteen columns, one per position in the neighbourhood, and the model learns the shape of the whole disturbance. That shape is the point. Retail pulls forward two or three days before the shops shut, drops to nothing on the day, and gets returns for a week afterwards. Industrial orders die for the full week of Chinese New Year and come back with a backlog. The holiday itself is often the least interesting day in its own window.
The same treatment covers the awkward cases: holidays observed on the following Monday, bridge days between a Thursday holiday and the weekend, and school terms. Whatever calendar you use must extend past the horizon, because the feature has to exist for the dates you are predicting.
Make each block earn its place
Add the calendar block, refit, and score it at the same origins over the same horizon as the model without it. Then look at the paired difference per origin rather than the average alone.
base = backtest(X, y) # MAE per originplus = backtest(X.join(cal), y)diff = base - plus # positive means the block helpedprint(diff.mean(), diff.std(), (diff > 0).mean())A block that helps at three origins out of twelve and hurts at nine has not earned anything, however good the average looks. Judging it by in-sample fit instead is the same move as every other way a forecast flatters itself, and it fails for the same reason: more calendar columns always fit the past better.
The reason to take this care is that calendar features are the only ones you know in full for the whole horizon. There is no lag to respect, no nowcast, no upstream table that lands late. On the morning you forecast ninety days ahead, you already know every weekday, every payday and every public holiday in that window, with certainty available nowhere else in the feature set.
So write the claim down before adding the column. One line each, in the same file as the feature code: people are paid on the 25th, shops shut on Good Friday, nobody orders parts the week of Chinese New Year. It costs a minute per feature. When the block helps at three origins and hurts at nine, those lines tell you which claim to test first, and which one to delete.