Post-Shock Forecasting Workflow

Overview

The donor-adjustment framework builds on the post-shock forecasting approach of Lin and Eck (2021).

Large structural shocks can cause forecasting models fitted only to pre-shock data to perform poorly in the post-shock period. The postshock package provides a donor-based forecasting framework that uses information from historical shock episodes to adjust forecasts following a new shock to the target series.

The package supports forecasting of both conditional mean and volatility dynamics. Its main components are:

The general workflow is:

  1. define a target episode and a collection of historical donor episodes;
  2. align the target and donors relative to their shock times;
  3. compare their pre-shock characteristics and estimate donor weights;
  4. estimate donor-specific post-shock effects; and
  5. add the weighted donor effect to a baseline forecast for the target.

This vignette demonstrates the main package workflows using processed data objects distributed with postshock. We first illustrate post-shock mean forecasting using the ConocoPhillips example, then introduce the structured donor-pool extension, and finally demonstrate post-shock volatility forecasting using the IYG example.

Package data structure

The forecasting functions in postshock operate on collections of target and donor series aligned relative to their respective shock times. At the function interface, the target and donor series are supplied through Y_series_list, with the target stored in the first position and the historical donors stored in the remaining positions.

Each series has its own shock-timing information:

The covariate structure differs between the two forecasting workflows:

The ordering of the series, covariate inputs, shock times, and shock lengths must remain consistent within the relevant workflow. Entries representing the same target or donor episode must be correctly aligned.

The cop_oil_postshock object follows the mean-forecasting structure. Its target and donor series are stored in Y, while their corresponding covariate matrices are stored in X.

The list names do not need to be identical across Y and X, but they should clearly identify the corresponding episodes. For example, Y_2008_03_17 and X_2008_03_17 contain the series and covariate matrix associated with the same historical shock episode.

This vignette uses two processed data objects distributed with the package:

Additional processed objects for the Apple control-shock examples are also available in the package.

We begin with the ConocoPhillips data object.

data("cop_oil_postshock", package = "postshock")

names(cop_oil_postshock)
#> [1] "covar_names"      "Y"                "X"                "shock_time_vec"  
#> [5] "shock_length_vec"

The target and donor series are stored in Y, while their corresponding covariate matrices are stored in X.

cop_structure <- data.frame(
  Position = seq_along(cop_oil_postshock$Y),
  Series = names(cop_oil_postshock$Y),
  Covariates = names(cop_oil_postshock$X),
  Shock_time = cop_oil_postshock$shock_time_vec,
  Shock_length = cop_oil_postshock$shock_length_vec
)

knitr::kable(
  cop_structure,
  caption = "Alignment of the target and donor inputs in `cop_oil_postshock`."
)
Alignment of the target and donor inputs in cop_oil_postshock.
Position Series Covariates Shock_time Shock_length
1 Y_target X_target 30 1
2 Y_2008_03_17 X_2008_03_17 30 1
3 Y_2014_11_28 X_2014_11_28 30 1
4 Y_2008_09_09 X_2008_09_09 30 1
5 Y_2008_09_15 X_2008_09_15 30 1
6 Y_2008_09_29 X_2008_09_29 30 1

The first element represents the target episode, and the remaining elements represent historical donor episodes. For every series, shock_time_vec identifies the last observation in the pre-shock period, so the shock effect begins at the following observation.

Donor balancing weights with dbw()

Historical donor episodes are not necessarily equally informative for a new target shock. The function dbw() constructs donor balancing weights using characteristics observed before the shock.

The function compares the target’s pre-shock feature vector with weighted combinations of the donor feature vectors. Donors that provide a closer pre-shock match can therefore receive larger weights.

A commonly used specification constrains the donor weights to be nonnegative and sum to one. Under this specification, the weights can be interpreted as the contribution of each donor to a synthetic donor combination.

For the ConocoPhillips example, we use all available covariate columns and an L2-regularized balancing specification.

