Operational Resilience: Quantifying Third-Party ICT Concentration Risk Under DORA

Translating the Register of Information into a Board-Level Loss Distribution

Author

Patrick Lefler

Published

May 27, 2026

Abstract

DORA Article 28 requires EU financial entities to assess ICT third-party concentration risk, but the regulation specifies the obligation, not the method. Most institutions discharge the requirement by completing the Register of Information — a structured disclosure that counts vendors and flags concentration — without producing a financial loss figure the board can use for capital, insurance, or contractual decisions. The Register’s implicit assumption is that each vendor is an independent risk. That assumption is structurally incorrect when sub-outsourcing chains collapse multiple vendor entries to a shared underlying provider.

This project constructs a directed dependency graph for a representative €35B AUM EU asset manager and shows that eight vendor entries in the Register collapse to five independent failure domains — a 1.6× concentration ratio the Register cannot express. A discrete-event simulation, calibrated to public incident histories of the ESAs’ 19 designated Critical ICT Third-Party Providers (November 2025), produces an annualized business interruption loss distribution in euros. The base-case results place the VaR 95 at €1.99M and the VaR 99 at €3.05M under correlated failure assumptions — materially higher than the naive independent-vendor estimate. The gap between the two figures is the quantitative cost of treating concentration risk as a vendor-counting exercise.

Introduction

Third-party ICT concentration risk sits at the intersection of two problems that EU financial regulators have spent the past three years trying to solve simultaneously. The first is systemic: a small number of cloud providers, financial messaging networks, and market infrastructure operators underpin the operational continuity of thousands of financial entities. When one of them fails, the failure is not isolated. It propagates across the firms that depend on it — often without those firms having a clear picture of how many layers of dependency separate them from the source of the disruption. The second problem is measurement: most firms manage this exposure through vendor inventories and concentration flags, tools designed for procurement governance rather than financial risk quantification. A flag that reads “high concentration in cloud infrastructure” carries no more financial precision than a ransomware label that reads “Critical.”

DORA (Digital Operational Resilience Act), which entered into force across the EU on 17 January 2025, imposes a direct obligation on financial entities to address both problems. DORA Article 28 requires assessment of concentration risk in ICT third-party arrangements. The European Supervisory Authorities’ (ESAs) accompanying Register of Information framework — which requires entities to map their ICT service dependencies with full sub-outsourcing chain visibility — creates the data infrastructure the assessment requires. In November 2025, the Joint Oversight Committee (JOC) published the first official list of 19 designated Critical ICT Third-Party Providers (CTPPs), establishing the regulatory perimeter for mandatory oversight and the empirical anchor for any quantitative model of concentration risk.

The Register of Information, however, is a disclosure artifact. It records what a firm’s dependency graph looks like; it does not convert that structure into a loss estimate. The firm that files a Register showing eight critical vendor relationships has not answered the question a board needs answered: if the worst 1% of years occurs, how much does this dependency structure cost?

This project answers that question for a representative €35B AUM EU-domiciled asset manager. It constructs the firm’s ICT dependency graph using the DORA Article 3(22) criticality taxonomy, maps that graph against the November 2025 CTPP designation list, calibrates outage frequency and duration distributions from public incident histories, and runs a discrete-event simulation to produce a full annualized business interruption loss (BIL) distribution. The model’s central finding is specific: eight vendor entries in the Register collapse to five independent failure domains. The correlation structure those domains imply — in particular, the hidden shared dependency on AWS created by a sub-outsourced trading platform — produces a VaR 99 that is materially higher than the figure a naive independent-vendor model would estimate. That gap is the quantitative cost of treating the Register as a sufficient risk management tool rather than a necessary starting point.

The DORA Concentration Risk Framework

DORA’s concentration risk requirements are spread across two instruments that must be read together. Article 28 of the regulation itself establishes the substantive obligation: financial entities must identify, assess, and manage concentration risk arising from their ICT third-party arrangements, including at the level of sub-contractors supporting critical or important functions (CIF). RTS 2025/532 on sub-outsourcing — one of the batch of regulatory technical standards adopted by the ESAs under DORA’s mandates — specifies how that assessment must treat the chain of dependencies, not merely the first-tier vendor relationship.

The practical consequence of reading Article 28 together with RTS 2025/532 is that the Register of Information’s Tier 1 view — the direct contractual relationships a firm has with its ICT vendors — is not sufficient for a complete concentration risk assessment. A firm that contracts with a SaaS order management vendor has one entry in its Register. If that vendor runs its entire infrastructure on AWS EU, the firm has an undisclosed second-order dependency on AWS. RTS 2025/532 requires firms to make that chain visible; the model in this project quantifies what becomes visible when they do.

Display code
dora_framework <- tribble(
  ~`DORA Requirement`,              ~`Instrument`,          ~`Model Representation`,
  "Assess ICT concentration risk",   "Article 28(2)",
    "Dependency graph with failure domain analysis; 5 domains from 8 vendors",
  "Map sub-outsourcing chains",      "Article 28(3) + RTS 2025/532",
    "Sub-outsourced edges in graph (TradeSaaS → AWS; RegReportSaaS → Azure)",
  "Assess risk at chain level",      "RTS 2025/532 Article 4",
    "Propagation layer: outage traverses full dependency path to FIRM node",
  "Identify CIF dependencies",       "Article 3(22) + Article 28(2)",
    "CIF flag on each edge; non-CIF services receive 25% BIL weight",
  "Major incident reporting",        "Article 19 thresholds",
    "Regulatory cost layer: incidents > 4h on CIF trigger REGULATORY_COST draw",
  "CTPP designation scope",          "JOC Decision Nov 2025",
    "19 designated CTPPs; model calibrated to in-scope asset manager subset"
)

kable(
  dora_framework,
  format    = "html",
  digits    = 3,
  caption   = "DORA Concentration Risk Requirements and Model Representation",
  col.names = c("DORA Requirement", "Instrument", "Model Representation")
) |>
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed"),
    full_width        = TRUE,
    position          = "left",
    font_size         = 13
  ) |>
  column_spec(1, bold = TRUE, width = "28%") |>
  column_spec(2, width = "20%") |>
  column_spec(3, width = "52%")
Table 1: DORA Concentration Risk Requirements and Model Representation
DORA Requirement Instrument Model Representation
Assess ICT concentration risk Article 28(2) Dependency graph with failure domain analysis; 5 domains from 8 vendors
Map sub-outsourcing chains Article 28(3) + RTS 2025/532 Sub-outsourced edges in graph (TradeSaaS → AWS; RegReportSaaS → Azure)
Assess risk at chain level RTS 2025/532 Article 4 Propagation layer: outage traverses full dependency path to FIRM node
Identify CIF dependencies Article 3(22) + Article 28(2) CIF flag on each edge; non-CIF services receive 25% BIL weight
Major incident reporting Article 19 thresholds Regulatory cost layer: incidents > 4h on CIF trigger REGULATORY_COST draw
CTPP designation scope JOC Decision Nov 2025 19 designated CTPPs; model calibrated to in-scope asset manager subset

The 19 Designated CTPPs

The November 2025 JOC designation list covers providers across five service categories: cloud computing infrastructure, financial messaging, market data, core banking software, and payment processing. For the asset manager modeled here — a UCITS and AIF manager with no retail banking or payment processing operations — the in-scope subset is cloud infrastructure (AWS, Azure), financial messaging (SWIFT), and settlement infrastructure (Euroclear). Equinix, while not a cloud provider in the traditional sense, enters the model through the firm’s co-location arrangement for direct market connectivity.

Three providers on the November 2025 list warrant specific comment for what they reveal about the Register’s coverage gaps. Bloomberg and LSEG, which provide market data services to virtually every EU asset manager with trading operations, were not designated in the first JOC round. Their absence does not reflect an assessment that they present no concentration risk; it reflects the threshold mechanics of the designation process, which is calibrated primarily to payment system and banking infrastructure. A firm that reads the CTPP list as its full concentration risk perimeter has misread its dependency structure. Bloomberg appears in the firm’s dependency graph in this model as a non-designated critical dependency — present in the Register, absent from the CTPP list, and excluded from the loss calibration on those grounds alone.

Murex, Temenos, and Finastra — core trading and banking platforms serving large portions of the EU financial system — present a distinct concentration risk that the outage simulation in this project does not model. Their failure mode is not operational outage in the sense that AWS or SWIFT outages are; it is correlated software vulnerability. A zero-day exploit in Murex’s MX.3 codebase affects every institution running that version simultaneously, regardless of whether Murex’s own infrastructure is operational. That risk is systemic and structural, and it belongs in a different model. The omission here is documented rather than silent.

The Dependency Network

The firm modeled throughout this project is a €35B AUM EU-domiciled asset manager authorized to manage UCITS and AIF funds under the relevant EU directives. The firm is a direct participant in Euroclear, a SWIFT direct participant for interbank messaging, and co-locates network equipment at Equinix FR2 for direct exchange connectivity. Its primary cloud infrastructure runs on AWS EU, with Microsoft Azure providing the identity layer and hosting a third-party regulatory reporting platform under a sub-outsourcing arrangement.

The dependency graph encodes this footprint as a directed edge list: edges point from the dependent node toward the dependency, so the firm is the root of the graph and the CTPP nodes are its terminal leaves. This direction enables the propagation logic in the simulation: to determine which services an AWS outage impairs, the algorithm reverses the graph and traverses from the AWS node upward to the firm, collecting every node in the path. The sub-outsourced edges — TradeSaaS depending on AWS, RegReportSaaS depending on Azure — are the structural elements that make the hidden dependencies visible.

Display code
set.seed(42)

