The G20 panel that ships with contagionchannels is one possible application; the same machinery applies to any directed network of return series and any set of channel proxies a user can construct. This vignette shows how to plug in custom data, explains the contracts each function expects, and ends with a worked example using a synthetic five-market panel seeded for reproducibility.
library(contagionchannels)
library(xts)
#> Loading required package: zoo
#>
#> Attaching package: 'zoo'
#> The following objects are masked from 'package:base':
#>
#> as.Date, as.Date.numeric
library(dplyr)
#>
#> ######################### Warning from 'xts' package ##########################
#> # #
#> # The dplyr lag() function breaks how base R's lag() function is supposed to #
#> # work, which breaks lag(my_xts). Calls to lag(my_xts) that you type or #
#> # source() into this session won't work correctly. #
#> # #
#> # Use stats::lag() to make sure you're not using dplyr::lag(), or you can add #
#> # conflictRules('dplyr', exclude = 'lag') to your .Rprofile to stop #
#> # dplyr from breaking base R's lag() function. #
#> # #
#> # Code in packages is not affected. It's protected by R's namespace mechanism #
#> # Set `options(xts.warn_dplyr_breaks_lag = FALSE)` to suppress this warning. #
#> # #
#> ###############################################################################
#>
#> Attaching package: 'dplyr'
#> The following objects are masked from 'package:xts':
#>
#> first, last
#> The following objects are masked from 'package:stats':
#>
#> filter, lag
#> The following objects are masked from 'package:base':
#>
#> intersect, setdiff, setequal, unionTwo objects drive the entire pipeline.
Returns. An xts object with one column per market. Rows are trading days (the index must be Date or POSIXct); columns are demeaned daily log returns. Missing values are allowed but should be sparse; na.locf or listwise deletion are both acceptable, but the user is responsible for the choice.
Channel proxies. A data.frame with a Date column matching the returns index plus one column per raw component of each channel. The helper build_channel_composites() does the standardisation and PCA reduction; the user must only ensure that the components are aligned on date and roughly stationary (returns or first-differenced spreads, never raw levels).
str(my_returns_xts)
# An 'xts' object on 2010-01-04/2024-12-31 of 18 columns
str(my_channels_df)
# 'data.frame' with Date + raw component columnsThe package does not hard-code 18 markets; the WQTE estimator accepts any \(N \ge 3\) markets. To pass your own crisis periods, build a named list of Date-vector pairs:
my_periods <- list(
Pre_Pandemic = as.Date(c("2018-01-02", "2020-01-31")),
Pandemic = as.Date(c("2020-02-01", "2021-12-31")),
Recovery = as.Date(c("2022-01-01", "2024-12-31"))
)Periods need not be contiguous and can overlap, though overlapping windows complicate cross-period comparisons. The conventional non-overlap rule is what the paper uses.
build_channel_composites() exposes a mapping argument that lets the user override the default component-to-channel assignment. Each channel must have at least two components for the PCA reduction to be defined.
my_mapping <- list(
Trade = c("BDI_chg", "TradeWeightedFX_chg", "ContainerRate_chg"),
Financial = c("FRAOIS", "TEDspread", "CDS_lvl"),
Geopolitical = c("GPR_daily", "GPR_actions"),
Behavioral = c("VIX_innov", "VVIX_innov", "PutCallRatio"),
Monetary_Policy = c("ShadowRate_surp", "FF_futures_surp")
)
my_channels <- build_channel_composites(
proxy_grid = my_proxies_df,
mapping = my_mapping,
standardise = "rolling_252"
)The standardise argument selects between "global" (one z-score over the whole sample), "rolling_252" (one-year rolling), or "period" (within each crisis period). The paper uses "rolling_252" to match the daily update cadence of the underlying components.
Once returns and composites are in hand, run_contagion_pipeline() consumes both. The full call mirrors the replication vignette but with user-supplied objects:
results <- run_contagion_pipeline(
returns = my_returns_xts,
channels = my_channels,
periods = my_periods,
scale = 5,
tau = 0.50,
abs_threshold = NULL, # NULL => derive from first period Q75
methods = c("iv2sls", "lp"),
bootstrap_B = 499,
n_cores = 4
)Setting abs_threshold = NULL instructs the pipeline to derive the absolute threshold from the 75th percentile of the first listed period’s WQTE distribution. Alternatively, pass a numeric value to fix the threshold across applications, which is useful for cross-paper comparability.
The plotting helpers accept any tidy data.frame with a Period and Channel column; they do not assume the eight G20 sub-periods. To customise:
plot_attribution_stack(
shares_long = results$shares_long,
period_order = names(my_periods),
palette = c(Trade = "#1f77b4", Financial = "#d62728",
Geopolitical = "#9467bd", Behavioral = "#2ca02c",
Monetary_Policy = "#ff7f0e")
)
plot_qte_intensity(
F_matrix = results$F_matrices$Pandemic,
threshold = results$abs_threshold,
market_order = c("US", "EU", "JP", "EM_Asia", "EM_LatAm")
)Both helpers return a ggplot object that can be modified with the standard ggplot2 grammar.
A small, fully-reproducible example is the most reliable way to confirm the contracts. Below we simulate five correlated equity-like return series together with five channel components, build the composites, and run a trimmed Stage 1 + Stage 2 pipeline.
set.seed(20260429)
n_obs <- 1500
markets <- c("US", "EU", "JP", "EM_Asia", "EM_LatAm")
dates <- seq.Date(from = as.Date("2018-01-02"), by = "day",
length.out = n_obs)
# Common factor + idiosyncratic shocks
Fcom <- rnorm(n_obs, sd = 0.012)
ret_mat <- sapply(markets, function(m) {
loading <- runif(1, 0.4, 0.9)
loading * Fcom + rnorm(n_obs, sd = 0.010)
})
my_returns <- xts(ret_mat, order.by = dates)
# Channel proxy raw components
my_proxies <- data.frame(
Date = dates,
BDI_chg = rnorm(n_obs, sd = 0.5),
TradeFX_chg = rnorm(n_obs, sd = 0.4),
FRAOIS = arima.sim(list(ar = 0.95), n_obs) * 0.01,
TEDspread = arima.sim(list(ar = 0.93), n_obs) * 0.01,
GPR_daily = exp(rnorm(n_obs, sd = 0.2)),
GPR_actions = exp(rnorm(n_obs, sd = 0.3)),
VIX_innov = rnorm(n_obs, sd = 1.5),
VVIX_innov = rnorm(n_obs, sd = 1.0),
ShadowRate_surp = rnorm(n_obs, sd = 0.05),
FF_futures_surp = rnorm(n_obs, sd = 0.04)
)
my_mapping <- list(
Trade = c("BDI_chg", "TradeFX_chg"),
Financial = c("FRAOIS", "TEDspread"),
Geopolitical = c("GPR_daily", "GPR_actions"),
Behavioral = c("VIX_innov", "VVIX_innov"),
Monetary_Policy = c("ShadowRate_surp", "FF_futures_surp")
)
my_periods <- list(
Calm = as.Date(c("2018-01-02", "2019-12-31")),
Stress = as.Date(c("2020-01-01", "2021-12-31"))
)my_channels <- build_channel_composites(
proxy_grid = my_proxies,
mapping = my_mapping,
standardise = "rolling_252"
)
head(my_channels, 3)A trimmed Stage 1 estimate on the Calm sub-period:
calm_dates <- my_periods$Calm
returns_calm <- my_returns[paste0(calm_dates[1], "/", calm_dates[2])]
F_calm <- compute_wqte_matrix(
returns = returns_calm,
scale = 5,
tau = 0.50,
n_cores = 1,
)
abs_thr_calm <- quantile(
F_calm[upper.tri(F_calm) | lower.tri(F_calm)],
probs = 0.75, na.rm = TRUE
)
links_calm <- which(F_calm >= abs_thr_calm, arr.ind = TRUE)
nrow(links_calm)A Stage 2 IV/2SLS attribution on the same window:
channels_calm <- my_channels[
my_channels$Date >= calm_dates[1] & my_channels$Date <= calm_dates[2], ]
iv_calm <- iv_2sls_attribute(
returns_period = returns_calm,
channels_period = channels_calm,
links = links_calm,
cluster_se = TRUE
)
iv_calm$sharesEnd-to-end pipeline call on the synthetic data:
synth_results <- run_contagion_pipeline(
returns = my_returns,
channels = my_channels,
periods = my_periods,
scale = 5,
tau = 0.50,
abs_threshold = abs_thr_calm,
methods = c("iv2sls"),
bootstrap_B = 199,
n_cores = 1
)
synth_results$summary_tableThe synthetic panel is too small for inference to be meaningful, but running the chain end-to-end is the cleanest way to verify that the data contracts are satisfied before committing compute to a real estimation.
A few user errors recur in support requests:
my_proxies$Date must match index(my_returns) exactly; the pipeline performs an inner_join and silently drops dates that do not match. Always inspect nrow(my_channels) after composites are built.abs_threshold = NULL the pipeline defaults to the first listed period; if your first period is a stress period the resulting threshold will be conservative and may zero out links in calmer periods. List a calm period first when relying on the default.These pitfalls aside, the package’s contracts are deliberately minimal: an xts of returns, a data.frame of proxies, and a list of period endpoints. Anything that satisfies those three constraints can be analysed with the same machinery that produces the headline results in the paper.
sessionInfo()
#> R version 4.1.2 (2021-11-01)
#> Platform: x86_64-pc-linux-gnu (64-bit)
#> Running under: Ubuntu 22.04.5 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/blas/libblas.so.3.10.0
#> LAPACK: /usr/lib/x86_64-linux-gnu/lapack/liblapack.so.3.10.0
#>
#> locale:
#> [1] LC_CTYPE=en_IN LC_NUMERIC=C LC_TIME=en_IN
#> [4] LC_COLLATE=C LC_MONETARY=en_IN LC_MESSAGES=en_IN
#> [7] LC_PAPER=en_IN LC_NAME=C LC_ADDRESS=C
#> [10] LC_TELEPHONE=C LC_MEASUREMENT=en_IN LC_IDENTIFICATION=C
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] dplyr_1.1.4 xts_0.14.1 zoo_1.8-14
#> [4] contagionchannels_0.1.3
#>
#> loaded via a namespace (and not attached):
#> [1] bslib_0.9.0 compiler_4.1.2 pillar_1.11.1 jquerylib_0.1.4
#> [5] tools_4.1.2 digest_0.6.37 tibble_3.3.0 jsonlite_2.0.0
#> [9] evaluate_1.0.5 lifecycle_1.0.5 lattice_0.20-45 pkgconfig_2.0.3
#> [13] rlang_1.2.0 Matrix_1.5-4.1 igraph_2.2.0 cli_3.6.6
#> [17] yaml_2.3.10 parallel_4.1.2 SparseM_1.84-2 xfun_0.53
#> [21] fastmap_1.2.0 multitaper_1.0-17 knitr_1.50 generics_0.1.4
#> [25] sass_0.4.10 vctrs_0.6.5 MatrixModels_0.5-1 tidyselect_1.2.1
#> [29] grid_4.1.2 glue_1.8.0 R6_2.6.1 survival_3.2-13
#> [33] waveslim_1.8.5 rmarkdown_2.30 magrittr_2.0.4 htmltools_0.5.8.1
#> [37] MASS_7.3-55 splines_4.1.2 quantreg_6.1 cachem_1.1.0