cop_dbw <- dbw(
  X = cop_oil_postshock$X,
  dbw_indices = seq_len(ncol(cop_oil_postshock$X[[1]])),
  shock_time_vec = cop_oil_postshock$shock_time_vec,
  center = TRUE,
  scale = TRUE,
  sum_to_1 = TRUE,
  bounded_below_by = 0,
  bounded_above_by = 1,
  normchoice = "l2",
  penalty_normchoice = "l2",
  penalty_lambda = 1e-2
)

The returned object contains the estimated donor weights, the final balancing loss, and the optimizer convergence result.

names(cop_dbw)
#> [1] "opt_params"  "convergence" "loss"

cop_dbw$convergence
#> [1] "convergence"
cop_dbw$loss
#> [1] 2.818126
sum(cop_dbw$opt_params)
#> [1] 1

The convergence result indicates whether the numerical optimization completed successfully. The loss measures the remaining discrepancy between the target features and the weighted donor features under the selected scaling and regularization specification. It is primarily useful for comparing alternative DBW specifications fitted to the same data.

The donor weights are stored in opt_params.

cop_weight_table <- data.frame(
  Donor = sub(
    "^Y_",
    "",
    names(cop_oil_postshock$Y)[-1]
  ),
  Weight = as.numeric(cop_dbw$opt_params)
)

knitr::kable(
  cop_weight_table,
  digits = 3,
  caption = "Donor balancing weights for the ConocoPhillips example."
)
Donor balancing weights for the ConocoPhillips example.
Donor Weight
2008_03_17 0.000
2014_11_28 0.635
2008_09_09 0.000
2008_09_15 0.000
2008_09_29 0.365

The estimated weights sum to one, up to numerical precision. In this example, most of the weight is assigned to the 2014-11-28 and 2008-09-29 episodes, while the remaining donor episodes receive weights close to zero. Under the selected pre-shock covariates and balancing specification, these two episodes therefore provide the main contribution to the synthetic donor combination.

These weights determine how donor-specific shock effects are combined. They should not be interpreted as probabilities that particular historical events will recur.

In most applications, users do not need to call dbw() separately. SynthPrediction() and SynthVolForecast() can call it internally when donor-based weighting is requested. A direct call is nevertheless useful for examining the pre-shock match and understanding which donors contribute to the synthetic adjustment.

Mean forecasting with SynthPrediction()

SynthPrediction() produces post-shock mean forecasts by combining a baseline forecast for the target series with estimated post-shock effects from historical donor episodes.

The procedure has four main steps. First, an ARIMA or ARIMAX model containing a post-shock indicator is fitted to each donor series. The coefficient of this indicator represents the estimated post-shock shift for that donor. Second, the donor-specific shifts are combined using either equal weights or donor balancing weights. Third, a baseline model is fitted to the target using only its pre-shock observations. Finally, the combined donor shift is added to the target baseline forecast.

The function returns three forecast versions:

ConocoPhillips example

The target episode is the large decline in the ConocoPhillips (COP) share price in March 2020. Five earlier crisis and oil-market episodes are used as donors. The aligned series, covariates, and shock timing information are stored in the cop_oil_postshock object loaded in the previous section.

Fit a one-step post-shock forecast

We set k = 1 to forecast the first observation after the target’s pre-shock period. The call below uses the same feature columns, centering and scaling choices, and L2 penalty as the direct dbw() example above.

The covariate matrices are used to construct donor weights. Because covariate_indices = NULL, they are not included as regressors in the ARIMA models. In that case, each donor model contains the post-shock indicator, while the target baseline is fitted without covariate regressors.

cop_fit <- SynthPrediction(
  Y_series_list = cop_oil_postshock$Y,
  covariates_series_list = cop_oil_postshock$X,
  shock_time_vec = cop_oil_postshock$shock_time_vec,
  shock_length_vec = cop_oil_postshock$shock_length_vec,
  k = 1L,
  dbw_indices = seq_len(ncol(cop_oil_postshock$X[[1]])),
  dbw_center = TRUE,
  dbw_scale = TRUE,
  use_dbw = TRUE,
  covariate_indices = NULL,
  seasonal = TRUE,
  penalty_lambda = 1e-2,
  penalty_normchoice = "l2"
)