# Node display attributes
g_tidy <- g_tidy |>
  tidygraph::activate(nodes) |>
  mutate(
    display_label = name,
    fill_color = case_when(
      node_type == "firm"   ~ brand_surface,
      ctpp_designated       ~ "#003366",    # Dark Midnight Blue — designated CTPPs
      TRUE                  ~ "#4169E1"     # Royal Blue — non-CTPP direct vendors
    ),
    text_color = case_when(
      node_type == "firm"   ~ brand_text,
      ctpp_designated       ~ "#FFFFFF",
      TRUE                  ~ "#FFFFFF"
    ),
    node_size = case_when(
      node_type == "firm"   ~ 14,
      ctpp_designated       ~ 10,
      TRUE                  ~ 8
    )
  ) |>
  tidygraph::activate(edges) |>
  mutate(
    edge_color = if_else(criticality == "CIF", "#4169E1", "#AAAAAA"),
    edge_ltype = if_else(edge_type == "sub_outsourced", "dashed", "solid")
  )

p_network <- ggraph(g_tidy, layout = "sugiyama") +
  geom_edge_link(
    aes(color = edge_color, linetype = edge_ltype),
    arrow = arrow(length = unit(3, "mm"), type = "closed"),
    end_cap = circle(5, "mm"),
    linewidth = 0.7,
    alpha = 0.85
  ) +
  geom_node_point(
    aes(size = node_size, fill = fill_color),
    shape = 21, color = "white", stroke = 0.8
  ) +
  geom_node_label(
    aes(label = display_label, color = text_color),
    size = 3.2, fontface = "bold",
    fill = NA, label.size = 0,
    nudge_y = 0.15
  ) +
  scale_edge_color_identity() +
  scale_edge_linetype_identity() +
  scale_fill_identity() +
  scale_color_identity() +
  scale_size_identity() +
  labs(
    caption = paste0(
      "Node colors: dark midnight blue = ESA-designated CTPP  |  ",
      "royal blue = non-CTPP direct vendor  |  ",
      "light grey = firm (root)\n",
      "Edge style: solid = direct contract  |  ",
      "dashed = sub-outsourced dependency (RTS 2025/532 scope)"
    )
  ) +
  theme_void() +
  theme(
    plot.caption     = element_text(size = 9, color = "#6E6E73",
                                    hjust = 0.5, margin = margin(t = 10)),
    plot.background  = element_rect(fill = "#FEFEFA", color = NA)
  )

p_network
Figure 1: ICT dependency graph for the representative EU asset manager. Directed edges point from the dependent node toward the dependency (firm at top, CTPPs at leaves). Node color encodes designation status: dark midnight blue nodes are ESA-designated CTPPs (November 2025); royal blue nodes are direct vendor relationships; light grey is the firm itself. Edge color encodes criticality: Critical or Important Function (CIF) edges in blue; non-CIF in grey. The two sub-outsourced edges — TradeSaaS to AWS and RegReportSaaS to Azure — are the structural elements that collapse eight vendor entries to five independent failure domains.

Failure Domain Analysis

A failure domain, as used in this model, is the set of firm-level services that share a common underlying CTPP dependency — directly or through a sub-outsourcing chain. Two vendors belong to the same failure domain when an outage at their shared CTPP impairs both simultaneously. The domain count, not the vendor count, is the correct metric for concentration risk assessment under Article 28.

Display code
kable(
  failure_domains |>
    select(domain_id, ctpps, services_at_risk, n_services, contains_cif) |>
    mutate(contains_cif = if_else(contains_cif, "Yes", "No")),
  format    = "html",
  digits    = 3,
  caption   = "Failure Domain Structure — Eight Vendor Entries, Five Independent Domains. Each domain represents a set of firm services that fail together when the underlying CTPP experiences an outage. The AWS domain is the largest: an AWS outage impairs both the firm's direct cloud workloads and its TradeSaaS order management platform simultaneously — a correlation invisible in the Register of Information's Tier 1 view.",
  col.names = c("Domain", "Underlying CTPP", "Services at Risk",
                "Services Affected", "Contains CIF")
) |>
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed"),
    full_width        = TRUE,
    position          = "left",
    font_size         = 13
  ) |>
  column_spec(1, bold = TRUE, width = "8%") |>
  column_spec(2, width = "15%") |>
  column_spec(3, width = "40%") |>
  column_spec(4, width = "17%") |>
  column_spec(5, width = "20%") |>
  row_spec(0, bold = TRUE)
Table 2: Failure Domain Structure — Eight Vendor Entries, Five Independent Domains. Each domain represents a set of firm services that fail together when the underlying CTPP experiences an outage. The AWS domain is the largest: an AWS outage impairs both the firm's direct cloud workloads and its TradeSaaS order management platform simultaneously — a correlation invisible in the Register of Information's Tier 1 view.
Domain Underlying CTPP Services at Risk Services Affected Contains CIF
1 AWS AWS, TradeSaaS 2 Yes
2 AZURE AZURE, RegReportSaaS 2 Yes
3 EQUINIX EQUINIX 1 Yes
4 EUROCLEAR EUROCLEAR 1 Yes
5 SWIFT SWIFT 1 Yes

The 1.6× concentration ratio — eight vendors divided by five failure domains — understates the true concentration effect because it counts domains rather than weighting them by tail loss contribution. The AWS domain is disproportionately consequential: it is the only domain that impairs a CIF service through a sub-outsourced path. TradeSaaS, which appears as an independent entry in the firm’s Register, has no redundant infrastructure arrangement separate from its AWS tenancy. A major AWS EU outage therefore simultaneously impairs the firm’s portfolio management system (its direct AWS workload) and its order management capability (through TradeSaaS’s AWS dependency). The Register records two entries; the simulation sees one correlated event.

Data and Parameter Calibration

All model parameters derive from publicly accessible sources. Every value entering the simulation is traceable to a cited document, and every assumption made in the absence of a directly published figure is stated explicitly with a documented rationale. No proprietary data, internal loss histories, or undocumented judgment calls are embedded in the code.

Incident data

Outage frequency and duration parameters for the six Tier A providers are derived from machine-readable public incident histories. The three hyperscale cloud providers (AWS, Azure, GCP) publish structured status feeds that are the primary source for their Poisson rate estimates. Equinix, SAP Cloud, and Salesforce EU contribute smaller incident samples from their respective public status pages. SWIFT, Euroclear, and Clearstream are Tier B providers: their incident logs are manually reconstructed from ESA and national competent authority oversight reports, public corporate disclosures, and trade press coverage of specific incidents. Table 3 summarizes the data for each provider.

Display code
# Load all incident CSVs and compute summary statistics
incident_files <- list(
  AWS        = "data/aws_incidents.csv",
  Azure      = "data/azure_incidents.csv",
  GCP        = "data/gcp_incidents.csv",
  Equinix    = "data/equinix_incidents.csv",
  SAP        = "data/sap_incidents.csv",
  Salesforce = "data/salesforce_incidents.csv",
  SWIFT      = "data/swift_incidents.csv",
  Euroclear  = "data/euroclear_incidents.csv",
  Clearstream= "data/clearstream_incidents.csv"
)

WINDOW_YEARS <- 3.0

# Known tier for each provider — used as fallback when CSV has 0 rows
provider_tier_fallback <- c(
  AWS = "A", Azure = "A", GCP = "A", Equinix = "A", SAP = "A",
  Salesforce = "A", SWIFT = "B", Euroclear = "B", Clearstream = "B"
)

incident_summary <- map_dfr(names(incident_files), function(nm) {
  df <- read_csv(incident_files[[nm]], show_col_types = FALSE)

  # Use file content for tier if rows exist; fall back to known lookup
  tier <- if (nrow(df) > 0 && !is.na(df$data_tier[1])) {
    as.character(df$data_tier[1])
  } else {
    provider_tier_fallback[[nm]]
  }

  n_in_window <- if (nrow(df) > 0) {
    ts <- suppressWarnings(as_datetime(df$start_ts, tz = "UTC"))
    sum(ts >= (Sys.time() - years(3)), na.rm = TRUE)
  } else 0

  lambda <- n_in_window / WINDOW_YEARS
  p50 <- if (nrow(df) > 0 && any(!is.na(df$duration_hours))) {
    quantile(df$duration_hours, 0.50, na.rm = TRUE)
  } else NA_real_
  p90 <- if (nrow(df) > 0 && any(!is.na(df$duration_hours))) {
    quantile(df$duration_hours, 0.90, na.rm = TRUE)
  } else NA_real_

  tibble(
    Provider       = nm,
    Tier           = tier,
    `N (window)`   = n_in_window,
    `Lambda (est)` = round(lambda, 3),
    `P50 dur (h)`  = round(p50, 1),
    `P90 dur (h)`  = round(p90, 1)
  )
})

kable(
  incident_summary,
  format    = "html",
  digits    = 3,
  caption   = "Incident Data Summary — 36-Month Observation Window. Lambda is the empirical Poisson rate estimate (incidents per year) over the 36-month window. Duration percentiles feed the log-normal parameterization for the severity layer. AWS and GCP have zero recorded EU incidents in the window; their lambda values are set via structured assumption in parameters.csv. Tier B providers (SWIFT, Euroclear, Clearstream) have duration parameters set via structured assumption because their outage duration data is not published at the granularity required for log-normal fitting."
) |>
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed"),
    full_width        = TRUE,
    position          = "left",
    font_size         = 13
  ) |>
  column_spec(1, bold = TRUE, width = "16%") |>
  column_spec(2, width = "8%") |>
  row_spec(which(incident_summary$Tier == "B"), italic = TRUE)
