9  Trading days

Similarly to irregular holidays, the weekly pattern differs from year to year. So the number of weekdays in a given month or quarter may be different from year to year. In the example below, January 2022 has 21 weekdays, while January 2023 has 22 weekdays. Some time series, such as manufacturing, may be affected by this pattern.

(a) January 2022

(b) January 2023

Figure 9.1: The number of weekdays per month differ between years

Similar to the holiday adjustments, X-13 offers various tools to deal with differences in the number of days or weekdays. This chapter discusses the automated trading day adjustments in X-13, and explores various ways of user customization. Note that the term trading day adjustment is X-13 slang for weekday adjustment and will be used interchangeably.

Trading day effects constitute a predictable movement in a time series, and therefore should not be part of the seasonally adjusted series. Trading day effects are usually removed by the regression spec, although some adjustments can also be done by the transform spec. Contrary to the regression spec, the transform spec performs a 1:1 adjustment, while the regression spec estimates the size of the effect from the data.

As with most adjustments, X-13 offers a built-in automated adjustment that works well in most circumstances. We start the chapter by looking at the automated procedures. Sometimes, you want to rely on a user-defined specification of trading days, a topic covered in Section 9.1. Finally, in Section 9.3, we link the two sections by replicating the built-in regressors in R.

9.1 Built-in trading day adjustment

In a default run of seas(), X-13 uses a familiar AICc test to decide between a number of potential trading day adjustments. From the various models, it uses the one with the lowest AICc as the best model. By default, the following models are evaluated:

  • td1coef: A single coefficient trading day adjustment

  • td: A six-day coefficient trading day adjustment

The first model distinguishes between weekdays and weekends, while the second model treats every day as its own. The first model is appropriate for many economic time series, where variables behave differently during the week than during the weekend. In some series, there may be large differences between weekdays, or even between Saturday and Sunday. Retail sales, for example, usually peak towards the end of the week and are weak on Sunday.

Using the built-in trading day adjustment in X-13 is straightforward:

m <- seas(AirPassengers)
summary(m)
#> 
#> Call:
#> seas(x = AirPassengers)
#> 
#> Coefficients:
#>                     Estimate Std. Error z value Pr(>|z|)    
#> Weekday           -0.0029497  0.0005232  -5.638 1.72e-08 ***
#> Easter[1]          0.0177674  0.0071580   2.482   0.0131 *  
#> AO1951.May         0.1001558  0.0204387   4.900 9.57e-07 ***
#> MA-Nonseasonal-01  0.1156204  0.0858588   1.347   0.1781    
#> MA-Seasonal-12     0.4973600  0.0774677   6.420 1.36e-10 ***
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> SEATS adj.  ARIMA: (0 1 1)(0 1 1)  Obs.: 144  Transform: log
#> AICc: 947.3, BIC: 963.9  QS (no seasonality in final):    0  
#> Box-Ljung (no autocorr.): 26.65   Shapiro (normality): 0.9908

The procedure has opted for a one coefficient model. During weekdays, the number of air passengers was lower during the period of the example series, by about 0.3% (note we are looking at a logarithmic, multiplicative model).

We can manipulate the automated model. To enforce a six coefficient model, regression.variables can be specified as "td" (as opposed to "td1coef"):

m_td <- seas(AirPassengers, regression.variables = "td")
summary(m_td)
#> 
#> Call:
#> seas(x = AirPassengers, regression.variables = "td")
#> 
#> Coefficients:
#>                 Estimate Std. Error z value Pr(>|z|)    
#> Mon            -0.001527   0.003458  -0.442   0.6588    
#> Tue            -0.007677   0.003607  -2.129   0.0333 *  
#> Wed            -0.001125   0.003465  -0.325   0.7453    
#> Thu            -0.005350   0.003425  -1.562   0.1183    
#> Fri             0.004676   0.003447   1.357   0.1749    
#> Sat             0.003025   0.003568   0.848   0.3965    
#> Easter[1]       0.017999   0.007246   2.484   0.0130 *  
#> AO1951.May      0.109256   0.019651   5.560 2.70e-08 ***
#> MA-Seasonal-12  0.500775   0.077252   6.482 9.03e-11 ***
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> SEATS adj.  ARIMA: (0 1 0)(0 1 1)  Obs.: 144  Transform: log
#> AICc: 949.5, BIC: 976.4  QS (no seasonality in final):    0  
#> Box-Ljung (no autocorr.): 28.05   Shapiro (normality): 0.9902