Setting seasonal = TRUE allows the function to consider a seasonal specification when a meaningful seasonal frequency is detected. It does not require every fitted model to contain a seasonal component.

Inspect forecasts

The returned object has three main components. predictions contains the forecast versions, linear_combinations contains the donor weights, and meta contains donor-specific effects and model information.

names(cop_fit)
#> [1] "linear_combinations" "predictions"         "meta"
names(cop_fit$predictions)
#> [1] "unadjusted"      "adjusted"        "arithmetic_mean"
names(cop_fit$meta)
#> [1] "n_donors"       "shock_time"     "shock_length"   "dbw_status"    
#> [5] "weights"        "omega_vec"      "combined_omega" "arima_order"   
#> [9] "ic_used"

Compare the forecasts

The first post-shock observation occurs at the index immediately following the last pre-shock observation of the target.

forecast_index <- cop_oil_postshock$shock_time_vec[1] + 1L

observed_value <- as.numeric(
  cop_oil_postshock$Y[[1]][forecast_index]
)

forecast_values <- c(
  as.numeric(cop_fit$predictions$unadjusted[1]),
  as.numeric(cop_fit$predictions$arithmetic_mean[1]),
  as.numeric(cop_fit$predictions$adjusted[1])
)

cop_forecast_table <- data.frame(
  Method = c(
    "Observed value",
    "Unadjusted",
    "Arithmetic mean",
    "DBW adjusted"
  ),
  Value = c(
    observed_value,
    forecast_values
  ),
  Absolute_error = c(
    NA_real_,
    abs(forecast_values - observed_value)
  )
)

knitr::kable(
  cop_forecast_table,
  digits = 3,
  col.names = c("Method", "Value", "Absolute error"),
  caption = paste(
    "One-step forecast comparison for the March 2020",
    "ConocoPhillips shock episode."
  )
)
One-step forecast comparison for the March 2020 ConocoPhillips shock episode.
Method Value Absolute error
Observed value 34.070 NA
Unadjusted 44.734 10.664
Arithmetic mean 39.750 5.680
DBW adjusted 39.110 5.040

The observed value is included only for retrospective evaluation. It is not supplied to SynthPrediction() when the target model or donor weights are estimated.

As shown in the table, both donor-adjusted forecasts reduce the absolute error relative to the unadjusted baseline. The DBW-adjusted forecast also has a smaller error than the arithmetic-mean adjustment in this example.

The improvement over the unadjusted baseline is substantial, whereas the additional gain from DBW weighting over equal weighting is more modest. Because the comparison is based on a single retrospective one-step forecast, it should be interpreted as an illustration of the workflow rather than general evidence that DBW always performs better.

Inspect donor weights and the combined adjustment

The donor weights used by SynthPrediction() are stored in linear_combinations. The estimated donor-specific post-shock effects are stored in meta$omega_vec.

cop_donor_summary <- data.frame(
  Donor = sub(
    "^Y_",
    "",
    names(cop_oil_postshock$Y)[-1]
  ),
  Weight = as.numeric(cop_fit$linear_combinations),
  Estimated_effect = as.numeric(cop_fit$meta$omega_vec)
)

knitr::kable(
  cop_donor_summary,
  digits = 3,
  col.names = c(
    "Donor",
    "DBW weight",
    "Estimated post-shock effect"
  ),
  caption = "Donor weights and estimated effects for the COP example."
)
Donor weights and estimated effects for the COP example.
Donor DBW weight Estimated post-shock effect
2008_03_17 0.000 -1.772
2014_11_28 0.635 -5.205
2008_09_09 0.000 -7.269
2008_09_15 0.000 -4.318
2008_09_29 0.365 -6.353

Donors with weights close to zero make little contribution to the final adjustment, even when their estimated post-shock effects are relatively large, whereas donors with larger weights have greater influence.