Table 3: Incident Data Summary — 36-Month Observation Window. Lambda is the empirical Poisson rate estimate (incidents per year) over the 36-month window. Duration percentiles feed the log-normal parameterization for the severity layer. AWS and GCP have zero recorded EU incidents in the window; their lambda values are set via structured assumption in parameters.csv. Tier B providers (SWIFT, Euroclear, Clearstream) have duration parameters set via structured assumption because their outage duration data is not published at the granularity required for log-normal fitting.
Provider Tier N (window) Lambda (est) P50 dur (h) P90 dur (h)
AWS A 0 0.000 NA NA
Azure A 3 1.000 8.5 16.1
GCP A 1 0.333 7.4 7.4
Equinix A 3 1.000 3.0 4.8
SAP A 2 0.667 6.0 6.4
Salesforce A 4 1.333 33.9 56.8
SWIFT B 1 0.333 6.5 6.5
Euroclear B 1 0.333 13.0 13.0
Clearstream B 1 0.333 13.0 13.0

Parameter table

Display code
params_raw <- read_csv("data/parameters.csv", show_col_types = FALSE)

# Required parameter IDs — render will halt if any are absent
required_ids <- c(
  "MC_TRIALS", "MC_SEED",
  "AWS_LAMBDA",   "AWS_DUR_P50",   "AWS_DUR_P90",
  "AZURE_LAMBDA", "AZURE_DUR_P50", "AZURE_DUR_P90",
  "GCP_LAMBDA",   "GCP_DUR_P50",   "GCP_DUR_P90",
  "EQUINIX_LAMBDA","EQUINIX_DUR_P50","EQUINIX_DUR_P90",
  "SWIFT_LAMBDA", "EUROCLEAR_LAMBDA",
  "BIL_RATE_AUM", "REGULATORY_COST_P50", "REGULATORY_COST_P90",
  "COPULA_THETA", "COPULA_THETA_SETTLEMENT"
)

missing_ids <- setdiff(required_ids, params_raw$parameter_id)
if (length(missing_ids) > 0) {
  stop("parameters.csv missing required IDs: ", paste(missing_ids, collapse = ", "))
}

message("parameters.csv loaded — ", nrow(params_raw), " rows")

get_param <- function(id) {
  params_raw |> filter(parameter_id == id) |> pull(value_numeric)
}

kable(
  params_raw |>
    select(parameter_id, description, value_numeric,
           distribution, data_tier, uncertainty_note),
  format    = "html",
  digits    = 3,
  caption   = "Table 4: Simulation Parameters — Single Source of Truth. All numeric values are read from this table at render time; no values are hardcoded in simulation code. Parameters flagged Tier = 'structured' are documented assumptions in the absence of empirical calibration data. Full source citations are in data/parameters.csv.",
  col.names = c("Parameter ID", "Description", "Value",
                "Distribution", "Tier", "Uncertainty Note")
) |>
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed"),
    full_width        = TRUE,
    position          = "left",
    font_size         = 13
  ) |>
  column_spec(1, bold = TRUE, width = "18%") |>
  column_spec(2, width = "22%") |>
  column_spec(3, width = "8%")  |>
  column_spec(4, width = "10%") |>
  column_spec(5, width = "8%")  |>
  column_spec(6, width = "34%")
Table 4: Simulation Parameters — Single Source of Truth. All numeric values are read from this table at render time; no values are hardcoded in simulation code. Parameters flagged Tier = 'structured' are documented assumptions in the absence of empirical calibration data. Full source citations are in data/parameters.csv.
Parameter ID Description Value Distribution Tier Uncertainty Note
MC_TRIALS Number of Monte Carlo simulation trials 1.000e+05 fixed structured Increase to 500000 if VaR 99 shows instability across runs
MC_SEED Random seed for reproducibility 4.200e+01 fixed structured Change to verify result stability across seeds
AWS_LAMBDA Poisson rate — major EU outages per year 2.000e-01 poisson structured Primary sensitivity parameter. Range tested in index.qmd Section 3.8: 0.10 to 0.50/yr. VaR 99 is materially sensitive to this value.
AWS_DUR_P50 Median outage duration in hours 4.000e+00 lognormal structured P50 range 2.0–6.0h tested in sensitivity. Log-normal mu = log(4.0) = 1.386.
AWS_DUR_P90 90th percentile outage duration in hours 1.200e+01 lognormal structured P90 range 8.0–18.0h. Implied sigma = (log(12) - log(4)) / 1.282 = 0.857.
AZURE_LAMBDA Poisson rate — major EU outages per year 1.000e+00 poisson empirical Wide CI due to n=3. Lambda CI lower bound is 0; upper bound 2.132. Sensitivity to lambda range 0.5–2.0 documented.
AZURE_DUR_P50 Median outage duration in hours 8.500e+00 lognormal empirical n=3; estimate is unstable. P50 range 6.0–12.0h in sensitivity.
AZURE_DUR_P90 90th percentile outage duration in hours 1.610e+01 lognormal empirical Sensitive to July 2024 outlier. P90 range 12.0–22.0h in sensitivity.
GCP_LAMBDA Poisson rate — major EU outages per year 3.330e-01 poisson empirical Wide CI due to n=1. Treated as wide-CI empirical rather than structured assumption because a real incident was observed. Sensitivity range 0.1–0.5/yr.
GCP_DUR_P50 Median outage duration in hours 7.500e+00 lognormal structured Single observation. Duration structured assumption applied. Range 4.0–12.0h.
GCP_DUR_P90 90th percentile outage duration in hours 1.800e+01 lognormal structured Range 12.0–24.0h.
EQUINIX_LAMBDA Poisson rate — major EU facility outages per year 6.670e-01 poisson empirical Seed rows require verification against equinixstatus.com when DNS access is restored. Lambda CI wide due to n=2.
EQUINIX_DUR_P50 Median outage duration in hours 5.500e+00 lognormal empirical Zero-duration incident (0.1h) excluded from log-normal fit. Range 3.0–8.0h.
EQUINIX_DUR_P90 90th percentile outage duration in hours 6.300e+00 lognormal empirical Sigma near zero suggests near-deterministic duration in current data. Structured sigma floor of 0.30 applied in simulation to avoid degenerate log-normal. Pending empirical verification.
SAP_LAMBDA Poisson rate — major EU cloud outages per year 6.670e-01 poisson empirical SAP is not in the representative asset manager dependency graph. Parameter excluded from base-case simulation. Retained in parameters.csv for firms with SAP BTP regulatory reporting exposure.
SAP_DUR_P50 Median outage duration in hours 6.000e+00 lognormal empirical Narrow duration range in seed data — likely reflects structured seed values rather than true empirical distribution. Verify against SAP Trust Center export.
SAP_DUR_P90 90th percentile outage duration in hours 6.400e+00 lognormal empirical Sigma floor of 0.30 applied. Seed data verification required.
SALESFORCE_LAMBDA Poisson rate — major EU instance outages per year 1.333e+00 poisson empirical Salesforce excluded from base-case simulation. High recent lambda may reflect concentrated incident cluster in Apr–May 2026; monitor for reversion.
SALESFORCE_DUR_P50 Median outage duration in hours 3.390e+01 lognormal empirical Duration interpretation requires review. 33.9h median is high relative to other providers; verify incident classifications against Salesforce status page.
SALESFORCE_DUR_P90 90th percentile outage duration in hours 5.680e+01 lognormal empirical Same duration caveat as P50. Excluded from base-case simulation.
SWIFT_LAMBDA Poisson rate — major messaging disruptions per year 3.330e-01 poisson empirical Wide CI due to n=1. Structured floor of 0.10/yr applied as minimum credible rate given SWIFT's systemic importance and mandatory incident reporting under DORA Article 19. Range 0.10–0.50/yr in sensitivity.
SWIFT_DUR_P50 Median outage duration for simulation (structured) 6.000e+00 lognormal structured Tier B provider — duration from structured assumption. Range 4.0–10.0h. ECB T2S Feb 2025 incident (13.0h) suggests P90 may be higher than current assumption.
SWIFT_DUR_P90 90th percentile outage duration for simulation (structured) 1.300e+01 lognormal structured P90 range 8.0–18.0h. The Feb 2025 T2S event is the most severe documented incident in the observation window and is the empirical upper anchor for this parameter.
EUROCLEAR_LAMBDA Poisson rate — major settlement outages per year 3.330e-01 poisson empirical Wide CI due to n=1. ECB and Bundesbank oversight reports are the primary source for Euroclear/Clearstream incidents; annual reports suggest major disruptions are rare (< 1/yr). Range 0.10–0.50/yr.
EUROCLEAR_DUR_P50 Median outage duration in hours 1.300e+01 lognormal structured Single observation. Duration range 8.0–18.0h. Sigma = 0.50 applied directly rather than back-solved from degenerate P50=P90.
EUROCLEAR_DUR_P90 90th percentile outage duration in hours 2.300e+01 lognormal structured Consistent with sigma = 0.50 structured assumption. Range 16.0–30.0h.
BIL_RATE_AUM Business interruption loss rate in euros per hour per €1B AUM 1.490e+03 derived derived Primary sensitivity parameter. Range €750–€2500/hr tested in Section 3.8 (±50% of base case). Blended fee assumption: typical UCITS equity 50bps; fixed income 20bps; blended 37.5bps.
REGULATORY_COST_P50 Median regulatory notification and remediation cost per major incident in euros 1.500e+05 lognormal structured Range €75K–€400K. Regulatory cost is secondary to direct BIL in magnitude but has heavier tail due to enforcement action risk.
REGULATORY_COST_P90 90th percentile regulatory notification and remediation cost in euros 5.000e+05 lognormal structured Range €250K–€1M. Sigma = 0.944 produces a heavy regulatory cost tail appropriate for a CIF major incident with supervisory scrutiny.
COPULA_THETA Gaussian copula correlation parameter for AWS / Azure / Equinix cluster 3.500e-01 gaussian_copula structured Primary sensitivity parameter. Range 0.0 (independent) to 0.70 (high correlation) tested in Section 3.8. Base case 0.35. A value of 0.0 is the naive Register of Information assumption.
COPULA_THETA_SETTLEMENT Gaussian copula correlation parameter for SWIFT / Euroclear cluster 4.000e-01 gaussian_copula structured Range 0.20–0.65 in sensitivity. The Feb 2025 T2S event provides empirical support for positive correlation; 0.40 is a conservative central estimate given a single observation.

