1 Introducción

1.1 Descripción del Proyecto

Este documento presenta el análisis exploratorio completo del Crypto EDA Dashboard, un proyecto académico que consume datos en tiempo real de la API de CryptoCompare para analizar el comportamiento histórico de 10 criptomonedas.

El análisis está orientado hacia la construcción de un modelo predictivo de precios basado en ARIMA, cubriendo desde la obtención y limpieza de datos hasta el ajuste, validación y predicción con series de tiempo.

Repositorio: https://github.com/Mateo3008/Crypto_EDA_App
Framework: R Shiny + shinydashboard
Fuente de datos: CryptoCompare API (datos diarios, ~1905 días por moneda)

1.2 Criptomonedas Analizadas

cryptos <- tibble(
  Nombre     = c("Bitcoin","Ethereum","USD Coin","Solana","XRP",
                 "Bittensor","Tether","Dogecoin","USD1","Zcash"),
  Símbolo    = c("BTC","ETH","USDC","SOL","XRP",
                 "TAO","USDT","DOGE","USD1","ZEC"),
  Categoría  = c("Store of Value","Smart Contract","Stablecoin","Smart Contract",
                 "Payments","AI / Subnet","Stablecoin","Meme","Stablecoin","Privacy")
)

kable(cryptos, caption = "Criptomonedas incluidas en el análisis",
      booktabs = TRUE) |>
  kable_styling(bootstrap_options = c("striped","hover","condensed"),
                full_width = FALSE)
Table 1.1: Table 1.2: Criptomonedas incluidas en el análisis
Nombre Símbolo Categoría
Bitcoin BTC Store of Value
Ethereum ETH Smart Contract
USD Coin USDC Stablecoin
Solana SOL Smart Contract
XRP XRP Payments
Bittensor TAO AI / Subnet
Tether USDT Stablecoin
Dogecoin DOGE Meme
USD1 USD1 Stablecoin
Zcash ZEC Privacy

1.3 Objetivo Académico

El objetivo principal es aplicar técnicas de Análisis Exploratorio de Datos (EDA) sobre series temporales financieras para:

  1. Caracterizar la distribución de precios y retornos de cada activo.
  2. Cuantificar el riesgo mediante métricas como la volatilidad rodante y el VaR.
  3. Identificar correlaciones entre criptomonedas.
  4. Evaluar la estacionariedad de las series y aplicar transformaciones necesarias.
  5. Ajustar modelos ARIMA(p, d, q) para la predicción de precios a corto plazo.

2 Configuración y Carga de Datos

2.1 Parámetros de la API

API_KEY  <- "ce6e922820dabbb917d5f6fd82b867726fbf320cf3f7414b33748c19e9514aae"
BASE_URL <- "https://min-api.cryptocompare.com/data"

CRYPTOS <- c(
  "Bitcoin"   = "BTC", "Ethereum"  = "ETH", "USD Coin"  = "USDC",
  "Solana"    = "SOL", "XRP"       = "XRP", "Bittensor" = "TAO",
  "Tether"    = "USDT","Dogecoin"  = "DOGE","USD1"      = "USD1",
  "Zcash"     = "ZEC"
)

COLOR_PALETTE <- c(
  "#2E86AB","#A23B72","#F18F01","#C73E1D","#6A994E",
  "#BC4A6C","#3D5A80","#EE6C4D","#98C1D9","#293241"
)
names(COLOR_PALETTE) <- CRYPTOS

2.2 Funciones de Extracción

2.2.1 Datos Históricos OHLCV

La función get_historical_daily() consulta el endpoint /v2/histoday de CryptoCompare y retorna un data.frame con columnas OHLCV, retornos simples, logarítmicos y volatilidad diaria.

get_historical_daily <- function(fsym, tsym = "USD", limit = 1905) {
  url <- paste0(BASE_URL, "/v2/histoday?fsym=", fsym, "&tsym=", tsym,
                "&limit=", limit, "&api_key=", API_KEY)
  url <- URLencode(url)

  tryCatch({
    resp <- GET(url, timeout(30))
    if (http_error(resp)) return(NULL)

    data <- fromJSON(content(resp, "text", encoding = "UTF-8"))
    if (is.null(data$Data$Data)) return(NULL)

    df <- as.data.frame(data$Data$Data) |>
      mutate(
        fecha      = as.Date(as.POSIXct(time, origin = "1970-01-01")),
        simbolo    = fsym,
        open       = as.numeric(open),
        high       = as.numeric(high),
        low        = as.numeric(low),
        close      = as.numeric(close),
        volume     = as.numeric(volumefrom),
        retorno    = (close - lag(close)) / lag(close) * 100,
        retorno_log = log(close / lag(close)) * 100,
        volatilidad = abs(high - low) / open * 100
      ) |>
      filter(!is.na(retorno))
    return(df)
  }, error = function(e) return(NULL))
}

2.2.2 Precios en Tiempo Real

get_price_overview <- function(fsyms, tsym = "USD") {
  fsyms_str <- paste(fsyms, collapse = ",")
  url <- paste0(BASE_URL, "/pricemultifull?fsyms=", fsyms_str,
                "&tsyms=", tsym, "&api_key=", API_KEY)
  url <- URLencode(url)

  tryCatch({
    resp <- GET(url, timeout(30))
    if (http_error(resp)) return(NULL)

    data <- fromJSON(content(resp, "text", encoding = "UTF-8"))
    if (is.null(data$RAW)) return(NULL)

    rows <- list()
    for (sym in fsyms) {
      if (!is.null(data$RAW[[sym]]) && !is.null(data$RAW[[sym]][[tsym]])) {
        d <- data$RAW[[sym]][[tsym]]
        rows[[sym]] <- data.frame(
          simbolo        = sym,
          precio         = d$PRICE,
          cambio_24h_pct = d$CHANGEPCT24HOUR,
          volumen_24h    = d$VOLUME24HOURTO,
          cap_mercado    = d$MKTCAP,
          stringsAsFactors = FALSE
        )
      }
    }
    if (length(rows) == 0) return(NULL)
    return(bind_rows(rows))
  }, error = function(e) return(NULL))
}

2.3 Descarga de Datos

cat("=== CARGANDO DATOS (10 monedas, 1905 días) ===\n")
## === CARGANDO DATOS (10 monedas, 1905 días) ===
hist_data <- NULL

for (crypto in CRYPTOS) {
  cat("Cargando", crypto, "... ")
  data <- get_historical_daily(crypto, limit = 1905)
  if (!is.null(data) && nrow(data) > 0) {
    hist_data <- bind_rows(hist_data, data)
    cat("OK (", nrow(data), "días)\n")
  } else {
    cat("FALLÓ — generando datos de ejemplo\n")
  }
  Sys.sleep(0.3)
}
## Cargando BTC ... OK ( 1905 días)
## Cargando ETH ... OK ( 1905 días)
## Cargando USDC ... OK ( 1905 días)
## Cargando SOL ... OK ( 1905 días)
## Cargando XRP ... OK ( 1905 días)
## Cargando TAO ... OK ( 702 días)
## Cargando USDT ... OK ( 1905 días)
## Cargando DOGE ... OK ( 1905 días)
## Cargando USD1 ... OK ( 326 días)
## Cargando ZEC ... OK ( 1905 días)
# Fallback: datos sintéticos si la API no responde
if (is.null(hist_data) || nrow(hist_data) == 0) {
  set.seed(123)
  fechas <- seq.Date(as.Date("2019-01-01"), as.Date("2024-04-10"), by = "day")

  for (sym in CRYPTOS) {
    precio_base <- switch(sym,
      "BTC"=50000,"ETH"=3000,"USDC"=1,"SOL"=100,"XRP"=0.5,
      "TAO"=300,"USDT"=1,"DOGE"=0.1,"USD1"=1,"ZEC"=30, 1000)

    trend <- seq(0, by=0.0002, length.out=length(fechas)) * precio_base
    noise <- cumsum(rnorm(length(fechas), 0, precio_base * 0.015))
    close <- pmax(precio_base + trend + noise, precio_base * 0.1)

    df <- data.frame(
      fecha       = fechas, simbolo = sym, close = close,
      open        = c(close[1], close[-length(close)]),
      high        = close + abs(rnorm(length(fechas), 0, close * 0.02)),
      low         = close - abs(rnorm(length(fechas), 0, close * 0.02)),
      retorno     = c(0, diff(close)/close[-length(close)] * 100),
      retorno_log = c(0, diff(log(close)) * 100),
      volatilidad = runif(length(fechas), 1, 6)
    )
    hist_data <- bind_rows(hist_data, df)
  }
}

prices_overview <- get_price_overview(CRYPTOS)
if (is.null(prices_overview)) {
  prices_overview <- data.frame(
    simbolo        = names(CRYPTOS),
    precio         = c(50000,3000,1,100,0.5,300,1,0.1,1,30),
    cambio_24h_pct = runif(10,-5,5),
    volumen_24h    = runif(10,1e8,1e10),
    cap_mercado    = c(1e12,4e11,5e10,3e10,1e10,5e9,8e10,2e10,4e9,1e9)
  )
}

cat("\n✅ Total filas:", nrow(hist_data))
## 
## ✅ Total filas: 16268
cat("\n📅 Período:", as.character(min(hist_data$fecha)),
    "→", as.character(max(hist_data$fecha)))
## 
## 📅 Período: 2021-02-19 → 2026-05-08
cat("\n🪙 Monedas:", paste(unique(hist_data$simbolo), collapse=", "), "\n")
## 
## 🪙 Monedas: BTC, ETH, USDC, SOL, XRP, TAO, USDT, DOGE, USD1, ZEC

3 Calidad de los Datos

3.1 Valores Faltantes

missing_summary <- function(df) {
  df |>
    summarise(across(everything(), ~ sum(is.na(.)))) |>
    pivot_longer(everything(), names_to = "Variable", values_to = "NAs") |>
    mutate(Pct = round(NAs / nrow(df) * 100, 2)) |>
    filter(NAs > 0)
}

ms_global <- hist_data |>
  group_by(simbolo) |>
  summarise(
    Total_filas  = n(),
    NAs_close    = sum(is.na(close)),
    NAs_retorno  = sum(is.na(retorno)),
    NAs_vol      = sum(is.na(volatilidad)),
    Pct_NA_close = round(NAs_close / Total_filas * 100, 2)
  )

kable(ms_global, caption = "Resumen de valores faltantes por moneda",
      booktabs = TRUE) |>
  kable_styling(bootstrap_options = c("striped","hover"), full_width = TRUE)