data.frame(
  Unadjusted_forecast =
    as.numeric(cop_fit$predictions$unadjusted[1]),
  Combined_donor_effect =
    as.numeric(cop_fit$meta$combined_omega),
  Adjusted_forecast =
    as.numeric(cop_fit$predictions$adjusted[1])
) |>
  knitr::kable(
    digits = 3,
    col.names = c(
      "Unadjusted forecast",
      "Combined donor effect",
      "DBW-adjusted forecast"
    ),
    caption = "Construction of the DBW-adjusted COP forecast."
  )
Construction of the DBW-adjusted COP forecast.
Unadjusted forecast Combined donor effect DBW-adjusted forecast
44.734 -5.624 39.11

The sign of the combined donor effect determines the direction of the adjustment. In this example, the combined effect is negative, so it lowers the unadjusted forecast and moves it closer to the observed post-shock value.

Structured donor pools

A single-pool analysis treats all historical donor episodes as members of one common donor set. In some applications, however, donor shocks may represent different economic mechanisms. The MultiPool extension allows donors to be organized into interpretable groups before their estimated adjustments are aggregated.

For the ConocoPhillips example, we separate the donor episodes into two channels:

These classifications are specified by the analyst and should reflect the economic mechanisms underlying the historical events. Numerical pre-shock characteristics are used later to construct donor weights within each pool.

Define the donor pools

Pools are supplied as a named list. Each element contains donor names that must match the corresponding entries in names(Y_series_list).

cop_pools <- list(
  supply = "Y_2014_11_28",
  demand = c(
    "Y_2008_03_17",
    "Y_2008_09_09",
    "Y_2008_09_15",
    "Y_2008_09_29"
  )
)

cop_pool_table <- data.frame(
  Pool = names(cop_pools),
  Donors = unname(
    vapply(
      cop_pools,
      function(x) {
        paste(
          gsub("_", "-", sub("^Y_", "", x)),
          collapse = ", "
        )
      },
      character(1)
    )
  )
)

knitr::kable(
  cop_pool_table,
  row.names = FALSE,
  col.names = c("Pool", "Donor episodes"),
  caption = "Structured donor pools for the ConocoPhillips example."
)
Structured donor pools for the ConocoPhillips example.
Pool Donor episodes
supply 2014-11-28
demand 2008-03-17, 2008-09-09, 2008-09-15, 2008-09-29

The first element of Y_series_list is always treated as the target. Only donor names should therefore be included in pools; the target is added automatically when each pool-specific model is fitted.

Fit the MultiPool specification

Providing the pools argument causes SynthPrediction() to use the MultiPool extension. The function retains a common unadjusted target forecast and estimates a separate donor adjustment within each pool.

Here, pool_use_dbw = TRUE applies donor balancing within each pool. Because the supply pool contains only one donor, its within-pool weight is necessarily one. Donor balancing primarily affects the multi-donor demand pool.

cop_multipool <- SynthPrediction(
  Y_series_list = cop_oil_postshock$Y,
  covariates_series_list = cop_oil_postshock$X,
  shock_time_vec = cop_oil_postshock$shock_time_vec,
  shock_length_vec = cop_oil_postshock$shock_length_vec,
  k = 1L,
  covariate_indices = NULL,
  seasonal = TRUE,
  dbw_indices = seq_len(
    ncol(cop_oil_postshock$X[[1]])
  ),
  dbw_center = TRUE,
  dbw_scale = TRUE,
  penalty_lambda = 1e-2,
  penalty_normchoice = "l2",
  pools = cop_pools,
  pool_agg = "sum",
  base_use_dbw = FALSE,
  pool_use_dbw = TRUE
)

With pool_agg = "sum", the pool-specific adjustments are treated as additive components of the total post-shock adjustment. This is a modeling choice rather than a property automatically inferred from the data. Users can instead select pool_agg = "weighted" and provide a named numeric vector through pool_weights.