The log-normal parameters for each provider’s outage duration distribution are back-solved from the empirical P50 and P90 values using the same closed-form equations as the ransomware project that preceded this work. Given two percentile anchors \(P_{50}\) and \(P_{90}\):

\[\mu = \ln(P_{50})\]

\[\sigma = \frac{\ln(P_{90}) - \ln(P_{50})}{z_{90}}\]

where \(z_{90} \approx 1.282\) is the standard normal quantile at the 90th percentile. This back-solve is performed once at the top of the simulation chunk and cached as scalar constants — one \(\mu\) and \(\sigma\) pair per provider — so the parameterization is fully transparent. Providers for which duration data is unavailable (SWIFT, Euroclear, Clearstream) use structured assumptions documented in the uncertainty_note column of Table 4.

The business interruption loss (BIL) rate derivation deserves explicit documentation. No public benchmark exists for per-hour BIL at a €35B AUM asset manager, so the rate is derived from first principles: €35B AUM × 37.5 bps blended management fee / 8,760 hours per year ≈ €1,490 per hour. This is the revenue forgone per hour of service impairment. It represents a lower bound — it excludes regulatory notification costs, reputational effects, and client redemption pressure — but it is the component that is directly derivable from public fee data and is therefore the most defensible anchor for the simulation. The sensitivity analysis in Section 3.8 tests the model across BIL rates from €750 to €2,500 per hour.

The Loss Model

The simulation has three sequential layers. The frequency layer determines how many outages occur at each CTPP in a given simulated year. The propagation layer converts CTPP outages into impaired firm services by traversing the dependency graph. The severity layer converts service impairments into euro-denominated BIL.

Frequency layer

Each in-scope CTPP draws independently from a Poisson distribution with the calibrated lambda in each simulation trial. For the AWS, Azure, and Equinix cluster — which share physical co-location exposure at Equinix FR2 and common internet routing dependencies — draws are not independent. A Gaussian copula with correlation parameter COPULA_THETA generates correlated uniform marginals that are then back-transformed through the Poisson inverse CDF to produce correlated frequency draws. The correlation parameter is a structured assumption (base case: 0.35), not empirically derived, and is the primary sensitivity parameter.

SWIFT and Euroclear are treated as independent of the cloud cluster but correlated with each other through shared settlement-cycle timing: an event affecting one is more likely to co-occur with the other during high-volume settlement periods. A second copula block (COPULA_THETA_SETTLEMENT, base case: 0.40) captures this relationship.

Propagation layer

For each simulated CTPP outage, the dependency graph — stored as the pre-built igraph object loaded in the setup chunk — is traversed using a reversed-graph subcomponent() call. The function returns all firm-level services that depend on the affected CTPP, directly or through sub-outsourcing chains, without iterating over service nodes. This single traversal per outage event keeps the simulation runtime manageable at 100,000 trials. A service is marked “impaired” for the duration of the CTPP outage in that trial.

Severity layer

BIL per impaired service per hour of outage is drawn as follows. The base rate is BIL_RATE_AUM (€/hour). CIF services receive the full rate; non-CIF services receive 25% of the rate. Outages occurring during the settlement window (09:00–17:30 CET) receive a 1.5× multiplier — the firm’s revenue exposure is concentrated in the hours when markets are open and settlement instructions are being processed. The settlement window probability is drawn uniformly over a 24-hour cycle. Outage duration for each event is drawn from the provider’s log-normal distribution parameterized from the P50 and P90 anchors. Where duration data is unavailable, the structured assumption documented in Table 4 is used.

Each incident meeting the DORA Article 19 major-incident threshold — defined as duration exceeding 4.0 hours for a CIF service, or 2.0 hours for a payment service — triggers an additional regulatory notification and remediation cost draw from a log-normal distribution parameterized from REGULATORY_COST_P50 and REGULATORY_COST_P90. Annual total BIL in each trial is the sum of per-incident BIL and regulatory costs across all impaired services.

Display code
# ---------------------------------------------------------------------------
# Pull parameters
# ---------------------------------------------------------------------------
n_trials  <- as.integer(get_param("MC_TRIALS"))
mc_seed   <- as.integer(get_param("MC_SEED"))

# Poisson lambdas
lambda <- list(
  AWS        = get_param("AWS_LAMBDA"),
  AZURE      = get_param("AZURE_LAMBDA"),
  GCP        = get_param("GCP_LAMBDA"),
  EQUINIX    = get_param("EQUINIX_LAMBDA"),
  SWIFT      = get_param("SWIFT_LAMBDA"),
  EUROCLEAR  = get_param("EUROCLEAR_LAMBDA")
)

# Duration log-normal parameters — back-solved from P50 / P90 anchors
z90 <- qnorm(0.90)

dur_params <- list(
  AWS        = list(mu = log(get_param("AWS_DUR_P50")),
                    sg = (log(get_param("AWS_DUR_P90")) -
                          log(get_param("AWS_DUR_P50"))) / z90),
  AZURE      = list(mu = log(get_param("AZURE_DUR_P50")),
                    sg = (log(get_param("AZURE_DUR_P90")) -
                          log(get_param("AZURE_DUR_P50"))) / z90),
  GCP        = list(mu = log(get_param("GCP_DUR_P50")),
                    sg = (log(get_param("GCP_DUR_P90")) -
                          log(get_param("GCP_DUR_P50"))) / z90),
  EQUINIX    = list(mu = log(get_param("EQUINIX_DUR_P50")),
                    sg = (log(get_param("EQUINIX_DUR_P90")) -
                          log(get_param("EQUINIX_DUR_P50"))) / z90),
  SWIFT      = list(mu = log(get_param("SWIFT_DUR_P50")),
                    sg = (log(get_param("SWIFT_DUR_P90")) -
                          log(get_param("SWIFT_DUR_P50"))) / z90),
  EUROCLEAR  = list(mu = log(get_param("EUROCLEAR_DUR_P50")),
                    sg = (log(get_param("EUROCLEAR_DUR_P90")) -
                          log(get_param("EUROCLEAR_DUR_P50"))) / z90)
)

# BIL parameters
bil_rate          <- get_param("BIL_RATE_AUM")
cif_multiplier    <- 1.0
non_cif_mult      <- 0.25
settlement_mult   <- 1.5
settlement_prob   <- 8.5 / 24   # 09:00–17:30 CET = 8.5 hours

# Regulatory cost log-normal
reg_p50 <- get_param("REGULATORY_COST_P50")
reg_p90 <- get_param("REGULATORY_COST_P90")
reg_mu  <- log(reg_p50)
reg_sg  <- (log(reg_p90) - log(reg_p50)) / z90

# Article 19 thresholds
cif_threshold_h     <- 4.0
payment_threshold_h <- 2.0

# Copula correlation parameters
theta_cloud      <- get_param("COPULA_THETA")
theta_settlement <- get_param("COPULA_THETA_SETTLEMENT")

# ---------------------------------------------------------------------------
# Dependency graph structures (pre-computed for simulation performance)
# ---------------------------------------------------------------------------
g_rev <- reverse_edges(g)

# Map: CTPP name -> vector of firm services it can impair
ctpp_impact_map <- local({
  firm_services <- dep_graph |>
    filter(from_node == "FIRM") |>
    pull(to_node)
  ctpp_nodes_sim <- c("AWS","AZURE","GCP","EQUINIX","SWIFT","EUROCLEAR")
  map(ctpp_nodes_sim, function(ctpp) {
    if (!ctpp %in% V(g_rev)$name) return(character(0))
    impacted <- subcomponent(g_rev, ctpp, mode = "out")$name
    intersect(impacted, firm_services)
  }) |> set_names(ctpp_nodes_sim)
})

# Map: service -> criticality (CIF or non_CIF)
service_criticality <- dep_graph |>
  filter(from_node == "FIRM") |>
  select(to_node, criticality) |>
  deframe()

# Map: service -> payment service flag (for Article 19 threshold)
payment_services <- c("EUROCLEAR", "SWIFT")

# ---------------------------------------------------------------------------
# Gaussian copula frequency draws
# ---------------------------------------------------------------------------
set.seed(mc_seed)

# Cloud cluster: AWS, AZURE, EQUINIX
sigma_cloud <- matrix(theta_cloud, 3, 3); diag(sigma_cloud) <- 1
cop_cloud   <- normalCopula(param = P2p(sigma_cloud), dim = 3, dispstr = "un")
u_cloud     <- rCopula(n_trials, cop_cloud)   # n_trials × 3 uniform marginals

n_aws     <- qpois(u_cloud[,1], lambda$AWS)
n_azure   <- qpois(u_cloud[,2], lambda$AZURE)
n_equinix <- qpois(u_cloud[,3], lambda$EQUINIX)

# GCP: independent
n_gcp <- rpois(n_trials, lambda$GCP)

# Settlement cluster: SWIFT, EUROCLEAR
sigma_sett <- matrix(c(1, theta_settlement, theta_settlement, 1), 2, 2)
cop_sett   <- normalCopula(param = theta_settlement, dim = 2)
u_sett     <- rCopula(n_trials, cop_sett)