Table 3.1: Table 3.2: Resumen de valores faltantes por moneda
simbolo Total_filas NAs_close NAs_retorno NAs_vol Pct_NA_close
BTC 1905 0 0 0 0
DOGE 1905 0 0 0 0
ETH 1905 0 0 0 0
SOL 1905 0 0 0 0
TAO 702 0 0 0 0
USD1 326 0 0 0 0
USDC 1905 0 0 0 0
USDT 1905 0 0 0 0
XRP 1905 0 0 0 0
ZEC 1905 0 0 0 0

3.2 Mapa de Calor de NAs

cols <- c("close","retorno","retorno_log","volatilidad")

df_heat <- hist_data |>
  group_by(simbolo) |>
  summarise(across(all_of(intersect(cols, names(hist_data))),
                   ~ sum(is.na(.)) / n() * 100, .names = "{.col}")) |>
  pivot_longer(-simbolo, names_to = "Variable", values_to = "pct_na")

ggplot(df_heat, aes(x = Variable, y = simbolo, fill = pct_na)) +
  geom_tile(color = "white", linewidth = 0.5) +
  geom_text(aes(label = paste0(round(pct_na, 1), "%")), size = 3) +
  scale_fill_gradient(low = "white", high = "#e74c3c", name = "% NA") +
  labs(title = "Porcentaje de NAs por moneda y variable",
       x = NULL, y = NULL) +
  theme_minimal(base_size = 12) +
  theme(axis.text.x = element_text(angle = 30, hjust = 1))
Porcentaje de valores faltantes por moneda y variable

Figure 3.1: Porcentaje de valores faltantes por moneda y variable

3.3 Métodos de Imputación

handle_missing <- function(df, method = "interpolation") {
  cols_imp <- c("close","open","high","low","volume","retorno","retorno_log","volatilidad")

  if (method == "remove") return(na.omit(df))

  if (method == "interpolation") {
    df <- df |> arrange(fecha)
    for (col in intersect(cols_imp, names(df))) {
      df[[col]] <- na.approx(df[[col]], na.rm = FALSE)
      df[[col]] <- na.locf(df[[col]], na.rm = FALSE)
      df[[col]] <- na.locf(df[[col]], fromLast = TRUE, na.rm = FALSE)
    }
    return(df)
  }

  if (method == "mean") {
    for (col in intersect(cols_imp, names(df))) {
      df[[col]][is.na(df[[col]])] <- mean(df[[col]], na.rm = TRUE)
    }
    return(df)
  }
  return(df)
}

Se implementaron tres métodos de manejo de valores faltantes:

  • Interpolación lineal (método predeterminado): utiliza zoo::na.approx() seguido de propagación hacia adelante/atrás con na.locf().
  • Media global: reemplaza cada NA por la media de esa columna.
  • Eliminación de filas: descarta registros incompletos con na.omit().

4 Visión General de Mercado

4.1 Precios y Capitalización Actuales

prices_overview |>
  mutate(
    precio         = dollar(precio, accuracy = 0.01),
    cambio_24h_pct = paste0(round(cambio_24h_pct, 2), "%"),
    volumen_24h    = dollar(volumen_24h, accuracy = 1, scale = 1e-6, suffix = "M"),
    cap_mercado    = dollar(cap_mercado, accuracy = 1, scale = 1e-9, suffix = "B")
  ) |>
  rename(
    Símbolo = simbolo, `Precio USD` = precio, `Cambio 24h` = cambio_24h_pct,
    `Volumen 24h` = volumen_24h, `Cap. Mercado` = cap_mercado
  ) |>
  kable(caption = "Visión general del mercado en tiempo real",
        booktabs = TRUE) |>
  kable_styling(bootstrap_options = c("striped","hover"), full_width = TRUE)
Table 4.1: Table 4.2: Visión general del mercado en tiempo real
Símbolo Precio USD Cambio 24h Volumen 24h Cap. Mercado
BTC $79,577.11 -1.56% $1,566M $1,594B
ETH $2,278.34 -1.88% $651M $275B
USDC $1.00 0.01% $220M $78B
SOL $88.21 0.51% $155M $55B
XRP $1.38 -1.53% $114M $138B
TAO $302.82 -1.18% $21M $3B
USDT $1.00 0% $382M $195B
DOGE $0.11 -3.5% $55M $18B
USD1 $1.00 -0.01% $123M $4B
ZEC $576.30 6.98% $87M $10B

4.2 Capitalización de Mercado

df_cap <- prices_overview |>
  mutate(simbolo = reorder(simbolo, cap_mercado))

ggplot(df_cap, aes(x = simbolo, y = cap_mercado / 1e9, fill = simbolo)) +
  geom_col(show.legend = FALSE, alpha = 0.85) +
  geom_text(aes(label = paste0("$", round(cap_mercado / 1e9, 1), "B")),
            hjust = -0.1, size = 3.5) +
  coord_flip() +
  scale_fill_manual(values = COLOR_PALETTE) +
  scale_y_continuous(expand = expansion(mult = c(0, 0.15)),
                     labels = dollar_format(suffix = "B", scale = 1)) +
  labs(title = "Capitalización de Mercado",
       x = NULL, y = "Miles de millones USD") +
  theme_minimal(base_size = 12)
Capitalización de mercado por criptomoneda (miles de millones USD)

Figure 4.1: Capitalización de mercado por criptomoneda (miles de millones USD)

4.3 Volumen de Transacciones (24h)

df_vol <- prices_overview |>
  mutate(simbolo = reorder(simbolo, volumen_24h))

ggplot(df_vol, aes(x = simbolo, y = volumen_24h / 1e6, fill = simbolo)) +
  geom_col(show.legend = FALSE, alpha = 0.85) +
  coord_flip() +
  scale_fill_manual(values = COLOR_PALETTE) +
  scale_y_continuous(labels = dollar_format(suffix = "M", scale = 1)) +
  labs(title = "Volumen 24h", x = NULL, y = "Millones USD") +
  theme_minimal(base_size = 12)
Volumen de transacciones en las últimas 24 horas

Figure 4.2: Volumen de transacciones en las últimas 24 horas


5 Análisis de Precios

5.1 Distribución de Precios (Boxplot)

df_365 <- hist_data |>
  filter(fecha >= max(fecha) - 365)

ggplot(df_365, aes(x = simbolo, y = close, fill = simbolo)) +
  geom_boxplot(alpha = 0.7, outlier.color = "#e74c3c", outlier.size = 1.5) +
  stat_summary(fun = "mean", geom = "point", shape = 18, size = 4, color = "white") +
  scale_y_log10(labels = dollar) +
  scale_fill_manual(values = COLOR_PALETTE) +
  coord_flip() +
  labs(title = "Distribución de Precios de Cierre (últimos 365 días)",
       x = NULL, y = "Precio USD (escala log)") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "none")
Distribución de precios de cierre (escala logarítmica) — últimos 365 días

Figure 5.1: Distribución de precios de cierre (escala logarítmica) — últimos 365 días

5.2 Serie Temporal de Precios — BTC

df_btc <- hist_data |> filter(simbolo == "BTC") |> arrange(fecha)

ggplot(df_btc, aes(x = fecha, y = close)) +
  geom_line(color = "#F7931A", linewidth = 0.7) +
  scale_y_continuous(labels = dollar) +
  labs(title = "Bitcoin — Precio de Cierre Histórico",
       x = NULL, y = "Precio (USD)") +
  theme_minimal(base_size = 12)
Serie temporal del precio de cierre de Bitcoin (BTC)

Figure 5.2: Serie temporal del precio de cierre de Bitcoin (BTC)

5.3 Bandas de Bollinger

Las Bandas de Bollinger son un indicador de volatilidad que envuelve el precio alrededor de una media móvil simple (SMA).

BB± = SMAₙ ± k · σₙ
%Bandwidth = (BB₊ − BB₋) / SMAₙ × 100

calculate_bollinger_bands <- function(prices, window = 20, sd_mult = 2) {
  sma <- rollmean(prices, window, fill = NA, align = "right")
  sd  <- rollapply(prices, window, sd, fill = NA, align = "right")
  data.frame(
    sma   = sma,
    upper = sma + (sd_mult * sd),
    lower = sma - (sd_mult * sd)
  )
}
bb <- calculate_bollinger_bands(df_btc$close, window = 20, sd_mult = 2)
df_bb <- cbind(df_btc, bb) |> drop_na(sma, upper, lower) |> tail(365)

ggplot(df_bb, aes(x = fecha)) +
  geom_ribbon(aes(ymin = lower, ymax = upper), fill = "#3498db", alpha = 0.18) +
  geom_line(aes(y = close, color = "Precio"),         linewidth = 0.8) +
  geom_line(aes(y = sma,   color = "SMA 20"),         linetype = "dashed", linewidth = 0.7) +
  geom_line(aes(y = upper, color = "Banda Superior"), linetype = "dotted", linewidth = 0.6) +
  geom_line(aes(y = lower, color = "Banda Inferior"), linetype = "dotted", linewidth = 0.6) +
  scale_color_manual(values = c(
    "Precio" = "#e94560", "SMA 20" = "#F7931A",
    "Banda Superior" = "#2ecc71", "Banda Inferior" = "#2ecc71"
  )) +
  scale_y_continuous(labels = dollar) +
  labs(title = "Bandas de Bollinger — BTC (SMA 20, k = 2)",
       x = NULL, y = "Precio (USD)", color = NULL) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom")
Bandas de Bollinger — BTC (SMA 20, k=2)

Figure 5.3: Bandas de Bollinger — BTC (SMA 20, k=2)

df_bb <- df_bb |>
  mutate(bw = (upper - lower) / sma * 100)

ggplot(df_bb, aes(x = fecha, y = bw)) +
  geom_line(color = "#627EEA", linewidth = 0.8) +
  geom_area(fill = "#627EEA", alpha = 0.15) +
  labs(title = "Ancho de Banda Relativo (%Bandwidth)",
       x = NULL, y = "%Bandwidth") +
  theme_minimal(base_size = 12)
Ancho de banda relativo — medida de volatilidad de Bollinger

Figure 5.4: Ancho de banda relativo — medida de volatilidad de Bollinger


6 Retornos y Riesgo

6.1 Distribución de Retornos

Los retornos se calculan como:

Retorno simple: rₜ = (Pₜ − Pₜ₋₁) / Pₜ₋₁ × 100
Retorno logarítmico: rₜˡᵒᵍ = ln(Pₜ / Pₜ₋₁) × 100