Inspect the pool-specific adjustments

The estimated adjustment for each pool is stored in meta$multi_pool$pool_effects, while the aggregated adjustment is stored in meta$multi_pool$aggregated_effect.

cop_pool_effects <- data.frame(
  Pool = names(
    cop_multipool$meta$multi_pool$pool_effects
  ),
  Estimated_adjustment = as.numeric(
    cop_multipool$meta$multi_pool$pool_effects
  )
)

knitr::kable(
  cop_pool_effects,
  row.names = FALSE,
  digits = 3,
  col.names = c(
    "Pool",
    "Estimated pool adjustment"
  ),
  caption = "Pool-specific adjustments for the ConocoPhillips example."
)
Pool-specific adjustments for the ConocoPhillips example.
Pool Estimated pool adjustment
supply -5.205
demand -6.353

The MultiPool-adjusted forecast is returned in predictions$adjusted_multipool.

cop_multipool_values <- c(
  as.numeric(
    cop_multipool$predictions$unadjusted[1]
  ),
  as.numeric(
    cop_fit$predictions$adjusted[1]
  ),
  as.numeric(
    cop_multipool$predictions$adjusted_multipool[1]
  )
)

cop_multipool_table <- data.frame(
  Method = c(
    "Observed value",
    "Unadjusted",
    "Single-pool DBW",
    "MultiPool (sum)"
  ),
  Value = c(
    observed_value,
    cop_multipool_values
  ),
  Absolute_error = c(
    NA_real_,
    abs(cop_multipool_values - observed_value)
  )
)

knitr::kable(
  cop_multipool_table,
  digits = 3,
  col.names = c("Method", "Value", "Absolute error"),
  caption = paste(
    "Comparison of single-pool and MultiPool forecasts",
    "for the ConocoPhillips example."
  )
)
Comparison of single-pool and MultiPool forecasts for the ConocoPhillips example.
Method Value Absolute error
Observed value 34.070 NA
Unadjusted 44.734 10.664
Single-pool DBW 39.110 5.040
MultiPool (sum) 33.176 0.894

In this example, both pool-level effects lower the unadjusted forecast. Their sum produces the MultiPool adjustment and moves the forecast closer to the observed post-shock value.

The result should be interpreted as an illustration of how economically meaningful donor groups can be represented in the package. A closer forecast in this single retrospective example does not imply that splitting donors into multiple pools will always improve forecasting performance.

Volatility forecasting with SynthVolForecast()

SynthVolForecast() extends the donor-adjustment framework to conditional variance forecasting. It combines a baseline variance forecast fitted to the pre-shock target series with post-shock variance adjustments estimated from historical donor episodes.

For each donor, the function fits a GARCH-X model containing a post-shock indicator in the conditional variance equation. The estimated coefficient of this indicator represents the donor-specific variance adjustment. These adjustments are then combined using donor balancing weights and added to the target’s baseline variance forecast.

The function returns three forecast versions:

Because conditional variance is latent in empirical financial data, forecast evaluation requires an observable proxy. In the example below, the next-period squared return is used as a realized-variance proxy.

Load the IYG example

The target series is IYG, a financial-sector exchange-traded fund, and the target shock is the 2016 U.S. presidential election. Historical election and political-uncertainty episodes serve as potential donors.

The processed inputs are distributed with postshock in the iyg_onestep_input object.

data("iyg_onestep_input", package = "postshock")

names(iyg_onestep_input)
#> [1] "description"      "target_symbol"    "target_event"     "inputs"          
#> [5] "donor_pool_specs" "evaluation"
names(iyg_onestep_input$inputs)
#> [1] "all"            "restricted"     "elections_only"

The object provides three analyst-defined donor-pool configurations: all, restricted, and elections_only. In this vignette, we use the restricted configuration to demonstrate the main workflow. The donor-pool choice is treated as part of the analysis design rather than selected using the realized forecast error.

iyg_restricted <- iyg_onestep_input$inputs$restricted