Note that the resulting AICc is higher (949.5) than for the one coefficient model (947.3). That is why the automated procedure has opted for the simpler model.

9.2 Case Study: Hobby Toy Game

m <- seas(hobby_toy_game)
summary(m)
#> 
#> Call:
#> seas(x = hobby_toy_game)
#> 
#> Coefficients:
#>                    Estimate Std. Error z value Pr(>|z|)    
#> Mon               -0.009625   0.003358  -2.866 0.004157 ** 
#> Tue                0.014245   0.003276   4.349 1.37e-05 ***
#> Wed               -0.009167   0.003289  -2.787 0.005326 ** 
#> Thu               -0.001631   0.003258  -0.501 0.616624    
#> Fri                0.010977   0.003332   3.295 0.000984 ***
#> Sat                0.011069   0.003316   3.338 0.000843 ***
#> Easter[8]          0.061462   0.006426   9.564  < 2e-16 ***
#> AO1996.May        -0.217402   0.025347  -8.577  < 2e-16 ***
#> AO1996.Dec         0.145309   0.025339   5.735 9.77e-09 ***
#> MA-Nonseasonal-01  0.377066   0.056405   6.685 2.31e-11 ***
#> MA-Nonseasonal-02  0.360585   0.056510   6.381 1.76e-10 ***
#> MA-Seasonal-12     0.326875   0.055259   5.915 3.31e-09 ***
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> SEATS adj.  ARIMA: (0 1 2)(0 1 1)  Obs.: 282  Transform: log
#> AICc:  2826, BIC:  2871  QS (no seasonality in final):    0  
#> Box-Ljung (no autocorr.): 27.25   Shapiro (normality): 0.9887 *

static(m)
#> seas(
#>   x = hobby_toy_game,
#>   regression.variables = c("td", "easter[8]", "ao1996.May", "ao1996.Dec"),
#>   arima.model = "(0 1 2)(0 1 1)",
#>   regression.aictest = NULL,
#>   outlier = NULL,
#>   transform.function = "log"
#> )

m0 <- seas(
  x = hobby_toy_game,
  regression.variables = c(
  "td",
  "easter[8]",
  "ao1996.May",
  "ao1996.Dec"
  ),
  arima.model = "(0 1 2)(0 1 1)",
  regression.aictest = NULL,
  outlier = NULL,
  transform.function = "log"
)
summary(m0)
#> 
#> Call:
#> seas(x = hobby_toy_game, transform.function = "log", regression.aictest = NULL, 
#>     outlier = NULL, regression.variables = c("td", "easter[8]", 
#>         "ao1996.May", "ao1996.Dec"), arima.model = "(0 1 2)(0 1 1)")
#> 
#> Coefficients:
#>                    Estimate Std. Error z value Pr(>|z|)    
#> Mon               -0.009625   0.003358  -2.866 0.004157 ** 
#> Tue                0.014245   0.003276   4.349 1.37e-05 ***
#> Wed               -0.009167   0.003290  -2.787 0.005327 ** 
#> Thu               -0.001631   0.003258  -0.501 0.616637    
#> Fri                0.010977   0.003332   3.295 0.000985 ***
#> Sat                0.011069   0.003316   3.338 0.000843 ***
#> Easter[8]          0.061462   0.006426   9.564  < 2e-16 ***
#> AO1996.May        -0.217403   0.025348  -8.577  < 2e-16 ***
#> AO1996.Dec         0.145309   0.025339   5.735 9.78e-09 ***
#> MA-Nonseasonal-01  0.377086   0.056405   6.685 2.30e-11 ***
#> MA-Nonseasonal-02  0.360597   0.056509   6.381 1.76e-10 ***
#> MA-Seasonal-12     0.326884   0.055258   5.916 3.31e-09 ***
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> SEATS adj.  ARIMA: (0 1 2)(0 1 1)  Obs.: 282  Transform: log
#> AICc:  2826, BIC:  2871  QS (no seasonality in final):    0  
#> Box-Ljung (no autocorr.): 27.25   Shapiro (normality): 0.9887 *