ggplot(df_365, aes(x = simbolo, y = retorno, fill = simbolo)) +
  geom_boxplot(alpha = 0.7, outlier.color = "#e74c3c", outlier.size = 1) +
  geom_hline(yintercept = 0, linetype = "dashed", color = "gray50") +
  scale_fill_manual(values = COLOR_PALETTE) +
  coord_flip() +
  labs(title = "Distribución de Retornos Diarios",
       x = NULL, y = "Retorno (%)") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "none")
Distribución de retornos diarios por criptomoneda (últimos 365 días)

Figure 6.1: Distribución de retornos diarios por criptomoneda (últimos 365 días)

6.2 Histograma de Retornos — BTC

ggplot(df_btc, aes(x = retorno, fill = after_stat(x) > 0)) +
  geom_histogram(bins = 60, alpha = 0.85) +
  scale_fill_manual(values = c("TRUE" = "#2ecc71", "FALSE" = "#e74c3c"),
                    labels = c("TRUE" = "Positivo", "FALSE" = "Negativo"),
                    name = NULL) +
  geom_vline(xintercept = 0, linetype = "dashed", color = "black") +
  labs(title = "Histograma de Retornos Diarios — BTC",
       x = "Retorno (%)", y = "Frecuencia") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "top")
Histograma de retornos diarios de BTC

Figure 6.2: Histograma de retornos diarios de BTC

6.3 Volatilidad Rodante (30 días)

df_btc_vol <- df_btc |>
  arrange(fecha) |>
  mutate(vol30 = zoo::rollapply(retorno, 30, sd, fill = NA, align = "right"))

ggplot(df_btc_vol, aes(x = fecha, y = vol30)) +
  geom_line(color = "#e94560", linewidth = 0.7) +
  geom_area(fill = "#e94560", alpha = 0.15) +
  labs(title = "Volatilidad Rodante 30 días — BTC",
       x = NULL, y = "Desv. Est. Retorno (%)") +
  theme_minimal(base_size = 12)
Volatilidad rodante de 30 días para BTC

Figure 6.3: Volatilidad rodante de 30 días para BTC

6.4 Tabla de Riesgo por Criptomoneda

tabla_riesgo <- df_365 |>
  group_by(simbolo) |>
  summarise(
    `Ret. Medio (%)` = round(mean(retorno, na.rm = TRUE), 3),
    `Desv. Est. (%)` = round(sd(retorno, na.rm = TRUE), 3),
    `VaR 95% (%)`    = round(quantile(retorno, 0.05, na.rm = TRUE), 3),
    `Días pos. (%)`  = round(sum(retorno > 0, na.rm = TRUE) / n() * 100, 1)
  ) |>
  arrange(desc(`Ret. Medio (%)`))

kable(tabla_riesgo,
      caption = "Métricas de retorno y riesgo — últimos 365 días",
      booktabs = TRUE) |>
  kable_styling(bootstrap_options = c("striped","hover"), full_width = TRUE) |>
  column_spec(4, color = ifelse(tabla_riesgo$`VaR 95% (%)` < -3, "red", "black"))
Table 6.1: Table 6.2: Métricas de retorno y riesgo — últimos 365 días
simbolo Ret. Medio (%) Desv. Est. (%) VaR 95% (%) Días pos. (%)
USD1 Inf NaN -0.100 38.7
ZEC 1.015 7.893 -8.682 50.8
ETH 0.133 3.767 -5.189 51.1
TAO 0.080 5.225 -7.736 46.2
USDC 0.000 0.008 -0.010 21.9
USDT 0.000 0.040 -0.100 19.9
BTC -0.029 2.230 -3.533 49.7
DOGE -0.031 4.488 -6.072 44.5
XRP -0.053 3.599 -4.914 43.2
SOL -0.067 3.827 -5.748 49.2

El VaR 95% representa la pérdida máxima esperada en el 5% de los peores días históricos. Un valor de -5% indica que en el 5% de los días más adversos, la pérdida fue de al menos 5%.


7 Análisis de Correlaciones

7.1 Matriz de Correlación (Pearson)

df_wide <- df_365 |>
  select(fecha, simbolo, retorno) |>
  pivot_wider(names_from = simbolo, values_from = retorno) |>
  select(-fecha)

mat_cor <- cor(df_wide, use = "complete.obs", method = "pearson")

df_cor_long <- as.data.frame(as.table(mat_cor)) |>
  rename(Var1 = Var1, Var2 = Var2, Corr = Freq)

ggplot(df_cor_long, aes(x = Var1, y = Var2, fill = Corr)) +
  geom_tile(color = "white", linewidth = 0.4) +
  geom_text(aes(label = round(Corr, 2)), size = 3, color = "black") +
  scale_fill_gradient2(low = "#3498db", mid = "white", high = "#e74c3c",
                       midpoint = 0, limits = c(-1, 1), name = "Pearson r") +
  labs(title = "Matriz de Correlación — Retornos Diarios (Pearson)",
       x = NULL, y = NULL) +
  theme_minimal(base_size = 11) +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))
Mapa de calor de correlaciones de Pearson entre retornos diarios (últimos 365 días)

Figure 7.1: Mapa de calor de correlaciones de Pearson entre retornos diarios (últimos 365 días)

7.2 Scatter BTC vs ETH

df_scatter <- df_365 |>
  filter(simbolo %in% c("BTC", "ETH")) |>
  select(fecha, simbolo, retorno) |>
  pivot_wider(names_from = simbolo, values_from = retorno) |>
  drop_na()

ggplot(df_scatter, aes(x = BTC, y = ETH)) +
  geom_point(alpha = 0.4, color = "#627EEA", size = 1.5) +
  geom_smooth(method = "lm", se = TRUE, color = "#e94560", linewidth = 1) +
  labs(title = "Retornos Diarios: BTC vs ETH",
       x = "Retorno BTC (%)", y = "Retorno ETH (%)") +
  theme_minimal(base_size = 12)
Diagrama de dispersión BTC vs ETH — retornos diarios

Figure 7.2: Diagrama de dispersión BTC vs ETH — retornos diarios


8 Comparador de Rendimiento

8.1 Rendimiento Acumulado (Base 100)

df_comp <- hist_data |>
  filter(fecha >= max(fecha) - 365) |>
  arrange(fecha) |>
  group_by(simbolo) |>
  mutate(ini = first(close), norm = close / ini * 100) |>
  ungroup()

ggplot(df_comp, aes(x = fecha, y = norm, color = simbolo)) +
  geom_line(linewidth = 0.8, alpha = 0.9) +
  geom_hline(yintercept = 100, linetype = "dashed", color = "gray50") +
  scale_color_manual(values = COLOR_PALETTE) +
  labs(title = "Rendimiento Acumulado — Base 100 (últimos 365 días)",
       x = NULL, y = "Índice (base = 100)", color = "Moneda") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "right")
Rendimiento acumulado normalizado en base 100 (desde el inicio del período)

Figure 8.1: Rendimiento acumulado normalizado en base 100 (desde el inicio del período)

8.2 Tabla Comparativa de Rendimiento

tabla_comp <- hist_data |>
  filter(fecha >= max(fecha) - 365) |>
  group_by(simbolo) |>
  summarise(
    `P. Inicial ($)` = round(first(close), 4),
    `P. Final ($)`   = round(last(close), 4),
    `Rend. (%)`      = round((last(close) - first(close)) / first(close) * 100, 2),
    `Vol. (%)`       = round(sd(retorno, na.rm = TRUE), 3)
  ) |>
  arrange(desc(`Rend. (%)`))

kable(tabla_comp,
      caption = "Comparativa de rendimiento — últimos 365 días",
      booktabs = TRUE) |>
  kable_styling(bootstrap_options = c("striped","hover"), full_width = TRUE) |>
  column_spec(4, color = ifelse(tabla_comp$`Rend. (%)` >= 0, "#2ecc71", "#e74c3c"),
              bold = TRUE)
Table 8.1: Table 8.2: Comparativa de rendimiento — últimos 365 días
simbolo P. Inicial (\() </th> <th style="text-align:right;"> P. Final (\)) Rend. (%) Vol. (%)
ZEC 41.8800 576.3000 1276.07 7.893
ETH 2207.1900 2278.8100 3.24 3.767
USDC 1.0000 0.9999 -0.01 0.008
USDT 0.9999 0.9997 -0.02 0.040
USD1 0.9999 0.9993 -0.06 NaN
BTC 103259.0000 79572.9500 -22.94 2.230
TAO 423.2000 302.8200 -28.45 5.225
XRP 2.3270 1.3840 -40.52 3.599
DOGE 0.1982 0.1064 -46.32 4.488
SOL 164.4400 88.2200 -46.35 3.827

9 Análisis EDA Orientado al Modelo

9.1 Boxplot Mensual de Retornos — BTC

df_btc_mes <- df_btc |>
  mutate(mes = floor_date(fecha, "month"),
         mes_label = format(mes, "%b %Y"))

ggplot(df_btc_mes, aes(x = reorder(mes_label, mes), y = retorno)) +
  geom_boxplot(fill = "#3498db", alpha = 0.7, outlier.size = 1) +
  geom_hline(yintercept = 0, linetype = "dashed", color = "gray40") +
  labs(title = "Retornos Diarios de BTC por Mes",
       x = NULL, y = "Retorno (%)") +
  theme_minimal(base_size = 11) +
  theme(axis.text.x = element_text(angle = 90, hjust = 1, size = 7))
Boxplot mensual de retornos diarios de BTC

Figure 9.1: Boxplot mensual de retornos diarios de BTC

9.2 Funciones ACF y PACF

Las funciones de autocorrelación son fundamentales para identificar los órdenes del modelo ARIMA:

  • ACF (Autocorrelation Function): indica el orden q del componente MA.
  • PACF (Partial Autocorrelation Function): indica el orden p del componente AR.
serie_btc <- na.omit(df_btc$retorno)
ci_val <- qnorm(0.975) / sqrt(length(serie_btc))

acf_r  <- acf(serie_btc,  plot = FALSE, lag.max = 40)
pacf_r <- pacf(serie_btc, plot = FALSE, lag.max = 40)

df_acf  <- data.frame(Lag = as.numeric(acf_r$lag[-1]),  ACF  = as.numeric(acf_r$acf[-1]))
df_pacf <- data.frame(Lag = as.numeric(pacf_r$lag),     PACF = as.numeric(pacf_r$acf))

p_acf <- ggplot(df_acf, aes(x = Lag, y = ACF)) +
  geom_bar(stat = "identity", fill = "#3498db", alpha = 0.7) +
  geom_hline(yintercept = c(ci_val, -ci_val), linetype = "dashed", color = "blue", linewidth = 0.7) +
  labs(title = "ACF — Retornos BTC") + theme_minimal(base_size = 11)