names(iyg_restricted)
#> [1] "target_event"           "donor_events"           "Y_series_list"         
#> [4] "covariates_series_list" "target_covariates"      "shock_time_vec"        
#> [7] "shock_length_vec"

The restricted input contains the target and donor return series, the donor covariate matrices, a separate target covariate matrix, and the shock-timing vectors required by SynthVolForecast().

iyg_restricted$target_event
#> [1] "2016 Election"
iyg_restricted$donor_events
#> [1] "2012 Election"        "Brexit Poll Released" "Brexit"

c(
  number_of_series =
    length(iyg_restricted$Y_series_list),
  number_of_donors =
    length(iyg_restricted$donor_events),
  donor_covariate_sets =
    length(iyg_restricted$covariates_series_list),
  shock_time_entries =
    length(iyg_restricted$shock_time_vec),
  shock_length_entries =
    length(iyg_restricted$shock_length_vec)
)
#>     number_of_series     number_of_donors donor_covariate_sets 
#>                    4                    3                    3 
#>   shock_time_entries shock_length_entries 
#>                    4                    4

The restricted configuration contains one target series and three donor series. The three donor covariate matrices correspond to the three donor episodes, while the shock-time and shock-length vectors contain one entry for every target or donor series.

Produce a variance forecast

We use the restricted donor-pool configuration and produce a one-step-ahead conditional variance forecast. This configuration contains the 2012 Election, Brexit Poll Released, and Brexit donor episodes.

The GARCH-X orders are selected automatically. With garch_order = NULL, SynthVolForecast() calls auto_garchx() separately for each donor and for the pre-shock target series. Candidate specifications are compared using BIC over GARCH orders up to max.p = 3 and ARCH orders up to max.q = 3, including both symmetric and asymmetric specifications.

The donor covariates are used for DBW matching. Because covariate_indices = NULL, the donor-side variance models contain the post-shock indicator but do not include additional covariates as GARCH-X regressors.

iyg_fit <- SynthVolForecast(
  Y_series_list =
    iyg_restricted$Y_series_list,
  covariates_series_list =
    iyg_restricted$covariates_series_list,
  target_covariates =
    iyg_restricted$target_covariates,
  shock_time_vec =
    iyg_restricted$shock_time_vec,
  shock_length_vec =
    iyg_restricted$shock_length_vec,
  k = 1L,
  dbw_indices = seq_len(
    ncol(iyg_restricted$target_covariates)
  ),
  dbw_center = TRUE,
  dbw_scale = TRUE,
  penalty_lambda = 1e-2,
  penalty_normchoice = "l2",
  covariate_indices = NULL,
  garch_order = NULL,
  max.p = 3L,
  max.q = 3L,
  vcov.type = "robust",
  return_fits = FALSE
)

Inspect the selected GARCH-X orders

The automatically selected orders are stored separately for the donor models and the target model. The order is reported as (q, p, o), where q is the ARCH order, p is the GARCH order, and o indicates whether an asymmetric term is included.

iyg_selected_orders <- do.call(
  rbind,
  c(
    iyg_fit$meta$donor_orders,
    list(iyg_fit$meta$target_order)
  )
)

iyg_order_table <- data.frame(
  Model = c(
    paste("Donor:", iyg_restricted$donor_events),
    paste("Target:", iyg_restricted$target_event)
  ),
  ARCH_q = iyg_selected_orders[, 1],
  GARCH_p = iyg_selected_orders[, 2],
  Asymmetry_o = iyg_selected_orders[, 3]
)

knitr::kable(
  iyg_order_table,
  row.names = FALSE,
  col.names = c(
    "Model",
    "ARCH order (q)",
    "GARCH order (p)",
    "Asymmetry indicator (o)"
  ),
  caption = paste(
    "Orders selected automatically by BIC for the",
    "donor and target variance models."
  )
)
Orders selected automatically by BIC for the donor and target variance models.
Model ARCH order (q) GARCH order (p) Asymmetry indicator (o)
Donor: 2012 Election 1 0 1
Donor: Brexit Poll Released 1 0 1
Donor: Brexit 1 0 1
Target: 2016 Election 1 0 1