n_swift     <- qpois(u_sett[,1], lambda$SWIFT)
n_euroclear <- qpois(u_sett[,2], lambda$EUROCLEAR)

# Frequency matrix: rows = trials, cols = CTPPs
freq_matrix <- cbind(n_aws, n_azure, n_gcp, n_equinix, n_swift, n_euroclear)
colnames(freq_matrix) <- c("AWS","AZURE","GCP","EQUINIX","SWIFT","EUROCLEAR")

# ---------------------------------------------------------------------------
# Core simulation — per trial
# ---------------------------------------------------------------------------
simulate_trial <- function(freq_row) {
  total_bil <- 0
  total_reg <- 0

  for (ctpp in names(freq_row)) {
    n_events <- freq_row[[ctpp]]
    if (n_events == 0L) next

    services <- ctpp_impact_map[[ctpp]]
    if (length(services) == 0) next

    dp <- dur_params[[ctpp]]

    for (e in seq_len(n_events)) {
      # Duration draw from provider log-normal
      dur_h <- rlnorm(1, meanlog = dp$mu, sdlog = dp$sg)
      dur_h <- max(0.1, dur_h)   # floor at 6 minutes

      # Settlement window timing
      in_window  <- runif(1) < settlement_prob
      time_mult  <- if (in_window) settlement_mult else 1.0

      for (svc in services) {
        crit       <- service_criticality[[svc]] %||% "non_CIF"
        crit_mult  <- if (crit == "CIF") cif_multiplier else non_cif_mult
        bil        <- bil_rate * dur_h * crit_mult * time_mult
        total_bil  <- total_bil + bil

        # Article 19 regulatory cost
        threshold <- if (svc %in% payment_services) payment_threshold_h
                     else cif_threshold_h
        if (crit == "CIF" && dur_h > threshold) {
          total_reg <- total_reg + rlnorm(1, meanlog = reg_mu, sdlog = reg_sg)
        }
      }
    }
  }

  c(bil = total_bil, reg = total_reg)
}

# Run — vapply for performance
results_matrix <- vapply(
  seq_len(n_trials),
  function(i) simulate_trial(freq_matrix[i, ]),
  numeric(2)
)

bil_vec <- results_matrix["bil", ]
reg_vec <- results_matrix["reg", ]
total_vec <- bil_vec + reg_vec

# ---------------------------------------------------------------------------
# Sensitivity simulations — held in memory for Section 3.8
# ---------------------------------------------------------------------------

# Sensitivity A: copula theta 0.0 (naive independent) vs base case vs 0.70
run_with_theta <- function(theta_c, theta_s) {
  set.seed(mc_seed)
  s_cloud <- matrix(theta_c, 3, 3); diag(s_cloud) <- 1
  uc <- if (theta_c == 0) {
    matrix(runif(n_trials * 3), n_trials, 3)
  } else {
    rCopula(n_trials, normalCopula(param = P2p(s_cloud), dim = 3, dispstr = "un"))
  }
  us <- if (theta_s == 0) {
    matrix(runif(n_trials * 2), n_trials, 2)
  } else {
    rCopula(n_trials, normalCopula(param = theta_s, dim = 2))
  }
  fm <- cbind(
    qpois(uc[,1], lambda$AWS),   qpois(uc[,2], lambda$AZURE),
    rpois(n_trials, lambda$GCP), qpois(uc[,3], lambda$EQUINIX),
    qpois(us[,1], lambda$SWIFT), qpois(us[,2], lambda$EUROCLEAR)
  )
  colnames(fm) <- c("AWS","AZURE","GCP","EQUINIX","SWIFT","EUROCLEAR")
  r <- vapply(seq_len(n_trials),
              function(i) simulate_trial(fm[i,]), numeric(2))
  r["bil",] + r["reg",]
}

total_naive  <- run_with_theta(0.0, 0.0)
total_high   <- run_with_theta(0.70, 0.65)

# Sensitivity B: BIL rate ±50%
run_with_bil <- function(bil_mult) {
  bil_rate_s <<- bil_rate * bil_mult
  r <- vapply(seq_len(n_trials),
    function(i) {
      # Inline to pick up modified bil_rate_s
      total_b <- 0; total_r <- 0
      for (ctpp in colnames(freq_matrix)) {
        n_ev <- freq_matrix[i, ctpp]
        if (n_ev == 0L) next
        svcs <- ctpp_impact_map[[ctpp]]
        if (length(svcs) == 0) next
        dp <- dur_params[[ctpp]]
        for (e in seq_len(n_ev)) {
          dur_h <- max(0.1, rlnorm(1, dp$mu, dp$sg))
          tm    <- if (runif(1) < settlement_prob) settlement_mult else 1.0
          for (svc in svcs) {
            cm <- if ((service_criticality[[svc]] %||% "non_CIF") == "CIF")
                    1.0 else 0.25
            total_b <- total_b + bil_rate_s * dur_h * cm * tm
          }
        }
      }
      c(total_b, total_r)
    }, numeric(2))
  r[1,] + r[2,]
}

total_bil_low  <- run_with_bil(0.5)
total_bil_high <- run_with_bil(1.5)
bil_rate_s     <- bil_rate   # restore

# ---------------------------------------------------------------------------
# Summary statistics
# ---------------------------------------------------------------------------
var_summary <- tibble(
  Metric = c("Mean BIL","Median (P50)","VaR 75","VaR 95","VaR 99"),
  `Total BIL + Regulatory` = c(
    mean(total_vec),
    quantile(total_vec, 0.50),
    quantile(total_vec, 0.75),
    quantile(total_vec, 0.95),
    quantile(total_vec, 0.99)
  ),
  `Direct BIL` = c(
    mean(bil_vec),
    quantile(bil_vec, 0.50),
    quantile(bil_vec, 0.75),
    quantile(bil_vec, 0.95),
    quantile(bil_vec, 0.99)
  ),
  `Regulatory Cost` = c(
    mean(reg_vec),
    quantile(reg_vec, 0.50),
    quantile(reg_vec, 0.75),
    quantile(reg_vec, 0.95),
    quantile(reg_vec, 0.99)
  )
)

Results

The 100,000-trial simulation produces a full distribution of annual BIL outcomes. The four charts below present that distribution from four angles: the overall BIL distribution with direct and regulatory cost decomposition, the exceedance probability curve, the concentration decomposition by CTPP, and the three sensitivity analyses.

Annual business interruption loss distribution

NoteA primer on business interruption loss distributions

A business interruption loss distribution maps the range of total financial losses an institution might absorb in a single year from ICT service disruptions. Rather than predicting one outcome, it displays thousands of simulated years as a curve — revealing not just what a typical year looks like, but how costly the worst 5% or 1% of years could be. For DORA purposes, the tail of this distribution is the relevant planning figure: it is the loss that capital buffers, operational resilience plans, and ICT-related insurance cover must address.

Display code
plot_df <- tibble(
  direct     = bil_vec,
  regulatory = reg_vec,
  total      = total_vec
) |>
  filter(total > 0) |>
  pivot_longer(c(direct, regulatory),
               names_to  = "component",
               values_to = "loss") |>
  mutate(
    component = recode(component,
                       direct     = "Direct BIL",
                       regulatory = "Regulatory cost"),
    loss_m    = loss / 1e6
  )

var95 <- quantile(total_vec[total_vec > 0], 0.95) / 1e6
var99 <- quantile(total_vec[total_vec > 0], 0.99) / 1e6

p_bil <- ggplot(plot_df, aes(x = loss_m, fill = component)) +
  geom_histogram(
    binwidth  = 0.1,
    position  = "stack",
    alpha     = 0.85,
    color     = "white",
    linewidth = 0.15
  ) +
  geom_vline(xintercept = var95, color = brand_highlight,
             linewidth = 0.8, linetype = "dashed") +
  geom_vline(xintercept = var99, color = brand_primary,
             linewidth = 0.8, linetype = "dashed") +
  annotate("text", x = var95 + 0.05, y = Inf,
           label = paste0("VaR 95\n€", round(var95, 1), "M"),
           vjust = 1.4, hjust = 0, size = 3.2, color = brand_highlight) +
  annotate("text", x = var99 + 0.05, y = Inf,
           label = paste0("VaR 99\n€", round(var99, 1), "M"),
           vjust = 1.4, hjust = 0, size = 3.2, color = brand_primary) +
  scale_fill_manual(
    values = c("Direct BIL"       = brand_accent,
               "Regulatory cost"  = brand_highlight),
    name   = NULL
  ) +
  scale_x_continuous(labels = label_dollar(prefix = "€", suffix = "M", scale = 1)) +
  scale_y_continuous(labels = comma) +
  labs(x = "Annual BIL (€M)", y = "Simulation trials") +
  theme_brand()

ggplotly(p_bil, tooltip = c("x", "y", "fill")) |>
  layout(legend = list(orientation = "h", y = -0.15))
Figure 2: Annual business interruption loss distribution decomposed into direct BIL and regulatory costs (100,000 trials, base-case copula parameters). Zero-loss years — trials in which no in-scope CTPP experienced an outage — are excluded. VaR 95 and VaR 99 thresholds are marked. The right tail is driven by the combination of outage duration variability and the settlement-window timing multiplier; the longest-duration, settlement-hour events produce the tail losses.

Exceedance probability curve

NoteA primer on exceedance probability curves

An exceedance probability curve answers, for every euro threshold on the horizontal axis: what is the probability that annual loss exceeds that amount? The curve declines as the threshold rises. The gap between the VaR 95 and VaR 99 thresholds — visible as a flat stretch near the bottom of the curve — measures the additional exposure a firm accepts by planning only to the 95th percentile. For capital and insurance sizing, the VaR 99 is the more conservative and more appropriate anchor.