p_pacf <- ggplot(df_pacf, aes(x = Lag, y = PACF)) +
  geom_bar(stat = "identity", fill = "#e74c3c", alpha = 0.7) +
  geom_hline(yintercept = c(ci_val, -ci_val), linetype = "dashed", color = "blue", linewidth = 0.7) +
  labs(title = "PACF — Retornos BTC") + theme_minimal(base_size = 11)

gridExtra::grid.arrange(p_acf, p_pacf, ncol = 2)
ACF y PACF de los retornos diarios de BTC

Figure 9.2: ACF y PACF de los retornos diarios de BTC

9.3 Estadísticas Descriptivas

stats_desc <- hist_data |>
  group_by(simbolo) |>
  summarise(
    n      = n(),
    Media  = round(mean(close, na.rm = TRUE), 2),
    Mediana= round(median(close, na.rm = TRUE), 2),
    DS     = round(sd(close, na.rm = TRUE), 2),
    Min    = round(min(close, na.rm = TRUE), 4),
    Max    = round(max(close, na.rm = TRUE), 2),
    IQR    = round(IQR(close, na.rm = TRUE), 2)
  )

kable(stats_desc,
      caption = "Estadísticas descriptivas del precio de cierre por moneda",
      booktabs = TRUE,
      col.names = c("Símbolo","n","Media","Mediana","Desv. Est.","Mín.","Máx.","IQR")) |>
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = TRUE)
Table 9.1: Table 9.2: Estadísticas descriptivas del precio de cierre por moneda
Símbolo n Media Mediana Desv. Est. Mín. Máx. IQR
BTC 1905 56298.29 50431.63 29296.01 15760.1900 124723.00 45328.37
DOGE 1905 0.15 0.12 0.09 0.0477 0.69 0.12
ETH 1905 2552.70 2436.20 880.85 994.4100 4830.60 1383.69
SOL 1905 100.87 96.85 68.59 9.6410 261.87 122.76
TAO 702 350.61 331.72 109.96 145.7400 713.91 143.12
USD1 326 1.00 1.00 0.00 0.9986 1.00 0.00
USDC 1905 1.00 1.00 0.00 0.9679 1.00 0.00
USDT 1905 1.00 1.00 0.00 0.9940 1.01 0.00
XRP 1905 1.08 0.63 0.81 0.3072 3.55 0.93
ZEC 1905 102.04 47.96 112.07 18.3100 698.97 105.23

9.4 Test de Estacionariedad (ADF)

La prueba Augmented Dickey-Fuller (ADF) contrasta la hipótesis nula de que la serie tiene una raíz unitaria (no es estacionaria):

  • H₀: la serie tiene raíz unitaria (no estacionaria)
  • H₁: la serie es estacionaria
  • Se rechaza H₀ si p-valor < 0.05
test_stationarity <- function(serie) {
  tryCatch({
    test <- adf.test(na.omit(as.numeric(serie)), k = trunc((length(na.omit(serie)) - 1)^(1/3)))
    list(
      estadistico    = test$statistic,
      p_valor        = test$p.value,
      es_estacionaria = test$p.value < 0.05,
      conclusion     = ifelse(test$p.value < 0.05,
                              "Serie estacionaria (rechaza H₀)",
                              "Serie NO estacionaria (no rechaza H₀)")
    )
  }, error = function(e) list(estadistico=NA, p_valor=NA, es_estacionaria=FALSE, conclusion="Error"))
}

adf_resultados <- lapply(unique(hist_data$simbolo), function(cr) {
  serie <- na.omit(hist_data[hist_data$simbolo == cr, "retorno"][[1]])
  res   <- test_stationarity(serie)
  data.frame(
    Moneda        = cr,
    `ADF Stat.`   = round(res$estadistico, 4),
    `p-valor`     = round(res$p_valor, 6),
    Estacionaria  = ifelse(res$es_estacionaria, "✅ Sí", "❌ No"),
    `d recomendado` = ifelse(res$es_estacionaria, 0, 1)
  )
}) |> bind_rows()

kable(adf_resultados,
      caption = "Resultados del Test ADF para retornos diarios por criptomoneda",
      booktabs = TRUE,
      row.names = FALSE) |>
  kable_styling(bootstrap_options = c("striped","hover"), full_width = TRUE)
Table 9.3: Table 9.4: Resultados del Test ADF para retornos diarios por criptomoneda
Moneda ADF.Stat. p.valor Estacionaria d.recomendado
BTC NA NA ❌ No | 1|
ETH NA NA ❌ No | 1|
USDC NA NA ❌ No | 1|
SOL NA NA ❌ No | 1|
XRP NA NA ❌ No | 1|
TAO NA NA ❌ No | 1|
USDT NA NA ❌ No | 1|
DOGE NA NA ❌ No | 1|
USD1 NA NA ❌ No | 1|
ZEC NA NA ❌ No | 1|

9.5 Descomposición STL — BTC

La descomposición STL (Seasonal and Trend decomposition using Loess) separa la serie en:

Y(t) = T(t) + S(t) + R(t)

Donde: T(t) = Tendencia · S(t) = Estacionalidad · R(t) = Residuo

serie_stl <- ts(na.omit(df_btc$retorno), frequency = 7)
decomp    <- stl(serie_stl, s.window = "periodic", robust = TRUE)

autoplot(decomp) +
  labs(title = "Descomposición STL — Retornos BTC") +
  theme_minimal(base_size = 12)
Descomposición STL de los retornos de BTC (frecuencia semanal)

Figure 9.3: Descomposición STL de los retornos de BTC (frecuencia semanal)


10 Modelado ARIMA

10.1 Marco Teórico

El modelo ARIMA(p, d, q) combina tres componentes:

Componente Símbolo Descripción
Autoregresivo AR(p) Dependencia en p rezagos pasados
Integrado I(d) Diferenciación para estacionariedad
Media Móvil MA(q) Dependencia en q errores pasados

Ecuación general:
φ(B)(1−B)ᵈ yₜ = θ(B) εₜ

Donde B es el operador de rezago, φ(B) el polinomio AR, θ(B) el polinomio MA y εₜ ruido blanco.

10.2 Función: Detección de Estacionariedad

detect_stationarity_and_differentiate <- function(serie, max_d = 2) {
  serie_clean <- as.numeric(na.omit(serie))
  d_val <- 0
  current <- serie_clean

  for (i in seq_len(max_d)) {
    tryCatch({
      test <- adf.test(current, k = trunc((length(current)-1)^(1/3)))
      if (test$p.value < 0.05) break
      d_val    <- i
      current  <- diff(current)
    }, error = function(e) break)
  }
  list(is_stationary = d_val == 0, d_value = d_val)
}

10.3 Ajuste del Mejor Modelo — BTC

set.seed(42)
df_btc_clean <- hist_data |>
  filter(simbolo == "BTC") |>
  arrange(fecha) |>
  handle_missing(method = "interpolation")

serie_full  <- na.omit(df_btc_clean$close)
n           <- length(serie_full)
n_train     <- floor(n * 0.8)
train       <- serie_full[1:n_train]
test        <- serie_full[(n_train + 1):n]
fechas_all  <- df_btc_clean$fecha[!is.na(df_btc_clean$close)]
train_dates <- fechas_all[1:n_train]
test_dates  <- fechas_all[(n_train + 1):n]

# Detectar d óptimo
stat_res  <- detect_stationarity_and_differentiate(train)
d_opt     <- stat_res$d_value
cat("d óptimo detectado:", d_opt, "\n")
## d óptimo detectado: 1
# Búsqueda de mejor (p, q) por AIC
pq_rng    <- 0:3
best_aic  <- Inf
best_order <- c(1, d_opt, 1)
best_model <- NULL
results   <- list()

for (p in pq_rng) {
  for (q in pq_rng) {
    if (p == 0 && d_opt == 0 && q == 0) next
    tryCatch({
      m   <- Arima(train, order = c(p, d_opt, q), method = "ML")
      aic <- AIC(m)
      results[[length(results)+1]] <- data.frame(p=p, d=d_opt, q=q, AIC=round(aic,2))
      if (aic < best_aic) { best_aic <- aic; best_order <- c(p, d_opt, q); best_model <- m }
    }, error = function(e) {})
  }
}

cat(sprintf("Mejor modelo: ARIMA(%d,%d,%d) — AIC = %.2f\n",
            best_order[1], best_order[2], best_order[3], best_aic))
## Mejor modelo: ARIMA(1,1,0) — AIC = 26687.81

10.4 Top Modelos por AIC

df_results <- bind_rows(results) |>
  arrange(AIC) |>
  head(10) |>
  mutate(Modelo = paste0("ARIMA(", p, ",", d, ",", q, ")")) |>
  select(Modelo, AIC)

kable(df_results,
      caption = "Top 10 modelos ARIMA por criterio AIC — BTC precio de cierre",
      booktabs = TRUE) |>
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE) |>
  row_spec(1, bold = TRUE, background = "#d5f0e0")
Table 10.1: Table 10.2: Top 10 modelos ARIMA por criterio AIC — BTC precio de cierre
Modelo AIC
ARIMA(1,1,0) 26687.81
ARIMA(0,1,1) 26687.96
ARIMA(2,1,0) 26689.71
ARIMA(0,1,2) 26689.72
ARIMA(1,1,1) 26689.73
ARIMA(3,1,3) 26691.11
ARIMA(3,1,0) 26691.70
ARIMA(2,1,1) 26691.71
ARIMA(0,1,3) 26691.72
ARIMA(1,1,2) 26691.72

10.5 Diagnóstico de Residuos

resid_model <- residuals(best_model)

p1 <- ggplot(data.frame(x = 1:length(resid_model), y = resid_model),
             aes(x = x, y = y)) +
  geom_line(color = "#3498db", linewidth = 0.5) +
  labs(title = "Residuos en el tiempo", x = "Índice", y = "Residuo") +
  theme_minimal(base_size = 11)

p2 <- ggplot(data.frame(x = resid_model), aes(x = x)) +
  geom_histogram(bins = 40, fill = "#627EEA", alpha = 0.8, color = "white") +
  labs(title = "Distribución de Residuos", x = "Residuo", y = "Frecuencia") +
  theme_minimal(base_size = 11)

gridExtra::grid.arrange(p1, p2, ncol = 2)
Diagnóstico de residuos del mejor modelo ARIMA

Figure 10.1: Diagnóstico de residuos del mejor modelo ARIMA

# Test de normalidad (Shapiro-Wilk)
sw_test <- if (length(resid_model) > 5000)
  shapiro.test(sample(resid_model, 5000)) else shapiro.test(resid_model)