An asymmetry indicator of one means that the selected model includes a GJR-type asymmetric term, allowing positive and negative return shocks to have different effects on conditional variance. A GARCH order of zero means that the specification does not include a lagged conditional-variance term, although it may still depend on lagged squared returns through its ARCH component. Because BIC selection is performed separately for each donor and the target, the selected orders need not be identical across series.

Inspect donor weights and variance adjustments

The donor balancing weights are stored in linear_combinations, while the estimated donor-specific adjustments in the conditional variance equation are stored in meta$omega_vec.

iyg_donor_summary <- data.frame(
  Donor = as.character(iyg_restricted$donor_events),
  Weight = as.numeric(
    iyg_fit$linear_combinations
  ),
  Estimated_adjustment = as.numeric(
    iyg_fit$meta$omega_vec
  )
)

knitr::kable(
  iyg_donor_summary,
  row.names = FALSE,
  digits = 6,
  col.names = c(
    "Donor episode",
    "DBW weight",
    "Estimated variance adjustment"
  ),
  caption = paste(
    "Donor weights and estimated variance adjustments",
    "for the restricted IYG specification."
  )
)
Donor weights and estimated variance adjustments for the restricted IYG specification.
Donor episode DBW weight Estimated variance adjustment
2012 Election 0.184268 0.000288
Brexit Poll Released 0.386991 0.002185
Brexit 0.428741 0.001254

In this example, Brexit and Brexit Poll Released receive the largest DBW weights, while the 2012 Election receives a smaller but still positive weight. All three estimated variance adjustments are positive, so each donor contributes to raising the target’s baseline variance forecast.

The weighted donor adjustment is stored in meta$combined_omega.

iyg_adjustment_table <- data.frame(
  Unadjusted_forecast = as.numeric(
    iyg_fit$predictions$unadjusted[1]
  ),
  Combined_adjustment = as.numeric(
    iyg_fit$meta$combined_omega
  ),
  Adjusted_forecast = as.numeric(
    iyg_fit$predictions$adjusted[1]
  )
)

knitr::kable(
  iyg_adjustment_table,
  row.names = FALSE,
  digits = 6,
  col.names = c(
    "Unadjusted variance forecast",
    "Combined donor adjustment",
    "DBW-adjusted variance forecast"
  ),
  caption = "Construction of the DBW-adjusted IYG variance forecast."
)
Construction of the DBW-adjusted IYG variance forecast.
Unadjusted variance forecast Combined donor adjustment DBW-adjusted variance forecast
6.8e-05 0.001436 0.001504

The sign of the combined donor adjustment determines the direction in which the baseline variance forecast is changed. In this example, the combined adjustment is positive, so it raises the unadjusted variance forecast.

SynthVolForecast() enforces a positive adjusted variance forecast. This constraint is not binding in the present example because the combined adjustment is positive.

Compare the variance forecasts

Because the latent conditional variance is not directly observed, we use the squared target return at the first post-shock observation as a realized-variance proxy.

iyg_proxy_index <-
  iyg_restricted$shock_time_vec[1] + 1L

iyg_observed_proxy <- as.numeric(
  iyg_restricted$Y_series_list[[1]][iyg_proxy_index]
)^2

iyg_forecast_values <- c(
  as.numeric(
    iyg_fit$predictions$unadjusted[1]
  ),
  as.numeric(
    iyg_fit$predictions$arithmetic_mean[1]
  ),
  as.numeric(
    iyg_fit$predictions$adjusted[1]
  )
)

iyg_forecast_table <- data.frame(
  Method = c(
    "Observed squared-return proxy",
    "Unadjusted",
    "Arithmetic mean",
    "DBW adjusted"
  ),
  Variance = c(
    iyg_observed_proxy,
    iyg_forecast_values
  ),
  Absolute_error = c(
    NA_real_,
    abs(
      iyg_forecast_values -
        iyg_observed_proxy
    )
  )
)