m1 <- seas(
  x = hobby_toy_game,
  regression.variables = c(
  "td1coef",
  "easter[8]",
  "ao1996.May",
  "ao1996.Dec"
  ),
  arima.model = "(0 1 2)(0 1 1)",
  regression.aictest = NULL,
  outlier = NULL,
  transform.function = "log"
)
summary(m1)
#> 
#> Call:
#> seas(x = hobby_toy_game, transform.function = "log", regression.aictest = NULL, 
#>     outlier = NULL, regression.variables = c("td1coef", "easter[8]", 
#>         "ao1996.May", "ao1996.Dec"), arima.model = "(0 1 2)(0 1 1)")
#> 
#> Coefficients:
#>                     Estimate Std. Error z value Pr(>|z|)    
#> Weekday            0.0005289  0.0005944   0.890    0.374    
#> Easter[8]          0.0542854  0.0074088   7.327 2.35e-13 ***
#> AO1996.May        -0.2300586  0.0290640  -7.916 2.46e-15 ***
#> AO1996.Dec         0.1231689  0.0289604   4.253 2.11e-05 ***
#> MA-Nonseasonal-01  0.4421089  0.0569371   7.765 8.17e-15 ***
#> MA-Nonseasonal-02  0.3495768  0.0569694   6.136 8.45e-10 ***
#> MA-Seasonal-12     0.3571851  0.0551869   6.472 9.65e-11 ***
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> SEATS adj.  ARIMA: (0 1 2)(0 1 1)  Obs.: 282  Transform: log
#> AICc:  2875, BIC:  2904  QS (no seasonality in final):    0  
#> Box-Ljung (no autocorr.): 40.88 * Shapiro (normality): 0.9943

library(tsbox)
ts_dygraphs(ts_c(
  final(m0),
  final(m1)
))

9.3 Case Study: Replicate X-13 trading days adjustment

‘Trading day adjustment’ removes the effect of the weekdays, but does not include holidays, such as Christmas or Easter. These are handled separately (Easter) (Chapter 8) or dealt with standard seasonal adjustment (Christmas).

Sometimes, users may want to specify user-defined trading day regressors, to incorporate country specific trading day patterns. In Section 9.3, we replicate the built-in regressors. These regressors simply use the number of weekdays, and do not pay any reference to a country specific calendar. If you want to deviate from them, a good way is to start with the replicated values, and adjust from there.

9.3.1 Constructing weekday regressors

To construct weekday regressors in R, we use the following code:

library(dplyr)

dates <- seq(as.Date("1931-01-01"), as.Date("2030-12-31"), by = "day")

first_of_month <- function(x) {
  as.Date(paste(
    data.table::year(dates),
    data.table::month(dates),
    1,
    sep = "-"
  ))
}

td_m_tbl <-
  tibble(dates, wd = as.POSIXlt(dates)$wday, name = weekdays(dates)) |>
  group_by(time = first_of_month(dates)) |>
  summarize(
    mon = sum(wd == 1) - sum(wd == 0),
    tue = sum(wd == 2) - sum(wd == 0),
    wed = sum(wd == 3) - sum(wd == 0),
    thu = sum(wd == 4) - sum(wd == 0),
    fri = sum(wd == 5) - sum(wd == 0),
    sat = sum(wd == 6) - sum(wd == 0),
    td1 = sum(wd %in% 1:5) - 5 / 2 * sum(wd %in% c(6, 0))
  )
time mon tue wed thu fri sat td1
1931-01-01 0 0 0 1 1 1 -0.5
1931-02-01 0 0 0 0 0 0 0.0
1931-03-01 0 0 -1 -1 -1 -1 -0.5
1931-04-01 0 0 1 1 0 0 2.0
1931-05-01 -1 -1 -1 -1 0 0 -4.0
1931-06-01 1 1 0 0 0 0 2.0

For each month, td_m_tbl computes the number of specific weekdays, and compares them to the number of Sundays (wd == -) (columns mon to sat). For example, in May 1931, the number of Mondays was 4 while the number of Sundays was 5. The column td1 compares the total number of weekdays with the total number of weekends. The normalization formula is the one used by X-13 and described in the X-13 manual.

To extract the regressors as ts time series, we use the following code:

td1nolpyear <-
  td_m_tbl |>
  select(time, value = td1) |>
  ts_ts()

tdnolpyear <-
  td_m_tbl |>
  select(-td1) |>
  ts_long() |>
  ts_ts()

9.3.2 Single coefficient regressor

It is straightforward to replicate these values to the automated procedure in X-13:

