← Applied time series theory

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.

ColumnMeaningUnit
week_startMonday of the trading weekISO date
sales_ksekWeekly net sales of the retailerkSEK
price_indexOwn shelf-price index (Jan 2022 = 100), two step increasesindex
promo_ksekPromotional spend booked in the weekkSEK
competitor_indexMain competitor's price index (Jan 2022 = 100)index
omx_return_pctWeekly return on the OMX Stockholm index%
fuel_sek_lAverage pump price for diesel that weekSEK per litre
Preview the first 12 weeks
weekweek_startsales_ksekprice_indexpromo_ksekcompetitor_indexomx_return_pctfuel_sek_l
12022-01-03832101.44099.9-0.5517.72
22022-01-10846.699.761100.70.2217.86
32022-01-17843.799.836100.4-3.718.1
42022-01-24844.9101.2281011.7318.63
52022-01-31853.499.3183101.22.9519.25
62022-02-07846.1100.832100.62.3919.35
72022-02-14842.4100.942101.20.419.76
82022-02-21841.710129101.1-0.9520.17
92022-02-28831.9100.257100.82.920.06
102022-03-07832.599.937100.60.4720.11
112022-03-14841.4100.71631020.2820.65
122022-03-21834.2100.842102.40.6620.79

2. The R workflow

Install once: install.packages(c("tidyverse","tsibble","feasts","fable","tseries","forecast","dynlm","vars","urca","rugarch","FinTS","lmtest","sandwich"))

  1. 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. 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. 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. 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. 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. 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. 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 vol

    How 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. 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.