knitr::kable(
  iyg_forecast_table,
  row.names = FALSE,
  digits = 6,
  col.names = c(
    "Method",
    "Variance",
    "Absolute error"
  ),
  caption = paste(
    "One-step variance forecast comparison for IYG",
    "under the restricted donor-pool specification."
  )
)
One-step variance forecast comparison for IYG under the restricted donor-pool specification.
Method Variance Absolute error
Observed squared-return proxy 0.001750 NA
Unadjusted 0.000068 0.001682
Arithmetic mean 0.001310 0.000440
DBW adjusted 0.001504 0.000246

As shown in the table, the unadjusted variance forecast substantially underestimates the observed squared-return proxy. Both donor-based adjustments reduce the absolute error, and the DBW-adjusted forecast is the closest of the three forecasts to the observed proxy in this retrospective example.

This result illustrates the workflow but does not imply that DBW weighting will outperform alternative forecasts in every application.

Interpretation and practical guidance

The examples above illustrate a common workflow for post-shock mean and conditional variance forecasting. In practice, the quality of the resulting forecast depends on the definition of the target episode, the choice of donor episodes, and the pre-shock information used for donor balancing.

Several practical points should be considered when using postshock.

Align episodes consistently

The target and donor series should be aligned relative to their own shock times. The entry in shock_time_vec identifies the final pre-shock observation, and the shock effect begins at the following observation.

The ordering of the series, covariate matrices, shock times, and shock lengths must remain consistent. For MultiPool analyses, donor names must also match the names used in the pools specification.

Separate donor matching from model regressors

The arguments dbw_indices and covariate_indices have different roles.

Covariates may therefore be used for donor matching without being included in the ARIMA, ARIMAX, or GARCH-X equations. This distinction should be made explicit when defining an analysis.

Choose donors using substantive information

Donor episodes should be selected using information available independently of the realized target outcome. Economic interpretation, event type, market environment, and pre-shock comparability may all be relevant.

The MultiPool extension can be useful when donors represent distinct mechanisms. However, the pool definitions and aggregation rule are modeling choices. In particular, pool_agg = "sum" assumes that the pool-specific adjustments can be treated as additive components.

Inspect weights and convergence

Large DBW weights indicate donors that contribute more strongly to the synthetic adjustment under the selected pre-shock features and constraints. They should not be interpreted as probabilities that historical events will recur.

Users should also inspect the DBW convergence information and consider whether the resulting weights are concentrated on only a small number of donors. Alternative feature sets, scaling choices, penalties, or pool definitions may produce different weights.

Interpret empirical variance forecasts carefully

SynthVolForecast() returns forecasts on the conditional variance scale. Taking the square root converts a variance forecast to the corresponding standard-deviation scale.

In empirical applications, the latent conditional variance is not directly observed. A squared return can be used as a realized-variance proxy, but it is itself noisy. Forecast comparisons based on a single squared return should therefore be interpreted cautiously.

When garch_order = NULL, the GARCH-X specification is selected automatically for each donor and the target. Users who need additional diagnostic information can set return_fits = TRUE and inspect the fitted model objects.

Avoid post-shock information leakage

Donor weights and the target baseline model should be estimated without using the target’s post-shock observations. Historical donor post-shock outcomes are used by design to estimate donor-specific shock effects.

Realized post-shock target values may be used afterward for retrospective evaluation, but they should not influence donor selection, tuning choices, or model specification. Thus, the no-leakage restriction concerns information from the target’s post-shock period, not the already observed post-shock histories of the donor episodes.

See the help pages for dbw(), SynthPrediction(), and SynthVolForecast() for complete argument and return-value documentation.

References

Lin, J., and Eck, D. J. (2021). Minimizing post-shock forecasting error through aggregation of outside information. International Journal of Forecasting, 37(4), 1710–1727. https://doi.org/10.1016/j.ijforecast.2021.03.010