# Test de independencia (Ljung-Box)
lb_test <- Box.test(resid_model, lag = 10, type = "Ljung-Box")

tests_df <- data.frame(
  Test         = c("Shapiro-Wilk (Normalidad)", "Ljung-Box (Independencia)"),
  `p-valor`    = round(c(sw_test$p.value, lb_test$p.value), 6),
  Conclusión   = c(
    ifelse(sw_test$p.value > 0.05, "✅ Residuos normales", "⚠️ No normales"),
    ifelse(lb_test$p.value > 0.05, "✅ Residuos independientes", "⚠️ Autocorrelación presente")
  )
)

kable(tests_df,
      caption = "Tests sobre los residuos del mejor modelo ARIMA",
      booktabs = TRUE) |>
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
Table 10.3: Table 10.4: Tests sobre los residuos del mejor modelo ARIMA
Test p.valor Conclusión
Shapiro-Wilk (Normalidad) 0.000000 ⚠️ No normales
Ljung-Box (Independencia) 0.067679 ✅ Residuos independientes |

10.6 Forecast y Métricas de Error

horizonte <- length(test)
fc        <- forecast(best_model, h = horizonte)
pred      <- as.numeric(fc$mean)

# Métricas
mae  <- mean(abs(pred - test))
rmse <- sqrt(mean((pred - test)^2))
mape <- mean(abs((pred - test) / test)) * 100
r2   <- cor(pred, test)^2

metricas <- data.frame(
  Métrica = c("MAE (USD)", "RMSE (USD)", "MAPE (%)", "R²"),
  Valor   = round(c(mae, rmse, mape, r2), 4)
)
kable(metricas,
      caption = sprintf("Métricas de error — ARIMA(%d,%d,%d) en conjunto de test",
                        best_order[1], best_order[2], best_order[3]),
      booktabs = TRUE) |>
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
Table 10.5: Table 10.6: Métricas de error — ARIMA(1,1,0) en conjunto de test
Métrica Valor
MAE (USD) 15437.3115
RMSE (USD) 17588.6850
MAPE (%) 16.8523
0.0000
n_hist <- min(200, length(train))
df_forecast <- bind_rows(
  data.frame(Fecha = tail(train_dates, n_hist),
             Precio = tail(train, n_hist), Tipo = "Entrenamiento"),
  data.frame(Fecha = test_dates[1:horizonte],
             Precio = test[1:horizonte],  Tipo = "Test Real"),
  data.frame(Fecha = test_dates[1:horizonte],
             Precio = pred,               Tipo = "Predicción"),
  data.frame(Fecha = test_dates[1:horizonte],
             Precio = as.numeric(fc$lower[,2]), Tipo = "IC 95% Inferior"),
  data.frame(Fecha = test_dates[1:horizonte],
             Precio = as.numeric(fc$upper[,2]), Tipo = "IC 95% Superior")
)

df_ribbon <- data.frame(
  Fecha = test_dates[1:horizonte],
  Lower = as.numeric(fc$lower[,2]),
  Upper = as.numeric(fc$upper[,2])
)

df_lines <- df_forecast |> filter(Tipo %in% c("Entrenamiento","Test Real","Predicción"))