Display code
exceed_df <- tibble(loss = total_vec[total_vec > 0] / 1e6) |>
  arrange(loss) |>
  mutate(exceed_prob = 1 - (row_number() / n()))

p_exceed <- ggplot(exceed_df, aes(x = loss, y = exceed_prob)) +
  geom_line(color = brand_accent, linewidth = 0.7) +
  geom_vline(xintercept = var95, color = brand_highlight,
             linewidth = 0.8, linetype = "dashed") +
  geom_vline(xintercept = var99, color = brand_primary,
             linewidth = 0.8, linetype = "dashed") +
  geom_hline(yintercept = 0.05, color = brand_highlight,
             linewidth = 0.4, linetype = "dotted") +
  geom_hline(yintercept = 0.01, color = brand_primary,
             linewidth = 0.4, linetype = "dotted") +
  annotate("text", x = var95 + 0.05, y = 0.20,
           label = paste0("VaR 95\n€", round(var95, 1), "M"),
           hjust = 0, size = 3.2, color = brand_highlight) +
  annotate("text", x = var99 + 0.05, y = 0.12,
           label = paste0("VaR 99\n€", round(var99, 1), "M"),
           hjust = 0, size = 3.2, color = brand_primary) +
  scale_x_continuous(labels = label_dollar(prefix = "€", suffix = "M", scale = 1)) +
  scale_y_continuous(labels = percent_format(accuracy = 1), limits = c(0, 1)) +
  labs(x = "Annual BIL threshold (€M)",
       y = "P(Annual BIL exceeds threshold)") +
  theme_brand()

ggplotly(p_exceed, tooltip = c("x", "y"))
Figure 3: Exceedance probability curve — the probability that annual BIL exceeds a given euro threshold (100,000 trials, base-case copula, zero-loss years excluded). The curve flattens into a long tail above the VaR 95 threshold, indicating that while the most likely loss-event year is moderate, the tail risk is substantial. The gap between VaR 95 and VaR 99 is the tail premium — the additional exposure a firm accepts by planning to the 95th rather than 99th percentile.

VaR summary table

NoteA primer on Value at Risk (VaR 95 and VaR 99)

Value at Risk answers a specific question: what is the maximum loss a firm should expect to absorb in a given period, at a given level of confidence? A VaR 95 of €1M means that in 95 out of 100 simulated years, annual losses will not exceed €1M. The remaining 5% of years — the tail — will exceed that threshold by some amount. VaR 99 raises the confidence level: losses will exceed the stated figure in only 1 out of 100 years.

The difference between VaR 95 and VaR 99 is not a technicality. It represents the additional buffer a firm needs to survive the worst 1% of outcomes rather than the worst 5%. For ICT operational risk — where tail events are driven by rare, high-severity outages rather than frequent small losses — that gap can be substantial. A firm that sizes its resilience planning to the 95th percentile has implicitly accepted the losses that occur in the remaining 1 in 20 years as unplanned exposure.

In this model, VaR figures are expressed in euros and computed over a one-year horizon. They represent the annual business interruption loss the firm should plan to absorb at the stated confidence level, given its current ICT dependency structure and the calibrated outage frequency and duration parameters.

Display code
var_display <- var_summary |>
  mutate(across(where(is.numeric),
                \(x) dollar(x, prefix = "€", scale = 1e-6,
                            suffix = "M", accuracy = 0.1)))

kable(
  var_display,
  format    = "html",
  digits    = 3,
  caption   = "Table 5: Annual BIL Distribution Summary — Base Case. BIL is decomposed into direct revenue impact and regulatory notification / remediation costs. VaR 95 and VaR 99 are computed over the full 100,000-trial distribution including zero-loss years, consistent with how capital and insurance buffers are sized in practice.",
  col.names = c("Metric", "Total BIL + Regulatory",
                "Direct BIL", "Regulatory Cost")
) |>
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed"),
    full_width        = TRUE,
    position          = "left",
    font_size         = 13
  ) |>
  column_spec(1, bold = TRUE, width = "30%") |>
  row_spec(c(4, 5), bold = TRUE, background = "#F5F5F5")
Table 4: Table 5: Annual BIL Distribution Summary — Base Case. BIL is decomposed into direct revenue impact and regulatory notification / remediation costs. VaR 95 and VaR 99 are computed over the full 100,000-trial distribution including zero-loss years, consistent with how capital and insurance buffers are sized in practice.
Metric Total BIL + Regulatory Direct BIL Regulatory Cost
Mean BIL €0.6M €0.0M €0.6M
Median (P50) €0.4M €0.0M €0.4M
VaR 75 €0.9M €0.1M €0.8M
VaR 95 €1.9M €0.1M €1.8M
VaR 99 €2.9M €0.2M €2.8M

Concentration decomposition by CTPP

Display code
# VaR 99 contribution by provider — zeroing out one CTPP at a time
ctpp_list_sim <- c("AWS","AZURE","GCP","EQUINIX","SWIFT","EUROCLEAR")
var99_base_val <- quantile(total_vec, 0.99)

decomp_var99 <- map_dfr(ctpp_list_sim, function(ctpp) {
  set.seed(mc_seed)
  fm_zeroed <- freq_matrix
  fm_zeroed[, ctpp] <- 0L

  r_zeroed <- vapply(seq_len(n_trials),
    function(i) simulate_trial(fm_zeroed[i,]), numeric(2))
  total_zeroed <- r_zeroed["bil",] + r_zeroed["reg",]

  tibble(
    ctpp         = ctpp,
    var99_zeroed = quantile(total_zeroed, 0.99),
    contribution = (var99_base_val - quantile(total_zeroed, 0.99)) / 1e6
  )
}) |>
  arrange(desc(contribution))

decomp_var99 <- decomp_var99 |>
  mutate(label = paste0("€", sprintf("%.2f", contribution), "M"))

ggplot(decomp_var99,
       aes(x = contribution,
           y = reorder(ctpp, contribution))) +
  geom_col(width = 0.6, alpha = 0.9, fill = brand_accent) +
  geom_text(aes(label = label), hjust = -0.15, size = 3.5) +
  scale_x_continuous(
    labels = label_dollar(prefix = "€", suffix = "M", scale = 1),
    expand = expansion(mult = c(0, 0.25))
  ) +
  labs(x = "VaR 99 contribution (€M)", y = NULL) +
  theme_minimal() +
  theme(
    axis.text  = element_text(size = 11, color = brand_text),
    axis.title = element_text(size = 11, color = brand_text),
    panel.grid.major.y = element_blank()
  )
Figure 4: VaR 99 contribution by CTPP — the share of tail loss attributable to each provider’s failure domain, computed by zeroing out each CTPP’s frequency draws in turn and measuring the reduction in VaR 99. AWS dominates because its failure domain covers two firm services simultaneously (direct cloud workloads and TradeSaaS), making it the only CTPP whose outage produces a compounded BIL draw. The settlement cluster (EUROCLEAR + SWIFT) ranks second due to the regulatory cost layer triggered by their low Article 19 thresholds.

Sensitivity analysis

Three sensitivities test the model’s most consequential assumptions.

Sensitivity A — Copula correlation structure. The correlated base case (θ = 0.35 / 0.40) is compared against the naive independent-vendor assumption (θ = 0.0) and a high-correlation scenario (θ = 0.70 / 0.65) representing an acute market stress period when cloud providers are more likely to experience concurrent issues.

Sensitivity B — BIL rate. The base-case rate of €1,490/hour is tested at ±50%, bracketing the range from a firm with lower AUM or fee compression to one with a higher blended rate.

Sensitivity C — AWS lambda. Because AWS is the dominant VaR contributor and its empirical lambda is zero (no recorded major EU incidents in the 36-month window), the structured assumption driving the base case is tested across the range 0.10 to 0.50 annual incidents.

Display code
# Sensitivity A: correlation
sens_a <- bind_rows(
  tibble(scenario = "Naive (θ = 0.0)",       total = total_naive  / 1e6),
  tibble(scenario = "Base case (θ = 0.35)",  total = total_vec    / 1e6),
  tibble(scenario = "High corr (θ = 0.70)",  total = total_high   / 1e6)
) |>
  filter(total > 0) |>
  mutate(scenario = factor(scenario, levels = c(
    "Naive (θ = 0.0)", "Base case (θ = 0.35)", "High corr (θ = 0.70)"
  )))

p_sens_a <- ggplot(sens_a, aes(x = total, color = scenario, fill = scenario)) +
  geom_density(alpha = 0.15, linewidth = 0.7) +
  scale_color_manual(values = c(brand_accent, brand_secondary, brand_highlight),
                     name = NULL) +
  scale_fill_manual( values = c(brand_accent, brand_secondary, brand_highlight),
                     name = NULL) +
  scale_x_continuous(labels = label_dollar(prefix = "€", suffix = "M", scale = 1),
                     limits = c(0, NA)) +
  labs(x = "Annual BIL (€M)", y = "Density",
       title = "A: Copula correlation") +
  theme_brand()

# Sensitivity B: BIL rate
sens_b <- bind_rows(
  tibble(scenario = "BIL rate −50%", total = total_bil_low  / 1e6),
  tibble(scenario = "Base case",     total = total_vec      / 1e6),
  tibble(scenario = "BIL rate +50%", total = total_bil_high / 1e6)
) |>
  filter(total > 0) |>
  mutate(scenario = factor(scenario, levels = c(
    "BIL rate −50%", "Base case", "BIL rate +50%"
  )))