m_td1coef <- seas(
  AirPassengers,
  xreg = td1nolpyear,
  regression.aictest = NULL,
  outlier = NULL,
  regression.usertype = "td"
)
summary(m_td1coef)
#> 
#> Call:
#> seas(x = AirPassengers, xreg = td1nolpyear, regression.aictest = NULL, 
#>     outlier = NULL, regression.usertype = "td")
#> 
#> Coefficients:
#>                     Estimate Std. Error z value Pr(>|z|)    
#> xreg              -0.0025474  0.0006732  -3.784 0.000154 ***
#> MA-Nonseasonal-01  0.3292278  0.0813633   4.046 5.20e-05 ***
#> MA-Seasonal-12     0.5695911  0.0739360   7.704 1.32e-14 ***
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> SEATS adj.  ARIMA: (0 1 1)(0 1 1)  Obs.: 144  Transform: log
#> AICc: 976.6, BIC: 987.7  QS (no seasonality in final):    0  
#> Box-Ljung (no autocorr.): 24.86   Shapiro (normality): 0.9805 *
m_td1coef_built_in <- seas(
  AirPassengers,
  regression.aictest = NULL,
  outlier = NULL,
  regression.variables = c("td1nolpyear", outlier = NULL)
)
summary(m_td1coef_built_in)
#> 
#> Call:
#> seas(x = AirPassengers, regression.aictest = NULL, outlier = NULL, 
#>     regression.variables = c("td1nolpyear", outlier = NULL))
#> 
#> Coefficients:
#>                     Estimate Std. Error z value Pr(>|z|)    
#> Weekday           -0.0025474  0.0006732  -3.784 0.000154 ***
#> MA-Nonseasonal-01  0.3292278  0.0813633   4.046 5.20e-05 ***
#> MA-Seasonal-12     0.5695911  0.0739360   7.704 1.32e-14 ***
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> SEATS adj.  ARIMA: (0 1 1)(0 1 1)  Obs.: 144  Transform: log
#> AICc: 976.6, BIC: 987.7  QS (no seasonality in final):    0  
#> Box-Ljung (no autocorr.): 24.86   Shapiro (normality): 0.9805 *

9.3.3 One coefficient per day

Note that the baseline is Sunday.

m_td <- seas(
  AirPassengers,
  xreg = tdnolpyear,
  regression.aictest = NULL,
  outlier = NULL,
  regression.usertype = "td"
)
summary(m_td)
#> 
#> Call:
#> seas(x = AirPassengers, xreg = tdnolpyear, regression.aictest = NULL, 
#>     outlier = NULL, regression.usertype = "td")
#> 
#> Coefficients:
#>                    Estimate Std. Error z value Pr(>|z|)    
#> xreg1             -0.004982   0.004731  -1.053 0.292359    
#> xreg2             -0.004589   0.004762  -0.964 0.335172    
#> xreg3             -0.001612   0.004745  -0.340 0.734094    
#> xreg4             -0.003817   0.004680  -0.816 0.414652    
#> xreg5              0.003958   0.004706   0.841 0.400272    
#> xreg6              0.003164   0.004829   0.655 0.512342    
#> MA-Nonseasonal-01  0.298942   0.082193   3.637 0.000276 ***
#> MA-Seasonal-12     0.579965   0.073855   7.853 4.07e-15 ***
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> SEATS adj.  ARIMA: (0 1 1)(0 1 1)  Obs.: 144  Transform: log
#> AICc: 983.9, BIC:  1008  QS (no seasonality in final):    0  
#> Box-Ljung (no autocorr.): 29.43   Shapiro (normality): 0.9781 *
m_td_built_in <- seas(
  AirPassengers,
  regression.aictest = NULL,
  outlier = NULL,
  regression.variables = c("tdnolpyear", outlier = NULL)
)
summary(m_td_built_in)
#> 
#> Call:
#> seas(x = AirPassengers, regression.aictest = NULL, outlier = NULL, 
#>     regression.variables = c("tdnolpyear", outlier = NULL))
#> 
#> Coefficients:
#>                    Estimate Std. Error z value Pr(>|z|)    
#> Mon               -0.004982   0.004731  -1.053 0.292359    
#> Tue               -0.004589   0.004762  -0.964 0.335172    
#> Wed               -0.001612   0.004745  -0.340 0.734094    
#> Thu               -0.003817   0.004680  -0.816 0.414652    
#> Fri                0.003958   0.004706   0.841 0.400272    
#> Sat                0.003164   0.004829   0.655 0.512342    
#> MA-Nonseasonal-01  0.298942   0.082193   3.637 0.000276 ***
#> MA-Seasonal-12     0.579965   0.073855   7.853 4.07e-15 ***
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> SEATS adj.  ARIMA: (0 1 1)(0 1 1)  Obs.: 144  Transform: log
#> AICc: 983.9, BIC:  1008  QS (no seasonality in final):    0  
#> Box-Ljung (no autocorr.): 29.43   Shapiro (normality): 0.9781 *