ggplot() +
  geom_ribbon(data = df_ribbon, aes(x = Fecha, ymin = Lower, ymax = Upper),
              fill = "#3498db", alpha = 0.2) +
  geom_line(data = df_lines, aes(x = Fecha, y = Precio, color = Tipo, linewidth = Tipo)) +
  scale_color_manual(values = c(
    "Entrenamiento" = "#2c3e50",
    "Test Real"     = "#3498db",
    "Predicción"    = "#e74c3c"
  )) +
  scale_linewidth_manual(values = c(
    "Entrenamiento" = 0.7, "Test Real" = 0.9, "Predicción" = 1.1
  )) +
  scale_y_continuous(labels = dollar) +
  labs(
    title    = sprintf("ARIMA(%d,%d,%d) — Predicción vs Realidad — BTC",
                       best_order[1], best_order[2], best_order[3]),
    subtitle = sprintf("MAE: $%.0f | RMSE: $%.0f | MAPE: %.2f%% | R²: %.4f",
                       mae, rmse, mape, r2),
    x = NULL, y = "Precio (USD)",
    color = NULL, linewidth = NULL
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom")
Ajuste del modelo ARIMA vs valores reales (BTC)

Figure 10.2: Ajuste del modelo ARIMA vs valores reales (BTC)


11 Predicción Óptima

11.1 Búsqueda Automática del Mejor Modelo

# Usar los 1000 días más recientes, predicción 30 días
n_pred_train <- min(1000, floor(n * 0.85))
train_opt    <- serie_full[1:n_pred_train]
train_dates_opt <- fechas_all[1:n_pred_train]
horizonte_opt   <- 30

stat_opt <- detect_stationarity_and_differentiate(train_opt)
d_opt2   <- stat_opt$d_value

best_bic  <- Inf
best_ord2 <- c(1, d_opt2, 1)
best_mod2 <- NULL

for (p in 0:3) {
  for (q in 0:3) {
    if (p == 0 && d_opt2 == 0 && q == 0) next
    tryCatch({
      m   <- Arima(train_opt, order = c(p, d_opt2, q), method = "ML")
      b   <- BIC(m)
      if (b < best_bic) { best_bic <- b; best_ord2 <- c(p, d_opt2, q); best_mod2 <- m }
    }, error = function(e) {})
  }
}

cat(sprintf("Mejor modelo (BIC): ARIMA(%d,%d,%d) — BIC = %.2f\n",
            best_ord2[1], best_ord2[2], best_ord2[3], best_bic))
## Mejor modelo (BIC): ARIMA(0,1,0) — BIC = 17130.12

11.2 Predicción a 30 Días

fc_opt  <- forecast(best_mod2, h = horizonte_opt)
last_d  <- tail(train_dates_opt, 1)
pred_dates <- seq(last_d + 1, by = "day", length.out = horizonte_opt)

df_pred_opt <- data.frame(
  Fecha      = pred_dates,
  Prediccion = as.numeric(fc_opt$mean),
  Lower_80   = as.numeric(fc_opt$lower[,1]),
  Upper_80   = as.numeric(fc_opt$upper[,1]),
  Lower_95   = as.numeric(fc_opt$lower[,2]),
  Upper_95   = as.numeric(fc_opt$upper[,2])
)

n_ctx <- min(120, length(train_opt))
df_ctx <- data.frame(
  Fecha  = tail(train_dates_opt, n_ctx),
  Precio = tail(train_opt, n_ctx)
)

ggplot() +
  geom_line(data = df_ctx, aes(x = Fecha, y = Precio), color = "#2c3e50", linewidth = 0.8) +
  geom_ribbon(data = df_pred_opt,
              aes(x = Fecha, ymin = Lower_95, ymax = Upper_95), fill = "#3498db", alpha = 0.18) +
  geom_ribbon(data = df_pred_opt,
              aes(x = Fecha, ymin = Lower_80, ymax = Upper_80), fill = "#3498db", alpha = 0.28) +
  geom_line(data = df_pred_opt, aes(x = Fecha, y = Prediccion),
            color = "#e74c3c", linewidth = 1.1) +
  scale_y_continuous(labels = dollar) +
  labs(
    title    = sprintf("Predicción Óptima 30 días — BTC | ARIMA(%d,%d,%d) por BIC",
                       best_ord2[1], best_ord2[2], best_ord2[3]),
    subtitle = "Azul: IC 80% y 95% | Rojo: Predicción puntual | Gris: Historial",
    x = NULL, y = "Precio (USD)"
  ) +
  theme_minimal(base_size = 12)
Predicción óptima a 30 días para BTC con intervalos de confianza

Figure 11.1: Predicción óptima a 30 días para BTC con intervalos de confianza

11.3 Tabla de Predicciones

df_pred_opt |>
  mutate(
    Fecha      = format(Fecha, "%d/%m/%Y"),
    Prediccion = dollar(Prediccion, accuracy = 1),
    `IC 80%`   = paste0(dollar(Lower_80, accuracy=1), " — ", dollar(Upper_80, accuracy=1)),
    `IC 95%`   = paste0(dollar(Lower_95, accuracy=1), " — ", dollar(Upper_95, accuracy=1))
  ) |>
  select(Fecha, Prediccion, `IC 80%`, `IC 95%`) |>
  kable(caption = "Predicciones diarias a 30 días con intervalos de confianza",
        booktabs = TRUE, row.names = FALSE) |>
  kable_styling(bootstrap_options = c("striped","hover","condensed"),
                full_width = TRUE) |>
  scroll_box(height = "400px")
Table 11.1: Table 11.2: Predicciones diarias a 30 días con intervalos de confianza
Fecha Prediccion IC 80% IC 95%
16/11/2023 $37,884 $36,249 — $39,519 $35,384 — $40,384
17/11/2023 $37,884 $35,572 — $40,196 $34,348 — $41,420
18/11/2023 $37,884 $35,053 — $40,716 $33,554 — $42,215
19/11/2023 $37,884 $34,614 — $41,154 $32,884 — $42,885
20/11/2023 $37,884 $34,229 — $41,540 $32,293 — $43,475
21/11/2023 $37,884 $33,880 — $41,889 $31,760 — $44,009
22/11/2023 $37,884 $33,559 — $42,210 $31,269 — $44,499
23/11/2023 $37,884 $33,260 — $42,508 $30,812 — $44,956
24/11/2023 $37,884 $32,980 — $42,789 $30,383 — $45,385
25/11/2023 $37,884 $32,714 — $43,054 $29,978 — $45,791
26/11/2023 $37,884 $32,462 — $43,306 $29,592 — $46,177
27/11/2023 $37,884 $32,221 — $43,547 $29,223 — $46,545
28/11/2023 $37,884 $31,990 — $43,779 $28,869 — $46,899
29/11/2023 $37,884 $31,767 — $44,001 $28,529 — $47,239
30/11/2023 $37,884 $31,552 — $44,216 $28,201 — $47,568
01/12/2023 $37,884 $31,345 — $44,424 $27,883 — $47,885
02/12/2023 $37,884 $31,144 — $44,625 $27,575 — $48,193
03/12/2023 $37,884 $30,948 — $44,820 $27,276 — $48,492
04/12/2023 $37,884 $30,758 — $45,010 $26,986 — $48,783
05/12/2023 $37,884 $30,573 — $45,195 $26,703 — $49,066
06/12/2023 $37,884 $30,392 — $45,376 $26,426 — $49,342
07/12/2023 $37,884 $30,216 — $45,552 $26,157 — $49,612
08/12/2023 $37,884 $30,044 — $45,725 $25,893 — $49,875
09/12/2023 $37,884 $29,875 — $45,893 $25,635 — $50,133
10/12/2023 $37,884 $29,710 — $46,058 $25,383 — $50,386
11/12/2023 $37,884 $29,548 — $46,220 $25,135 — $50,633
12/12/2023 $37,884 $29,389 — $46,379 $24,892 — $50,876
13/12/2023 $37,884 $29,233 — $46,535 $24,654 — $51,115
14/12/2023 $37,884 $29,080 — $46,688 $24,420 — $51,349
15/12/2023 $37,884 $28,930 — $46,839 $24,190 — $51,579

12 Construcción del Dashboard Shiny

12.1 Código de la Aplicación

El dashboard se construye con shinydashboard y se estructura en 9 pestañas, cada una mostrando diferentes aspectos del análisis. Los siguientes bloques de código (ui.R y server.R) deben ser guardados en archivos separados para ejecutar la aplicación Shiny completa, o se pueden integrar aquí para un despliegue local.

12.1.1 UI (ui.R)

# ui.R
library(shiny)
library(shinydashboard)
library(plotly)
library(DT)
library(tidyverse)

# Interfaz de usuario
ui <- dashboardPage(
  dashboardHeader(title = "Crypto EDA Dashboard", titleWidth = 300),
  dashboardSidebar(
    sidebarMenu(
      menuItem("Introducción",      tabName = "intro",       icon = icon("info-circle")),
      menuItem("Visión General",    tabName = "overview",    icon = icon("chart-line")),
      menuItem("Precios",           tabName = "prices",      icon = icon("bitcoin")),
      menuItem("Retornos y Riesgo", tabName = "returns",     icon = icon("chart-pie")),
      menuItem("Correlaciones",     tabName = "correlations",icon = icon("project-diagram")),
      menuItem("Comparador",        tabName = "comparator",  icon = icon("exchange-alt")),
      menuItem("Análisis EDA",      tabName = "eda_model",   icon = icon("chart-bar")),
      menuItem("Modelo ARIMA",      tabName = "arima",       icon = icon("chart-line")),
      menuItem("Predicción Óptima", tabName = "best_pred",   icon = icon("chart-line")),
      menuItem("Valores Faltantes", tabName = "missing",     icon = icon("exclamation-triangle"))
    )
  ),
  dashboardBody(
    tags$head(tags$style(HTML("
      .content-wrapper, .right-side { background-color: #f4f4f4; }
      .small-box { border-radius: 15px; }
      .small-box .icon { font-size: 50px; top: 15px; }
      .main-header .logo { font-weight: bold; }
    "))),
    tabItems(
      tabItem(tabName = "intro",
              fluidRow(
                box(title = "Bienvenido al Crypto EDA Dashboard", status = "primary",
                    solidHeader = TRUE, width = 12,
                    "Este dashboard interactivo permite explorar datos históricos de 10 criptomonedas,
                     realizar análisis de retornos y riesgo, y generar predicciones con modelos ARIMA."
                ),
                box(title = "Equipo", status = "info", width = 6,
                    "Mateo Barrios - Ciencia de Datos",
                    br(),
                    "Rafael Romero - Ciencia de Datos"
                ),
                box(title = "Criptomonedas Analizadas", status = "success", width = 6,
                    paste(names(CRYPTOS), collapse = ", ")
                )
              )
      ),
      tabItem(tabName = "overview",
              fluidRow(
                valueBoxOutput("btc_price"),
                valueBoxOutput("eth_price"),
                valueBoxOutput("sol_price")
              ),
              fluidRow(
                box(title = "Capitalización de Mercado", plotlyOutput("cap_plot"), width = 6),
                box(title = "Volumen 24h",               plotlyOutput("vol_plot"), width = 6)
              ),
              fluidRow(
                box(title = "Visión General", DTOutput("overview_table"), width = 12)
              )
      ),
      tabItem(tabName = "prices",
              fluidRow(
                box(title = "Distribución de Precios", plotlyOutput("price_boxplot"), width = 6),
                box(title = "Serie Temporal BTC",      plotlyOutput("btc_series"),    width = 6)
              ),
              fluidRow(
                box(title = "Bandas de Bollinger BTC", plotlyOutput("bollinger"), width = 12)
              )
      ),
      tabItem(tabName = "returns",
              fluidRow(
                box(title = "Distribución de Retornos", plotlyOutput("ret_boxplot"), width = 6),
                box(title = "Histograma BTC",           plotlyOutput("hist_btc"),    width = 6)
              ),
              fluidRow(
                box(title = "Volatilidad Rodante BTC", plotlyOutput("vol_rolling"), width = 12)
              ),
              fluidRow(
                box(title = "Tabla de Riesgo", DTOutput("risk_table"), width = 12)
              )
      ),
      tabItem(tabName = "correlations",
              fluidRow(
                box(title = "Matriz de Correlación (Pearson)", plotlyOutput("cor_heatmap"), width = 12)
              ),
              fluidRow(
                box(title = "Selecciona dos monedas", width = 4,
                    selectInput("corr_x", "Moneda X", choices = names(CRYPTOS), selected = "BTC"),
                    selectInput("corr_y", "Moneda Y", choices = names(CRYPTOS), selected = "ETH")
                ),
                box(title = "Scatter Plot", plotlyOutput("scatter_plot"), width = 8)
              )
      ),
      tabItem(tabName = "comparator",
              fluidRow(
                box(title = "Rendimiento Acumulado", plotlyOutput("comp_plot"),  width = 12)
              ),
              fluidRow(
                box(title = "Tabla Comparativa",     DTOutput("comp_table"),     width = 12)
              )
      ),
      tabItem(tabName = "eda_model",
              fluidRow(
                box(title = "Boxplot Mensual BTC",    plotlyOutput("monthly_boxplot"), width = 6),
                box(title = "Descomposición STL BTC", plotlyOutput("stl_plot"),        width = 6)
              ),
              fluidRow(
                box(title = "ACF/PACF BTC",   plotlyOutput("acf_pacf"),  width = 12)
              ),
              fluidRow(
                box(title = "Estacionariedad", DTOutput("adf_table"),    width = 12)
              )
      ),
      tabItem(tabName = "arima",
              fluidRow(
                box(title = "Ajuste Modelo ARIMA BTC", verbatimTextOutput("arima_summary"), width = 12)
              ),
              fluidRow(
                box(title = "Diagnóstico de Residuos", plotOutput("resid_plots"), width = 12)
              ),
              fluidRow(
                box(title = "Predicción vs Realidad",  plotlyOutput("forecast_plot"), width = 12)
              ),
              fluidRow(
                box(title = "Métricas de Error", DTOutput("metrics_table"), width = 12)
              )
      ),
      tabItem(tabName = "best_pred",
              fluidRow(
                box(title = "Predicción Óptima 30 días", plotlyOutput("best_forecast_plot"), width = 12)
              ),
              fluidRow(
                box(title = "Tabla de Predicciones", DTOutput("pred_table"), width = 12)
              )
      ),
      tabItem(tabName = "missing",
              fluidRow(
                box(title = "Mapa de Calor de NAs", plotlyOutput("nas_heatmap"), width = 12)
              ),
              fluidRow(
                box(title = "Resumen de NAs", DTOutput("nas_table"), width = 12)
              )
      )
    )
  )
)

12.1.2 Servidor (server.R)

# server.R
server <- function(input, output, session) {

  # Reactive values
  hist_data_react    <- reactive({ hist_data })
  prices_overview_react <- reactive({ prices_overview })

  # --- Visión General ---
  output$btc_price <- renderValueBox({
    price <- prices_overview_react() %>% filter(simbolo == "BTC") %>% pull(precio)
    valueBox(value = dollar(price), subtitle = "Precio BTC", icon = icon("bitcoin"), color = "purple")
  })
  output$eth_price <- renderValueBox({
    price <- prices_overview_react() %>% filter(simbolo == "ETH") %>% pull(precio)
    valueBox(value = dollar(price), subtitle = "Precio ETH", icon = icon("ethereum"), color = "blue")
  })
  output$sol_price <- renderValueBox({
    price <- prices_overview_react() %>% filter(simbolo == "SOL") %>% pull(precio)
    valueBox(value = dollar(price), subtitle = "Precio SOL", icon = icon("sun"), color = "green")
  })

  output$cap_plot <- renderPlotly({
    p <- prices_overview_react() %>%
      mutate(simbolo = reorder(simbolo, cap_mercado)) %>%
      ggplot(aes(x = simbolo, y = cap_mercado / 1e9, fill = simbolo)) +
      geom_col(show.legend = FALSE, alpha = 0.85) +
      geom_text(aes(label = paste0("$", round(cap_mercado / 1e9, 1), "B")),
                hjust = -0.1, size = 3.5) +
      coord_flip() +
      scale_fill_manual(values = COLOR_PALETTE) +
      scale_y_continuous(expand = expansion(mult = c(0, 0.15)),
                         labels = dollar_format(suffix = "B", scale = 1)) +
      labs(title = "Capitalización de Mercado", x = NULL, y = "Miles de millones USD") +
      theme_minimal(base_size = 12)
    ggplotly(p, tooltip = "y")
  })

  output$vol_plot <- renderPlotly({
    p <- prices_overview_react() %>%
      mutate(simbolo = reorder(simbolo, volumen_24h)) %>%
      ggplot(aes(x = simbolo, y = volumen_24h / 1e6, fill = simbolo)) +
      geom_col(show.legend = FALSE, alpha = 0.85) +
      coord_flip() +
      scale_fill_manual(values = COLOR_PALETTE) +
      scale_y_continuous(labels = dollar_format(suffix = "M", scale = 1)) +
      labs(title = "Volumen 24h", x = NULL, y = "Millones USD") +
      theme_minimal(base_size = 12)
    ggplotly(p, tooltip = "y")
  })

  output$overview_table <- renderDT({
    prices_overview_react() %>%
      mutate(
        precio         = dollar(precio, accuracy = 0.01),
        cambio_24h_pct = paste0(round(cambio_24h_pct, 2), "%"),
        volumen_24h    = dollar(volumen_24h, accuracy = 1, scale = 1e-6, suffix = "M"),
        cap_mercado    = dollar(cap_mercado, accuracy = 1, scale = 1e-9, suffix = "B")
      ) %>%
      rename(Símbolo = simbolo, `Precio USD` = precio, `Cambio 24h` = cambio_24h_pct,
             `Volumen 24h` = volumen_24h, `Cap. Mercado` = cap_mercado) %>%
      datatable(options = list(pageLength = 10, scrollX = TRUE), rownames = FALSE)
  })

  # --- Precios (sin candlestick) ---
  output$price_boxplot <- renderPlotly({
    df <- hist_data_react() %>% filter(fecha >= max(fecha) - 365)
    p <- ggplot(df, aes(x = simbolo, y = close, fill = simbolo)) +
      geom_boxplot(alpha = 0.7, outlier.color = "#e74c3c", outlier.size = 1.5) +
      stat_summary(fun = "mean", geom = "point", shape = 18, size = 4, color = "white") +
      scale_y_log10(labels = dollar) +
      scale_fill_manual(values = COLOR_PALETTE) +
      coord_flip() +
      labs(title = "Distribución de Precios de Cierre (últimos 365 días)",
           x = NULL, y = "Precio USD (escala log)") +
      theme_minimal(base_size = 12) +
      theme(legend.position = "none")
    ggplotly(p, tooltip = c("x", "y"))
  })

  output$btc_series <- renderPlotly({
    df <- hist_data_react() %>% filter(simbolo == "BTC") %>% arrange(fecha)
    p <- ggplot(df, aes(x = fecha, y = close)) +
      geom_line(color = "#F7931A", linewidth = 0.7) +
      scale_y_continuous(labels = dollar) +
      labs(title = "Bitcoin — Precio de Cierre Histórico", x = NULL, y = "Precio (USD)") +
      theme_minimal(base_size = 12)
    ggplotly(p, tooltip = c("x", "y"))
  })

  output$bollinger <- renderPlotly({
    df_btc <- hist_data_react() %>%
      filter(simbolo == "BTC") %>% arrange(fecha) %>%
      select(fecha, close) %>% drop_na()
    bb <- calculate_bollinger_bands(df_btc$close, window = 20, sd_mult = 2)
    df_bb <- cbind(df_btc, bb) |> drop_na(sma, upper, lower) |> tail(365)
    p <- ggplot(df_bb, aes(x = fecha)) +
      geom_ribbon(aes(ymin = lower, ymax = upper), fill = "#3498db", alpha = 0.18) +
      geom_line(aes(y = close, color = "Precio"),         linewidth = 0.8) +
      geom_line(aes(y = sma,   color = "SMA 20"),         linetype = "dashed", linewidth = 0.7) +
      geom_line(aes(y = upper, color = "Banda Superior"), linetype = "dotted", linewidth = 0.6) +
      geom_line(aes(y = lower, color = "Banda Inferior"), linetype = "dotted", linewidth = 0.6) +
      scale_color_manual(values = c(
        "Precio" = "#e94560", "SMA 20" = "#F7931A",
        "Banda Superior" = "#2ecc71", "Banda Inferior" = "#2ecc71"
      )) +
      scale_y_continuous(labels = dollar) +
      labs(title = "Bandas de Bollinger — BTC (SMA 20, k = 2)",
           x = NULL, y = "Precio (USD)", color = NULL) +
      theme_minimal(base_size = 12) + theme(legend.position = "bottom")
    ggplotly(p, tooltip = c("x", "y"))
  })

  # --- Retornos y Riesgo ---
  output$ret_boxplot <- renderPlotly({
    df <- hist_data_react() %>% filter(fecha >= max(fecha) - 365)
    p <- ggplot(df, aes(x = simbolo, y = retorno, fill = simbolo)) +
      geom_boxplot(alpha = 0.7, outlier.color = "#e74c3c", outlier.size = 1) +
      geom_hline(yintercept = 0, linetype = "dashed", color = "gray50") +
      scale_fill_manual(values = COLOR_PALETTE) +
      coord_flip() +
      labs(title = "Distribución de Retornos Diarios", x = NULL, y = "Retorno (%)") +
      theme_minimal(base_size = 12) + theme(legend.position = "none")
    ggplotly(p, tooltip = c("x", "y"))
  })

  output$hist_btc <- renderPlotly({
    df <- hist_data_react() %>% filter(simbolo == "BTC") %>% drop_na(retorno)
    p <- ggplot(df, aes(x = retorno, fill = after_stat(x) > 0)) +
      geom_histogram(bins = 60, alpha = 0.85) +
      scale_fill_manual(values = c("TRUE" = "#2ecc71", "FALSE" = "#e74c3c"),
                        labels = c("TRUE" = "Positivo", "FALSE" = "Negativo"), name = NULL) +
      geom_vline(xintercept = 0, linetype = "dashed", color = "black") +
      labs(title = "Histograma de Retornos Diarios — BTC",
           x = "Retorno (%)", y = "Frecuencia") +
      theme_minimal(base_size = 12) + theme(legend.position = "top")
    ggplotly(p, tooltip = c("x", "y"))
  })

  output$vol_rolling <- renderPlotly({
    df_btc <- hist_data_react() %>%
      filter(simbolo == "BTC") %>% arrange(fecha) %>%
      mutate(vol30 = zoo::rollapply(retorno, 30, sd, fill = NA, align = "right")) %>%
      drop_na(vol30)
    p <- ggplot(df_btc, aes(x = fecha, y = vol30)) +
      geom_line(color = "#e94560", linewidth = 0.7) +
      geom_area(fill = "#e94560", alpha = 0.15) +
      labs(title = "Volatilidad Rodante 30 días — BTC",
           x = NULL, y = "Desv. Est. Retorno (%)") +
      theme_minimal(base_size = 12)
    ggplotly(p, tooltip = c("x", "y"))
  })

  output$risk_table <- renderDT({
    df <- hist_data_react() %>%
      filter(fecha >= max(fecha) - 365) %>%
      group_by(simbolo) %>%
      summarise(
        `Ret. Medio (%)` = round(mean(retorno, na.rm = TRUE), 3),
        `Desv. Est. (%)` = round(sd(retorno, na.rm = TRUE), 3),
        `VaR 95% (%)`    = round(quantile(retorno, 0.05, na.rm = TRUE), 3),
        `Días pos. (%)`  = round(sum(retorno > 0, na.rm = TRUE) / n() * 100, 1)
      ) %>% arrange(desc(`Ret. Medio (%)`))
    datatable(df, options = list(pageLength = 10, scrollX = TRUE), rownames = FALSE)
  })

  # --- Correlaciones ---
  output$cor_heatmap <- renderPlotly({
    df_wide <- hist_data_react() %>%
      filter(fecha >= max(fecha) - 365) %>%
      select(fecha, simbolo, retorno) %>%
      pivot_wider(names_from = simbolo, values_from = retorno) %>%
      select(-fecha)
    mat_cor <- cor(df_wide, use = "complete.obs", method = "pearson")
    plot_ly(
      x = colnames(mat_cor), y = colnames(mat_cor), z = mat_cor,
      type = "heatmap", colorscale = "RdBu", reversescale = TRUE, zmin = -1, zmax = 1
    ) %>%
      layout(title = "Mapa de Calor de Correlaciones (Pearson)",
             xaxis = list(title = ""), yaxis = list(title = ""))
  })

  output$scatter_plot <- renderPlotly({
    x_sym <- input$corr_x; y_sym <- input$corr_y
    df <- hist_data_react() %>%
      filter(fecha >= max(fecha) - 365, simbolo %in% c(x_sym, y_sym)) %>%
      select(fecha, simbolo, retorno) %>%
      pivot_wider(names_from = simbolo, values_from = retorno) %>% drop_na()
    p <- ggplot(df, aes(x = .data[[x_sym]], y = .data[[y_sym]])) +
      geom_point(alpha = 0.4, color = "#627EEA", size = 1.5) +
      geom_smooth(method = "lm", se = TRUE, color = "#e94560", linewidth = 1) +
      labs(title = paste("Retornos Diarios:", x_sym, "vs", y_sym),
           x = paste("Retorno", x_sym, "(%)"), y = paste("Retorno", y_sym, "(%)")) +
      theme_minimal(base_size = 12)
    ggplotly(p, tooltip = c("x", "y"))
  })

  # --- Comparador ---
  output$comp_plot <- renderPlotly({
    df <- hist_data_react() %>%
      filter(fecha >= max(fecha) - 365) %>% arrange(fecha) %>%
      group_by(simbolo) %>%
      mutate(ini = first(close), norm = close / ini * 100) %>% ungroup()
    p <- ggplot(df, aes(x = fecha, y = norm, color = simbolo)) +
      geom_line(linewidth = 0.8, alpha = 0.9) +
      geom_hline(yintercept = 100, linetype = "dashed", color = "gray50") +
      scale_color_manual(values = COLOR_PALETTE) +
      labs(title = "Rendimiento Acumulado — Base 100 (últimos 365 días)",
           x = NULL, y = "Índice (base = 100)", color = "Moneda") +
      theme_minimal(base_size = 12) + theme(legend.position = "right")
    ggplotly(p, tooltip = c("x", "y", "colour"))
  })

  output$comp_table <- renderDT({
    df <- hist_data_react() %>%
      filter(fecha >= max(fecha) - 365) %>%
      group_by(simbolo) %>%
      summarise(
        `P. Inicial ($)` = round(first(close), 4),
        `P. Final ($)`   = round(last(close), 4),
        `Rend. (%)`      = round((last(close) - first(close)) / first(close) * 100, 2),
        `Vol. (%)`       = round(sd(retorno, na.rm = TRUE), 3)
      ) %>% arrange(desc(`Rend. (%)`))
    datatable(df, options = list(pageLength = 10, scrollX = TRUE), rownames = FALSE)
  })

  # --- Análisis EDA ---
  output$monthly_boxplot <- renderPlotly({
    df_btc <- hist_data_react() %>%
      filter(simbolo == "BTC") %>%
      mutate(mes = floor_date(fecha, "month"), mes_label = format(mes, "%b %Y"))
    p <- ggplot(df_btc, aes(x = reorder(mes_label, mes), y = retorno)) +
      geom_boxplot(fill = "#3498db", alpha = 0.7, outlier.size = 1) +
      geom_hline(yintercept = 0, linetype = "dashed", color = "gray40") +
      labs(title = "Retornos Diarios de BTC por Mes", x = NULL, y = "Retorno (%)") +
      theme_minimal(base_size = 11) +
      theme(axis.text.x = element_text(angle = 90, hjust = 1, size = 7))
    ggplotly(p, tooltip = c("x", "y"))
  })

  output$acf_pacf <- renderPlotly({
    serie <- hist_data_react() %>%
      filter(simbolo == "BTC") %>% pull(retorno) %>% na.omit()
    ci_val <- qnorm(0.975) / sqrt(length(serie))
    acf_r  <- acf(serie, plot = FALSE, lag.max = 40)
    pacf_r <- pacf(serie, plot = FALSE, lag.max = 40)
    df_acf  <- data.frame(Lag = as.numeric(acf_r$lag[-1]),  ACF  = as.numeric(acf_r$acf[-1]))
    df_pacf <- data.frame(Lag = as.numeric(pacf_r$lag),     PACF = as.numeric(pacf_r$acf))
    p1 <- ggplot(df_acf, aes(x = Lag, y = ACF)) +
      geom_bar(stat = "identity", fill = "#3498db", alpha = 0.7) +
      geom_hline(yintercept = c(ci_val, -ci_val), linetype = "dashed",
                 color = "blue", linewidth = 0.7) +
      labs(title = "ACF — Retornos BTC") + theme_minimal(base_size = 11)
    p2 <- ggplot(df_pacf, aes(x = Lag, y = PACF)) +
      geom_bar(stat = "identity", fill = "#e74c3c", alpha = 0.7) +
      geom_hline(yintercept = c(ci_val, -ci_val), linetype = "dashed",
                 color = "blue", linewidth = 0.7) +
      labs(title = "PACF — Retornos BTC") + theme_minimal(base_size = 11)
    subplot(ggplotly(p1), ggplotly(p2), nrows = 1, shareX = TRUE, titleX = TRUE, titleY = TRUE)
  })

  output$stl_plot <- renderPlotly({
    serie_btc <- hist_data_react() %>%
      filter(simbolo == "BTC") %>% pull(retorno) %>% na.omit()
    serie_stl <- ts(serie_btc, frequency = 7)
    decomp <- stl(serie_stl, s.window = "periodic", robust = TRUE)
    df_decomp <- data.frame(
      time      = as.numeric(time(decomp$time.series)),
      trend     = as.numeric(decomp$time.series[, "trend"]),
      seasonal  = as.numeric(decomp$time.series[, "seasonal"]),
      remainder = as.numeric(decomp$time.series[, "remainder"])
    )
    p_trend <- ggplot(df_decomp, aes(x = time, y = trend))     + geom_line(color = "#2c3e50") + labs(y = "Tendencia")     + theme_minimal()
    p_seas  <- ggplot(df_decomp, aes(x = time, y = seasonal))  + geom_line(color = "#3498db") + labs(y = "Estacionalidad")+ theme_minimal()
    p_rem   <- ggplot(df_decomp, aes(x = time, y = remainder))  + geom_line(color = "#e74c3c") + labs(y = "Residuo")       + theme_minimal()
    subplot(ggplotly(p_trend), ggplotly(p_seas), ggplotly(p_rem),
            nrows = 3, shareX = TRUE, titleY = TRUE)
  })

  output$adf_table <- renderDT({
    cryptos <- unique(hist_data$simbolo)
    adf_res <- lapply(cryptos, function(cr) {
      serie <- na.omit(hist_data[hist_data$simbolo == cr, "retorno"][[1]])
      res   <- test_stationarity(serie)
      data.frame(
        Moneda = cr,
        `ADF Stat.` = round(res$estadistico, 4),
        `p-valor`   = round(res$p_valor, 6),
        Estacionaria = ifelse(res$es_estacionaria, "✅ Sí", "❌ No"),
        `d recomendado` = ifelse(res$es_estacionaria, 0, 1)
      )
    }) |> bind_rows()
    datatable(adf_res, options = list(pageLength = 10, scrollX = TRUE), rownames = FALSE)
  })

  # --- Modelo ARIMA ---
  output$arima_summary <- renderPrint({ summary(best_model) })

  output$resid_plots <- renderPlot({
    par(mfrow = c(1, 2))
    hist(resid_model, main = "Histograma de Residuos", xlab = "Residuo", col = "#627EEA")
    qqnorm(resid_model, main = "Q-Q Plot")
    qqline(resid_model, col = "red")
  })

  output$forecast_plot <- renderPlotly({
    n_hist <- min(200, length(train))
    df_forecast <- bind_rows(
      data.frame(Fecha = tail(train_dates, n_hist),  Precio = tail(train, n_hist), Tipo = "Entrenamiento"),
      data.frame(Fecha = test_dates[1:horizonte],    Precio = test[1:horizonte],   Tipo = "Test Real"),
      data.frame(Fecha = test_dates[1:horizonte],    Precio = pred,                Tipo = "Predicción"),
      data.frame(Fecha = test_dates[1:horizonte],    Precio = as.numeric(fc$lower[,2]), Tipo = "IC 95% Inferior"),
      data.frame(Fecha = test_dates[1:horizonte],    Precio = as.numeric(fc$upper[,2]), Tipo = "IC 95% Superior")
    )
    df_ribbon <- data.frame(
      Fecha = test_dates[1:horizonte],
      Lower = as.numeric(fc$lower[,2]),
      Upper = as.numeric(fc$upper[,2])
    )
    df_lines <- df_forecast |> filter(Tipo %in% c("Entrenamiento","Test Real","Predicción"))
    p <- ggplot() +
      geom_ribbon(data = df_ribbon, aes(x = Fecha, ymin = Lower, ymax = Upper),
                  fill = "#3498db", alpha = 0.2) +
      geom_line(data = df_lines, aes(x = Fecha, y = Precio, color = Tipo, linewidth = Tipo)) +
      scale_color_manual(values = c(
        "Entrenamiento" = "#2c3e50", "Test Real" = "#3498db", "Predicción" = "#e74c3c"
      )) +
      scale_linewidth_manual(values = c(
        "Entrenamiento" = 0.7, "Test Real" = 0.9, "Predicción" = 1.1
      )) +
      scale_y_continuous(labels = dollar) +
      labs(
        title    = sprintf("ARIMA(%d,%d,%d) — Predicción vs Realidad — BTC",
                           best_order[1], best_order[2], best_order[3]),
        subtitle = sprintf("MAE: $%.0f | RMSE: $%.0f | MAPE: %.2f%% | R²: %.4f",
                           mae, rmse, mape, r2),
        x = NULL, y = "Precio (USD)", color = NULL, linewidth = NULL
      ) +
      theme_minimal(base_size = 12) + theme(legend.position = "bottom")
    ggplotly(p, tooltip = c("x", "y", "colour"))
  })

  output$metrics_table <- renderDT({
    metricas <- data.frame(
      Métrica = c("MAE (USD)", "RMSE (USD)", "MAPE (%)", "R²"),
      Valor   = round(c(mae, rmse, mape, r2), 4)
    )
    datatable(metricas, options = list(dom = 't'), rownames = FALSE)
  })

  # --- Predicción Óptima ---
  output$best_forecast_plot <- renderPlotly({
    n_ctx <- min(120, length(train_opt))
    df_ctx <- data.frame(
      Fecha  = tail(train_dates_opt, n_ctx),
      Precio = tail(train_opt, n_ctx)
    )
    p <- ggplot() +
      geom_line(data = df_ctx, aes(x = Fecha, y = Precio),
                color = "#2c3e50", linewidth = 0.8) +
      geom_ribbon(data = df_pred_opt,
                  aes(x = Fecha, ymin = Lower_95, ymax = Upper_95),
                  fill = "#3498db", alpha = 0.18) +
      geom_ribbon(data = df_pred_opt,
                  aes(x = Fecha, ymin = Lower_80, ymax = Upper_80),
                  fill = "#3498db", alpha = 0.28) +
      geom_line(data = df_pred_opt, aes(x = Fecha, y = Prediccion),
                color = "#e74c3c", linewidth = 1.1) +
      scale_y_continuous(labels = dollar) +
      labs(
        title    = sprintf("Predicción Óptima 30 días — BTC | ARIMA(%d,%d,%d) por BIC",
                           best_ord2[1], best_ord2[2], best_ord2[3]),
        subtitle = "Azul: IC 80% y 95% | Rojo: Predicción puntual | Gris: Historial",
        x = NULL, y = "Precio (USD)"
      ) +
      theme_minimal(base_size = 12)
    ggplotly(p, tooltip = c("x", "y"))
  })

  output$pred_table <- renderDT({
    df_pred_opt %>%
      mutate(
        Fecha      = format(Fecha, "%d/%m/%Y"),
        Prediccion = dollar(Prediccion, accuracy = 1),
        `IC 80%`   = paste0(dollar(Lower_80, accuracy = 1), " — ", dollar(Upper_80, accuracy = 1)),
        `IC 95%`   = paste0(dollar(Lower_95, accuracy = 1), " — ", dollar(Upper_95, accuracy = 1))
      ) %>%
      select(Fecha, Prediccion, `IC 80%`, `IC 95%`) %>%
      datatable(options = list(pageLength = 10, scrollX = TRUE), rownames = FALSE)
  })

  # --- Valores Faltantes ---
  output$nas_heatmap <- renderPlotly({
    cols <- c("close","retorno","retorno_log","volatilidad")
    df_heat <- hist_data_react() %>%
      group_by(simbolo) %>%
      summarise(across(all_of(intersect(cols, names(hist_data_react()))),
                       ~ sum(is.na(.)) / n() * 100, .names = "{.col}")) %>%
      pivot_longer(-simbolo, names_to = "Variable", values_to = "pct_na")
    p <- ggplot(df_heat, aes(x = Variable, y = simbolo, fill = pct_na)) +
      geom_tile(color = "white", linewidth = 0.5) +
      geom_text(aes(label = paste0(round(pct_na, 1), "%")), size = 3) +
      scale_fill_gradient(low = "white", high = "#e74c3c", name = "% NA") +
      labs(title = "Porcentaje de NAs por moneda y variable", x = NULL, y = NULL) +
      theme_minimal(base_size = 12) +
      theme(axis.text.x = element_text(angle = 30, hjust = 1))
    ggplotly(p, tooltip = c("x", "y", "fill"))
  })

  output$nas_table <- renderDT({
    ms_global <- hist_data_react() %>%
      group_by(simbolo) %>%
      summarise(
        Total_filas  = n(),
        NAs_close    = sum(is.na(close)),
        NAs_retorno  = sum(is.na(retorno)),
        NAs_vol      = sum(is.na(volatilidad)),
        Pct_NA_close = round(NAs_close / Total_filas * 100, 2)
      )
    datatable(ms_global, options = list(pageLength = 10, scrollX = TRUE), rownames = FALSE)
  })
}

# Ejecutar la aplicación
shinyApp(ui = ui, server = server)

12.2 Instrucciones de Ejecución

Para ejecutar el dashboard localmente, guarda los códigos anteriores en archivos ui.R y server.R en el mismo directorio, y luego ejecuta:

shiny::runApp()

También puedes desplegar el dashboard en shinyapps.io utilizando el paquete rsconnect