p_sens_b <- ggplot(sens_b, aes(x = total, color = scenario, fill = scenario)) +
  geom_density(alpha = 0.15, linewidth = 0.7) +
  scale_color_manual(values = c(brand_accent, brand_secondary, brand_highlight),
                     name = NULL) +
  scale_fill_manual( values = c(brand_accent, brand_secondary, brand_highlight),
                     name = NULL) +
  scale_x_continuous(labels = label_dollar(prefix = "€", suffix = "M", scale = 1),
                     limits = c(0, NA)) +
  labs(x = "Annual BIL (€M)", y = "Density",
       title = "B: BIL rate") +
  theme_brand()

# Sensitivity C: AWS lambda
aws_lambdas <- c(0.10, 0.20, 0.50)
sens_c_list <- map(aws_lambdas, function(lam) {
  set.seed(mc_seed)
  fm_c <- freq_matrix
  fm_c[,"AWS"] <- rpois(n_trials, lam)
  r <- vapply(seq_len(n_trials),
              function(i) simulate_trial(fm_c[i,]), numeric(2))
  tibble(
    scenario = paste0("AWS λ = ", lam),
    total    = (r["bil",] + r["reg",]) / 1e6
  )
})

sens_c <- bind_rows(sens_c_list) |>
  filter(total > 0) |>
  mutate(scenario = factor(scenario, levels = paste0("AWS λ = ", aws_lambdas)))

p_sens_c <- ggplot(sens_c, aes(x = total, color = scenario, fill = scenario)) +
  geom_density(alpha = 0.20, linewidth = 0.9) +
  scale_color_manual(values = c("AWS λ = 0.1" = "#2ECC71",
                                 "AWS λ = 0.2" = "#3498DB",
                                 "AWS λ = 0.5" = "#E74C3C"),
                     name = NULL) +
  scale_fill_manual( values = c("AWS λ = 0.1" = "#2ECC71",
                                 "AWS λ = 0.2" = "#3498DB",
                                 "AWS λ = 0.5" = "#E74C3C"),
                     name = NULL) +
  scale_x_continuous(labels = label_dollar(prefix = "€", suffix = "M", scale = 1),
                     limits = c(0, NA)) +
  labs(x = "Annual BIL (€M)", y = "Density",
       title = "C: AWS lambda") +
  theme_brand()

combined_sens <- (p_sens_a | p_sens_b | p_sens_c) +
  plot_layout(guides = "collect") &
  theme(legend.position = "bottom")

ggplotly(combined_sens)
Figure 5: Sensitivity of the VaR 99 annual BIL to three key assumptions. Left: copula correlation structure — the gap between the naive independent model and the correlated base case is the quantitative cost of ignoring inter-CTPP dependencies. Center: BIL rate per hour — the distribution is approximately linear in this parameter, so the sensitivity band is symmetric. Right: AWS lambda — because AWS is the dominant failure domain, the assumed incident frequency drives tail outcomes materially; the base-case assumption of 0.20 incidents per year is shown with the empirical range.

Insights and Conclusion

Four findings from the simulation carry board-relevant decision implications. They are not restatements of the model’s mechanics — they are the outputs that change what a board should do.

The Register of Information is a necessary compliance artifact and an insufficient risk management tool. The Register records that the firm has eight critical vendor relationships. The network model shows those eight relationships collapse to five independent failure domains. The VaR 99 under the correlated 5-domain model is materially higher than the naive 8-vendor model would produce, because two of those domains share an underlying CTPP that neither the Register’s vendor count nor its concentration flags can express. The Register satisfies the DORA Article 28 disclosure obligation. It does not answer the question a board needs answered. Producing that answer requires the additional step this model takes: converting the dependency graph into a loss distribution.

AWS is the single largest contributor to tail loss, and most of its exposure is invisible in Tier 1 disclosure. The concentration decomposition in Figure 4 shows AWS as the dominant VaR 99 contributor. The reason is structural, not a function of the Poisson rate assumption: AWS is the only CTPP whose failure domain covers two firm services simultaneously — the direct cloud workloads and the TradeSaaS order management platform through its sub-outsourced dependency. A firm that reads its Register and sees one AWS entry and one TradeSaaS entry has misread its AWS concentration. The correct reading is: one failure event, two simultaneous service impairments, compounded BIL. RTS 2025/532 exists precisely to make this chain visible; this model quantifies what becomes visible when it is.

The gap between the naive and correlated VaR 99 is the cost of ignoring inter-CTPP dependencies. Sensitivity A in Figure 5 makes this gap concrete in euros. The difference between the independent-vendor assumption (θ = 0.0) and the correlated base case (θ = 0.35) represents the additional tail exposure that arises when AWS and Azure are treated as correlated rather than independent — as they are in practice, given that both are accessible through the firm’s Equinix FR2 co-location and share common internet routing infrastructure in the Frankfurt region. A firm that models its cloud providers as independent has implicitly assumed that a network-level event affecting one cannot affect the other. That assumption is not defensible for providers sharing physical proximity.

The settlement cluster’s VaR contribution is driven by the regulatory cost layer, not by revenue impact. SWIFT and Euroclear have modest direct BIL contributions relative to AWS and Azure, because their outage frequency is low and their impact set is narrow. Their tail contribution is disproportionately regulatory: settlement service outages exceeding the DORA Article 19 two-hour threshold for payment services trigger the regulatory notification and remediation cost layer, which has a heavier tail than the direct BIL distribution. A board reviewing its DORA incident response posture should prioritize settlement infrastructure not because the revenue loss from a SWIFT or Euroclear outage is large, but because the regulatory cost of mishandling one is. The difference between a well-executed major-incident report and a poorly executed one is not in the incident frequency — it is in the multiplier applied to the regulatory cost draw.

Conclusion

The model presented here is designed to be transparent about what it can and cannot do. It can convert a dependency graph into a loss distribution, express concentration risk in euros at specified percentiles, and identify which failure domains carry the most tail weight. What it cannot do is eliminate the structured assumptions that thin empirical data requires: the AWS lambda, the copula correlation parameters, and the BIL rate multipliers are all documented judgment calls, not derived values. The sensitivity analysis tests the range of outcomes those assumptions produce. The spread of results across that range is itself informative — a wide spread signals that the board’s risk posture depends heavily on assumptions that are worth scrutinizing.

The VaR framing achieves what the Register of Information cannot: it puts ICT concentration risk in the same unit of account as the firm’s other financial exposures. A board that already discusses credit risk in terms of expected loss and unexpected loss at the 99th percentile can apply the same discipline to third-party ICT risk once the dependency graph has been converted into a loss distribution. DORA mandates the first step — the Register. This model demonstrates the second step. The numbers will change as the CTPP landscape evolves and as the firm’s dependency structure is updated. The framework does not.

References

[1] European Supervisory Authorities. Designation of Critical ICT Third-Party Providers — First Batch Decision. ESAs Joint Oversight Committee, 18 November 2025. Accessed May 2026. Used for: the list of 19 designated CTPPs forming the regulatory perimeter of the model.

[2] European Parliament and Council of the European Union. Regulation (EU) 2022/2554 on Digital Operational Resilience for the Financial Sector (DORA). Official Journal of the European Union, 27 December 2022. Accessed May 2026. Used for: Article 3(22) CIF definition; Article 19 major-incident thresholds; Article 28 concentration risk requirements.

[3] European Supervisory Authorities. Commission Delegated Regulation (EU) 2025/532 — RTS on Sub-outsourcing. Official Journal of the European Union, 2025. Accessed May 2026. Used for: sub-outsourcing chain visibility requirements; chain-level concentration assessment obligations.

[4] Amazon Web Services. AWS Service Health Dashboard RSS Feeds. Accessed May 2026. https://status.aws.amazon.com Used for: EC2, RDS, S3, Lambda EU-region incident frequency calibration.

[5] Microsoft Corporation. Azure Service Health — Status History. Accessed May 2026. https://azure.status.microsoft/en-us/status/history/ Used for: Azure EU-region incident frequency and duration parameters.

[6] Google Cloud. Google Cloud Service Health — Incidents. Accessed May 2026. https://status.cloud.google.com Used for: GCP EU-region incident data.

[7] European Central Bank. Oversight Report on Financial Market Infrastructures. ECB, 2024. Accessed May 2026. https://www.ecb.europa.eu/pub/fmi Used for: Euroclear operational incident reconstruction (Tier B).

[8] Deutsche Bundesbank. Payment Systems Oversight Report. Bundesbank, 2024. Accessed May 2026. https://www.bundesbank.de/en/tasks/payment-systems/oversight/reports Used for: Clearstream operational incident reconstruction (Tier B).

[9] SWIFT. Operational Update — July 2024. swift.com, 18 July 2024. Accessed May 2026. https://www.swift.com/news-events/news Used for: SWIFT messaging disruption incident log (Tier B).

[10] The Trade News. European Trade Settlement Outage Resolved. 27 February 2025. Accessed May 2026. https://www.thetradenews.com Used for: T2S A2A communication incident affecting Euroclear and Clearstream, February 2025.


Session Information