9.3.4 Is it worth building country specific regressors?

The built-in trading day adjustment in X-13 is exclusively based on the calendar to construct regressor variables, meaning it does not consider country-specific holidays like Thanksgiving in the US. These holidays are addressed separately, as discussed in the Chapter 8 section.

As previously noted, regular holidays, such as Christmas, typically don’t need special handling. When dealing with irregular holidays, such as Chinese New Year, employing holiday regressors is the preferred approach. Consequently, creating country-specific regressors is generally unnecessary.

9.4 Case Study: Azerbaijani Consumer price index for construction

td1coef is significant. But does the series have a seasonal pattern?

library(cbar.sa)
#> 
#> Attaching package: 'cbar.sa'
#> The following object is masked _by_ '.GlobalEnv':
#> 
#>     m1
#> The following object is masked from 'package:seasonal':
#> 
#>     cpi
m0 <- seas(
  x = cpi_const,
  regression.variables = c("const", "ls2009.1"),
  arima.model = "(1 1 0)",
  regression.aictest = NULL,
  outlier = NULL,
  transform.function = "none"
)
#> Model used in SEATS is different: (0 1 1)
summary(m0)
#> 
#> Call:
#> seas(x = cpi_const, transform.function = "none", regression.aictest = NULL, 
#>     outlier = NULL, regression.variables = c("const", "ls2009.1"), 
#>     arima.model = "(1 1 0)")
#> 
#> Coefficients:
#>                   Estimate Std. Error z value Pr(>|z|)    
#> Constant            0.7068     0.1674   4.224 2.40e-05 ***
#> LS2009.1           -3.5364     0.8613  -4.106 4.02e-05 ***
#> AR-Nonseasonal-01   0.3144     0.1229   2.558   0.0105 *  
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> SEATS adj.  ARIMA: (1 1 0)  Obs.: 62  Transform: none
#> AICc:   169, BIC: 176.8  QS (no seasonality in final):0.7392  
#> Box-Ljung (no autocorr.): 11.86   Shapiro (normality): 0.9754  
#> Messages generated by X-13:
#> Notes:
#> - Model used for SEATS decomposition is different from the
#>   model estimated in the regARIMA modeling module of
#>   X-13ARIMA-SEATS.
m1 <- seas(
  x = cpi_const,
  regression.variables = c("const", "td1coef", "ls2009.1"), 
  arima.model = "(1 1 0)",
  regression.aictest = NULL,
  outlier = NULL,
  transform.function = "none"
)
#> Model used in SEATS is different: (0 1 1)
summary(m1)
#> 
#> Call:
#> seas(x = cpi_const, transform.function = "none", regression.aictest = NULL, 
#>     outlier = NULL, regression.variables = c("const", "td1coef", 
#>         "ls2009.1"), arima.model = "(1 1 0)")
#> 
#> Coefficients:
#>                   Estimate Std. Error z value Pr(>|z|)    
#> Constant            0.7050     0.1736   4.062 4.86e-05 ***
#> Weekday             0.1262     0.0436   2.896  0.00379 ** 
#> Leap Year          -0.5241     0.2943  -1.781  0.07491 .  
#> LS2009.1           -3.3119     0.7863  -4.212 2.53e-05 ***
#> AR-Nonseasonal-01   0.3927     0.1210   3.245  0.00117 ** 
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> SEATS adj.  ARIMA: (1 1 0)  Obs.: 62  Transform: none
#> AICc:   164, BIC: 175.1  QS (no seasonality in final):0.423  
#> Box-Ljung (no autocorr.): 12.95   Shapiro (normality): 0.9701  
#> Messages generated by X-13:
#> Notes:
#> - Model used for SEATS decomposition is different from the
#>   model estimated in the regARIMA modeling module of
#>   X-13ARIMA-SEATS.

9.5 Exercises

  1. What is the difference between td1coef and td regressor? When would fit better than the other?
  2. Run seas() to on AirPassengers using td1coef and td regressors and compare the results.
  3. Consult X-13ARIMA-SEATS Reference Manual. What other options are available as built-in regressors to model trading days?
  4. Would it make sense to model both the holiday and trading days effect at the same time?
  5. Explain the difference among the following arguments: xreg, regression.variables, and regression.usertype. Are these equivalent?
  6. Use the trade_tur series. Try modeling the holiday and trading days effect simultaneously and compare the results with the individual solutions. Do you get better results?