R project
Weekly retail sales: from ADF test to a defensible forecast
The Applied Time Series phase describes the workflow; this page hands you the data and the code to run it. The dataset is a synthetic Nordic retailer with a known structure — trend, December peak, an AR(1) demand shock, two price step-ups, promo pulses with a one-week carry-over and a GARCH market return — so you can check whether your models find what is actually there.
1. The data — 156 weeks, 2022-01-03 to 2024-12-23
Weekly sales, kSEK. Grey dashes: year boundaries. Accent dashes: the two own-price step changes (weeks 60 and 118) — your structural-break candidates.
| Column | Meaning | Unit |
|---|---|---|
| week_start | Monday of the trading week | ISO date |
| sales_ksek | Weekly net sales of the retailer | kSEK |
| price_index | Own shelf-price index (Jan 2022 = 100), two step increases | index |
| promo_ksek | Promotional spend booked in the week | kSEK |
| competitor_index | Main competitor's price index (Jan 2022 = 100) | index |
| omx_return_pct | Weekly return on the OMX Stockholm index | % |
| fuel_sek_l | Average pump price for diesel that week | SEK per litre |
Preview the first 12 weeks
| week | week_start | sales_ksek | price_index | promo_ksek | competitor_index | omx_return_pct | fuel_sek_l |
|---|---|---|---|---|---|---|---|
| 1 | 2022-01-03 | 832 | 101.4 | 40 | 99.9 | -0.55 | 17.72 |
| 2 | 2022-01-10 | 846.6 | 99.7 | 61 | 100.7 | 0.22 | 17.86 |
| 3 | 2022-01-17 | 843.7 | 99.8 | 36 | 100.4 | -3.7 | 18.1 |
| 4 | 2022-01-24 | 844.9 | 101.2 | 28 | 101 | 1.73 | 18.63 |
| 5 | 2022-01-31 | 853.4 | 99.3 | 183 | 101.2 | 2.95 | 19.25 |
| 6 | 2022-02-07 | 846.1 | 100.8 | 32 | 100.6 | 2.39 | 19.35 |
| 7 | 2022-02-14 | 842.4 | 100.9 | 42 | 101.2 | 0.4 | 19.76 |
| 8 | 2022-02-21 | 841.7 | 101 | 29 | 101.1 | -0.95 | 20.17 |
| 9 | 2022-02-28 | 831.9 | 100.2 | 57 | 100.8 | 2.9 | 20.06 |
| 10 | 2022-03-07 | 832.5 | 99.9 | 37 | 100.6 | 0.47 | 20.11 |
| 11 | 2022-03-14 | 841.4 | 100.7 | 163 | 102 | 0.28 | 20.65 |
| 12 | 2022-03-21 | 834.2 | 100.8 | 42 | 102.4 | 0.66 | 20.79 |
2. The R workflow
Install once: install.packages(c("tidyverse","tsibble","feasts","fable","tseries","forecast","dynlm","vars","urca","rugarch","FinTS","lmtest","sandwich"))
1. Load and inspect
Get the data into a time-aware object and look at it before touching a model.
library(tidyverse); library(tsibble); library(feasts); library(fable) library(tseries); library(forecast); library(dynlm); library(vars); library(rugarch) d <- read_csv("weekly_retail.csv") |> mutate(week_start = as.Date(week_start)) |> as_tsibble(index = week_start) d |> autoplot(sales_ksek) + labs(title = "Weekly sales, kSEK") d |> gg_season(sales_ksek, period = "year") d |> ACF(sales_ksek, lag_max = 60) |> autoplot()How to read it: Trend up, a December peak, a summer dip and a slowly decaying ACF: a non-stationary, seasonal series. Nothing is estimated yet; this decides everything that follows.
2. Stationarity tests and transformation
Decide whether to model levels, differences or seasonal differences — with statistics, not eyeballs.
y <- ts(d$sales_ksek, frequency = 52, start = c(2022, 1)) adf.test(y) # H0: unit root kpss.test(y, null = "Level") # H0: stationary ndiffs(y); nsdiffs(y) dy <- diff(y) adf.test(dy); kpss.test(dy) ggtsdisplay(dy, main = "First difference")How to read it: Report the statistic, the critical value and the decision. If ADF fails to reject and KPSS rejects, you have a unit root: difference once. Expect ndiffs = 1 here and a seasonal signal at lag 52.
3. Univariate baseline: seasonal ARIMA
A benchmark that uses only the series' own past.
fit_arima <- auto.arima(y, seasonal = TRUE, stepwise = FALSE, approximation = FALSE) summary(fit_arima) checkresiduals(fit_arima) # Ljung–Box on residuals fc_arima <- forecast(fit_arima, h = 12) autoplot(fc_arima)How to read it: Write down the chosen order, the AICc, and whether Ljung–Box rejects. White residuals are the pass mark; a significant Ljung–Box means the model is missing dynamics.
4. Drivers: ADL regression on stationary variables
Explain sales with relative price, promotion and fuel — with lags, because promotion works for more than one week.
dd <- d |> mutate(rel_price = price_index - competitor_index, d_sales = difference(sales_ksek), d_rel = difference(rel_price), d_fuel = difference(fuel_sek_l)) z <- ts(as.matrix(dd[-1, c("d_sales","d_rel","promo_ksek","d_fuel")]), frequency = 52) adl <- dynlm(d_sales ~ L(d_sales, 1) + d_rel + promo_ksek + L(promo_ksek, 1) + d_fuel, data = z) summary(adl) lmtest::bgtest(adl, order = 4) # residual autocorrelation lmtest::coeftest(adl, vcov = sandwich::NeweyWest(adl))How to read it: Signs first: relative price negative, promo positive with a smaller positive lag, fuel negative. Long-run promo effect = (β₀ + β₁)/(1 − φ). Use Newey–West errors if BG rejects.
5. System view: VAR, Granger and impulse responses
Let sales, relative price and promo spend feed back on each other.
v <- z[, c("d_sales","d_rel","promo_ksek")] VARselect(v, lag.max = 8, type = "const")$selection var2 <- VAR(v, p = 2, type = "const") serial.test(var2, lags.pt = 12) causality(var2, cause = "promo_ksek")$Granger plot(irf(var2, impulse = "promo_ksek", response = "d_sales", n.ahead = 8, boot = TRUE))How to read it: An IRF that peaks at week 1–2 and dies within 6 weeks says promotion pulls demand forward rather than growing it. Say which ordering you used and why.
6. Cointegration check (levels)
Test whether own price and competitor price share a long-run relationship instead of just differencing them away.
lv <- ts(cbind(price = d$price_index, comp = d$competitor_index), frequency = 52) jo <- urca::ca.jo(lv, type = "trace", ecdet = "const", K = 2) summary(jo) # if rank = 1: error-correction model ect <- residuals(lm(price ~ comp, data = as.data.frame(lv))) ecm <- dynlm(d(lv[, "price"]) ~ L(ect, 1) + L(d(lv[, "price"]), 1) + d(lv[, "comp"])) summary(ecm)How to read it: A trace statistic above the 5% critical value at r = 0 and below at r ≤ 1 means one cointegrating vector. The ECT coefficient must be negative; its size is the weekly speed of adjustment.
7. Volatility: GARCH on the market return
Model the OMX return's variance for the risk section of the synopsis.
r <- d$omx_return_pct / 100 FinTS::ArchTest(r, lags = 5) spec <- ugarchspec(variance.model = list(model = "sGARCH", garchOrder = c(1,1)), mean.model = list(armaOrder = c(0,0))) g <- ugarchfit(spec, r) coef(g); persistence(g) sqrt(uncvariance(g)) * sqrt(52) # annualised long-run volHow to read it: α + β near 0.95 is typical; the long-run variance is ω/(1 − α − β). Annualise weekly with √52. Compare the conditional vol path against the sales shocks in step 4.
8. Out-of-sample horse race
Pick the model on rolling-origin forecast error, not in-sample fit.
f_naive <- function(x, h) snaive(x, h = h) f_arima <- function(x, h) forecast(auto.arima(x, seasonal = TRUE), h = h) e_naive <- tsCV(y, f_naive, h = 4, initial = 104) e_arima <- tsCV(y, f_arima, h = 4, initial = 104) rmse <- function(e) sqrt(mean(e^2, na.rm = TRUE)) c(naive = rmse(e_naive[, 4]), arima = rmse(e_arima[, 4]))How to read it: If ARIMA does not beat the seasonal naive at the horizon the decision needs, say so. Beating the random walk is the bar; a table of RMSE and MAE by horizon is the deliverable.
3. Synopsis template
Roughly 1,500 words plus tables. Each heading lists what an examiner — or a client partner — expects to find there.
1. Research question and motivation
150–200 words- One question a number can answer, e.g. 'Does promotional spend raise weekly sales beyond a one-week pull-forward, and by how much per kSEK?'
- Which decision changes with the answer (next year's promo budget, the price gap to the competitor).
- What your approach adds over the naive comparison the business already does.
2. Data
200–250 words- Source, frequency (weekly, 156 observations, 2022-01-03 to 2024-12-23), units, transformations.
- Plot the series. Report ADF and KPSS as hypotheses with statistic, critical value, decision.
- Name the two price step changes as candidate structural breaks and how you treat them.
3. Methods
250–300 words- At least two model families: seasonal ARIMA (univariate baseline) and ADL or VAR (drivers).
- Why each: what economic mechanism it captures and what diagnostic it must pass.
- State the selection rule in advance: rolling-origin RMSE at the 4-week horizon against the seasonal naive.
4. Results
400–500 words- Coefficient table with standard errors; signs and magnitudes against theory.
- Short-run vs long-run promo effect; price elasticity from the relative-price coefficient.
- Diagnostics: Ljung–Box, Breusch–Godfrey, ARCH test. What you re-specified and why.
- Impulse response of sales to a promo shock; Granger tests; cointegration rank if tested.
5. Forecast evaluation
150–200 words- RMSE/MAE by horizon for each model and the naive benchmark.
- Interval width at the horizon the decision needs — and whether that width is useful.
6. Conclusion and limitations
150–200 words- Answer the question with the winning model, in business units.
- Limitations you would fix with more data: weather, store openings, online share, competitor promo data.
- One sentence on what the decision-maker should do differently on Monday.
Before you submit
- ✓Stationarity decided by tests, reported with critical values.
- ✓Every transformation justified; levels vs differences chosen for a reason.
- ✓At least two model families with a pre-stated selection rule.
- ✓Residual diagnostics reported and acted on.
- ✓Forecast evaluated out of sample against a naive benchmark.
- ✓Economic interpretation of every retained coefficient.
- ✓Structural breaks acknowledged; look-ahead bias avoided.
- ✓Limitations honest and specific.