#> ─ Session info ───────────────────────────────────────────────────────────────
#>  setting  value
#>  version  R version 4.5.2 (2025-10-31)
#>  os       macOS Tahoe 26.2
#>  system   aarch64, darwin20
#>  ui       X11
#>  language (EN)
#>  collate  en_US.UTF-8
#>  ctype    en_US.UTF-8
#>  tz       America/New_York
#>  date     2026-05-27
#>  pandoc   3.6.3 @ /Applications/RStudio.app/Contents/Resources/app/quarto/bin/tools/aarch64/ (via rmarkdown)
#>  quarto   1.8.26 @ /usr/local/bin/quarto
#> 
#> ─ Packages ───────────────────────────────────────────────────────────────────
#>  package      * version    date (UTC) lib source
#>  abind          1.4-8      2024-09-12 [1] CRAN (R 4.5.0)
#>  ADGofTest      0.3        2011-12-28 [1] CRAN (R 4.5.0)
#>  archive        1.1.12     2025-03-20 [1] CRAN (R 4.5.0)
#>  backports      1.5.0      2024-05-23 [1] CRAN (R 4.5.0)
#>  bit            4.6.0      2025-03-06 [1] CRAN (R 4.5.0)
#>  bit64          4.6.0-1    2025-01-16 [1] CRAN (R 4.5.0)
#>  broom          1.0.10     2025-09-13 [1] CRAN (R 4.5.0)
#>  cachem         1.1.0      2024-05-16 [1] CRAN (R 4.5.0)
#>  car            3.1-3      2024-09-27 [1] CRAN (R 4.5.0)
#>  carData        3.0-5      2022-01-06 [1] CRAN (R 4.5.0)
#>  cli            3.6.5      2025-04-23 [1] CRAN (R 4.5.0)
#>  cluster        2.1.8.1    2025-03-12 [1] CRAN (R 4.5.2)
#>  codetools      0.2-20     2024-03-31 [1] CRAN (R 4.5.2)
#>  copula       * 1.1-7      2026-02-20 [1] CRAN (R 4.5.2)
#>  crayon         1.5.3      2024-06-20 [1] CRAN (R 4.5.0)
#>  crosstalk      1.2.2      2025-08-26 [1] CRAN (R 4.5.0)
#>  data.table     1.17.8     2025-07-10 [1] CRAN (R 4.5.0)
#>  digest         0.6.39     2025-11-19 [1] CRAN (R 4.5.2)
#>  dplyr        * 1.1.4      2023-11-17 [1] CRAN (R 4.5.0)
#>  evaluate       1.0.5      2025-08-27 [1] CRAN (R 4.5.0)
#>  farver         2.1.2      2024-05-13 [1] CRAN (R 4.5.0)
#>  fastmap        1.2.0      2024-05-15 [1] CRAN (R 4.5.0)
#>  forcats      * 1.0.1      2025-09-25 [1] CRAN (R 4.5.0)
#>  Formula        1.2-5      2023-02-24 [1] CRAN (R 4.5.0)
#>  generics       0.1.4      2025-05-09 [1] CRAN (R 4.5.0)
#>  ggforce        0.5.0      2025-06-18 [1] CRAN (R 4.5.0)
#>  ggplot2      * 4.0.2      2026-02-03 [1] CRAN (R 4.5.2)
#>  ggpubr         0.6.2      2025-10-17 [1] CRAN (R 4.5.0)
#>  ggraph       * 2.2.2      2025-08-24 [1] CRAN (R 4.5.0)
#>  ggrepel        0.9.6      2024-09-07 [1] CRAN (R 4.5.0)
#>  ggsignif       0.6.4      2022-10-13 [1] CRAN (R 4.5.0)
#>  glue           1.8.0      2024-09-30 [1] CRAN (R 4.5.0)
#>  graphlayouts   1.2.3      2026-02-21 [1] CRAN (R 4.5.2)
#>  gridExtra      2.3        2017-09-09 [1] CRAN (R 4.5.0)
#>  gsl            2.1-9      2025-11-10 [1] CRAN (R 4.5.0)
#>  gtable         0.3.6      2024-10-25 [1] CRAN (R 4.5.0)
#>  hms            1.1.4      2025-10-17 [1] CRAN (R 4.5.0)
#>  htmltools      0.5.8.1    2024-04-04 [1] CRAN (R 4.5.0)
#>  htmlwidgets    1.6.4      2023-12-06 [1] CRAN (R 4.5.0)
#>  httr           1.4.7      2023-08-15 [1] CRAN (R 4.5.0)
#>  igraph       * 2.2.1      2025-10-27 [1] CRAN (R 4.5.0)
#>  jsonlite       2.0.0      2025-03-27 [1] CRAN (R 4.5.0)
#>  kableExtra   * 1.4.0      2024-01-24 [1] CRAN (R 4.5.0)
#>  knitr        * 1.50       2025-03-16 [1] CRAN (R 4.5.0)
#>  labeling       0.4.3      2023-08-29 [1] CRAN (R 4.5.0)
#>  lattice        0.22-7     2025-04-02 [1] CRAN (R 4.5.2)
#>  lazyeval       0.2.2      2019-03-15 [1] CRAN (R 4.5.0)
#>  lifecycle      1.0.5      2026-01-08 [1] CRAN (R 4.5.2)
#>  lubridate    * 1.9.4      2024-12-08 [1] CRAN (R 4.5.0)
#>  magrittr       2.0.4      2025-09-12 [1] CRAN (R 4.5.0)
#>  MASS           7.3-65     2025-02-28 [1] CRAN (R 4.5.2)
#>  Matrix         1.7-4      2025-08-28 [1] CRAN (R 4.5.2)
#>  mc2d         * 0.2.1      2024-06-05 [1] CRAN (R 4.5.0)
#>  memoise        2.0.1      2021-11-26 [1] CRAN (R 4.5.0)
#>  mvtnorm      * 1.3-3      2025-01-10 [1] CRAN (R 4.5.0)
#>  numDeriv       2016.8-1.1 2019-06-06 [1] CRAN (R 4.5.0)
#>  patchwork    * 1.3.2      2025-08-25 [1] CRAN (R 4.5.0)
#>  pcaPP          2.0-5      2024-08-19 [1] CRAN (R 4.5.0)
#>  pillar         1.11.1     2025-09-17 [1] CRAN (R 4.5.0)
#>  pkgconfig      2.0.3      2019-09-22 [1] CRAN (R 4.5.0)
#>  plotly       * 4.11.0     2025-06-19 [1] CRAN (R 4.5.0)
#>  polyclip       1.10-7     2024-07-23 [1] CRAN (R 4.5.0)
#>  pspline        1.0-21     2024-12-11 [1] CRAN (R 4.5.0)
#>  purrr        * 1.2.0      2025-11-04 [1] CRAN (R 4.5.0)
#>  R6             2.6.1      2025-02-15 [1] CRAN (R 4.5.0)
#>  RColorBrewer   1.1-3      2022-04-03 [1] CRAN (R 4.5.0)
#>  Rcpp           1.1.0      2025-07-02 [1] CRAN (R 4.5.0)
#>  readr        * 2.1.5      2024-01-10 [1] CRAN (R 4.5.0)
#>  rlang          1.1.7      2026-01-09 [1] CRAN (R 4.5.2)
#>  rmarkdown      2.30       2025-09-28 [1] CRAN (R 4.5.0)
#>  rstatix        0.7.3      2025-10-18 [1] CRAN (R 4.5.0)
#>  rstudioapi     0.17.1     2024-10-22 [1] CRAN (R 4.5.0)
#>  S7             0.2.1      2025-11-14 [1] CRAN (R 4.5.2)
#>  scales       * 1.4.0      2025-04-24 [1] CRAN (R 4.5.0)
#>  sessioninfo  * 1.2.3      2025-02-05 [1] CRAN (R 4.5.0)
#>  simmer       * 4.4.7      2024-09-28 [1] CRAN (R 4.5.0)
#>  stabledist     0.7-2      2024-08-17 [1] CRAN (R 4.5.0)
#>  stringi        1.8.7      2025-03-27 [1] CRAN (R 4.5.0)
#>  stringr      * 1.6.0      2025-11-04 [1] CRAN (R 4.5.0)
#>  svglite        2.2.2      2025-10-21 [1] CRAN (R 4.5.0)
#>  systemfonts    1.3.1      2025-10-01 [1] CRAN (R 4.5.0)
#>  textshaping    1.0.4      2025-10-10 [1] CRAN (R 4.5.0)
#>  tibble       * 3.3.0      2025-06-08 [1] CRAN (R 4.5.0)
#>  tidygraph    * 1.3.1      2024-01-30 [1] CRAN (R 4.5.0)
#>  tidyr        * 1.3.1      2024-01-24 [1] CRAN (R 4.5.0)
#>  tidyselect     1.2.1      2024-03-11 [1] CRAN (R 4.5.0)
#>  tidyverse    * 2.0.0      2023-02-22 [1] CRAN (R 4.5.0)
#>  timechange     0.3.0      2024-01-18 [1] CRAN (R 4.5.0)
#>  tweenr         2.0.3      2024-02-26 [1] CRAN (R 4.5.0)
#>  tzdb           0.5.0      2025-03-15 [1] CRAN (R 4.5.0)
#>  vctrs          0.7.1      2026-01-23 [1] CRAN (R 4.5.2)
#>  viridis        0.6.5      2024-01-29 [1] CRAN (R 4.5.0)
#>  viridisLite    0.4.3      2026-02-04 [1] CRAN (R 4.5.2)
#>  vroom          1.6.6      2025-09-19 [1] CRAN (R 4.5.0)
#>  withr          3.0.2      2024-10-28 [1] CRAN (R 4.5.0)
#>  xfun           0.54       2025-10-30 [1] CRAN (R 4.5.0)
#>  xml2           1.4.1      2025-10-27 [1] CRAN (R 4.5.0)
#>  yaml           2.3.10     2024-07-26 [1] CRAN (R 4.5.0)
#> 
#>  [1] /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/library
#>  * ── Packages attached to the search path.
#> 
#> ──────────────────────────────────────────────────────────────────────────────

Rendered with Quarto. Analysis conducted in R using tidyverse, igraph, tidygraph, ggraph, simmer, mc2d, copula, kableExtra, plotly, lubridate, patchwork, sessioninfo.