Objetivo: Detectar asimetría, colas pesadas y comportamientos extremos en los retornos diarios.
Objetivo: Segmentar los activos por nivel de riesgo y comparar su comportamiento colectivo.
Objetivo: Identificar relaciones lineales y no lineales entre activos para diversificación de portafolio.
---
title: "Dashboard Financiero — S&P500 Top 10"
author: "Jhonatan"
date: "2026-03-10"
output:
flexdashboard::flex_dashboard:
orientation: rows
vertical_layout: scroll
theme: flatly
source_code: embed
navbar:
- { title: "Taller 1-2", icon: "fa-chart-line", align: right }
---
```{r setup, include=FALSE}
# ── Librerías ──────────────────────────────────────────────────────────────────
library(flexdashboard)
library(readr)
library(dplyr)
library(tidyr)
library(tibble)
library(ggplot2)
library(plotly)
library(DT)
library(scales)
library(moments) # skewness / kurtosis
library(corrplot) # correlación
library(ggridges) # ridgeline plots
# ── Paleta corporativa ─────────────────────────────────────────────────────────
PALETTE <- c(
"#2C3E50","#E74C3C","#3498DB","#2ECC71","#F39C12",
"#9B59B6","#1ABC9C","#E67E22","#34495E","#E91E63"
)
# ── Carga de datos ─────────────────────────────────────────────────────────────
sp500_raw <- read_csv("sp500_top10_stocks_clean.csv", show_col_types = FALSE)
# ── Pipeline principal ─────────────────────────────────────────────────────────
sp500_reducido <- sp500_raw %>% select(Date, Ticker, Close)
sp500_ancho <- sp500_reducido %>%
pivot_wider(names_from = Ticker, values_from = Close) %>%
mutate(across(where(is.numeric), ~ round(., 2)))
sp500_imputado <- sp500_ancho %>% drop_na()
sp500_retornos <- sp500_imputado %>%
pivot_longer(cols = -Date, names_to = "Ticker", values_to = "Precio") %>%
arrange(Ticker, Date) %>%
group_by(Ticker) %>%
mutate(Retorno = (Precio - lag(Precio)) / lag(Precio) * 100) %>%
filter(!is.na(Retorno)) %>%
ungroup()
resumen <- sp500_retornos %>%
group_by(Ticker) %>%
summarise(
Media = round(mean(Retorno), 4),
Mediana = round(median(Retorno), 4),
Desv_Std = round(sd(Retorno), 4),
Asimetria= round(skewness(Retorno), 4),
Curtosis = round(kurtosis(Retorno), 4),
VaR_95 = round(quantile(Retorno, 0.05), 4),
CVaR_95 = round(mean(Retorno[Retorno <= quantile(Retorno, 0.05)]), 4),
Sharpe = round(mean(Retorno) / sd(Retorno), 4),
Min = round(min(Retorno), 4),
Max = round(max(Retorno), 4),
.groups = "drop"
) %>%
arrange(desc(Desv_Std))
tickers <- unique(sp500_retornos$Ticker)
n_obs <- nrow(sp500_imputado)
n_dias <- as.numeric(diff(range(sp500_imputado$Date)))
```
<!-- ═══════════════════════════════════════════════════════════════════
PÁGINA 1 · KPIs CON INCERTIDUMBRE
═══════════════════════════════════════════════════════════════════ -->
# 📊 KPIs
## Row {data-height=130}
### Tickers Analizados
```{r}
valueBox(length(tickers), icon = "fa-th", color = "#2C3E50")
```
### Observaciones (días limpios)
```{r}
valueBox(n_obs, icon = "fa-calendar-check", color = "#3498DB")
```
### Ticker Más Volátil
```{r}
top_vol <- resumen %>% slice_max(Desv_Std, n = 1)
valueBox(
paste0(top_vol$Ticker, " (", top_vol$Desv_Std, "%)"),
icon = "fa-bolt",
color = "#E74C3C"
)
```
### Mejor Sharpe Ratio
```{r}
top_sharpe <- resumen %>% slice_max(Sharpe, n = 1)
valueBox(
paste0(top_sharpe$Ticker, " (", top_sharpe$Sharpe, ")"),
icon = "fa-trophy",
color = "#2ECC71"
)
```
### Peor VaR 95%
```{r}
worst_var <- resumen %>% slice_min(VaR_95, n = 1)
valueBox(
paste0(worst_var$Ticker, " (", worst_var$VaR_95, "%)"),
icon = "fa-exclamation-triangle",
color = "#E67E22"
)
```
## Row {data-height=420}
### 📋 Tabla KPIs completa {data-width=700}
```{r}
datatable(
resumen %>% select(Ticker, Media, Desv_Std, Sharpe, VaR_95, CVaR_95, Asimetria, Curtosis),
rownames = FALSE,
options = list(pageLength = 10, dom = "frtip", scrollX = TRUE),
caption = "KPIs de Retornos Diarios (%) con métricas de riesgo"
) %>%
formatStyle("Desv_Std",
background = styleColorBar(resumen$Desv_Std, "#E74C3C33"),
backgroundSize = "100% 90%", backgroundRepeat = "no-repeat",
backgroundPosition = "center"
) %>%
formatStyle("Sharpe",
color = styleInterval(0, c("#E74C3C", "#2ECC71")),
fontWeight = "bold"
) %>%
formatStyle("VaR_95",
color = "#E74C3C", fontWeight = "bold"
)
```
### 📈 Sharpe vs Volatilidad {data-width=500}
```{r}
p <- ggplot(resumen, aes(x = Desv_Std, y = Sharpe, color = Ticker, size = abs(VaR_95))) +
geom_point(alpha = 0.85) +
geom_hline(yintercept = 0, linetype = "dashed", color = "gray50") +
geom_text(aes(label = Ticker), vjust = -1.1, size = 3, show.legend = FALSE) +
scale_color_manual(values = PALETTE) +
labs(
title = "Riesgo vs Retorno ajustado",
x = "Volatilidad (Desv. Std %)",
y = "Sharpe Ratio",
size = "|VaR 95%|",
color = "Ticker"
) +
theme_minimal(base_size = 12)
ggplotly(p, tooltip = c("Ticker","Desv_Std","Sharpe","VaR_95")) %>%
layout(legend = list(orientation = "h", y = -0.2))
```
<!-- ═══════════════════════════════════════════════════════════════════
PÁGINA 2 · DISTRIBUCIÓN Y COLAS
═══════════════════════════════════════════════════════════════════ -->
# 📉 Distribución & Colas
## Row {data-height=50}
> **Objetivo:** Detectar asimetría, colas pesadas y comportamientos extremos en los retornos diarios.
## Row {data-height=420}
### 🎻 Violin + Boxplot — Dispersión completa
```{r}
p <- ggplot(sp500_retornos,
aes(x = reorder(Ticker, Retorno, FUN = sd),
y = Retorno, fill = Ticker)) +
geom_violin(alpha = 0.55, trim = FALSE) +
geom_boxplot(width = 0.18, outlier.color = "#E74C3C",
outlier.size = 1.2, alpha = 0.8) +
geom_hline(yintercept = 0, linetype = "dashed", color = "gray40") +
scale_fill_manual(values = PALETTE) +
labs(title = "Distribución de Retornos Diarios por Ticker",
subtitle = "Ordenado de menor a mayor volatilidad",
x = "Ticker", y = "Retorno Diario (%)") +
theme_minimal(base_size = 12) +
theme(legend.position = "none")
ggplotly(p)
```
### 🌊 Ridgeline — Densidades por Ticker
```{r}
p <- ggplot(sp500_retornos,
aes(x = Retorno, y = reorder(Ticker, Retorno, FUN = sd),
fill = after_stat(x))) +
geom_density_ridges_gradient(scale = 2.5, rel_min_height = 0.01,
quantile_lines = TRUE, quantiles = c(0.05, 0.95)) +
scale_fill_gradient2(low = "#E74C3C", mid = "#ECF0F1", high = "#2ECC71",
midpoint = 0) +
geom_vline(xintercept = 0, linetype = "dashed", color = "gray40") +
labs(title = "Densidad de Retornos por Ticker",
subtitle = "Líneas verticales: percentiles 5% y 95%",
x = "Retorno Diario (%)", y = "Ticker") +
theme_minimal(base_size = 12) +
theme(legend.position = "none")
ggplotly(p)
```
## Row {data-height=380}
### 📐 Asimetría vs Curtosis — Forma de las colas
```{r}
p <- ggplot(resumen, aes(x = Asimetria, y = Curtosis,
color = Ticker, size = Desv_Std)) +
geom_point(alpha = 0.85) +
geom_vline(xintercept = 0, linetype = "dashed", color = "gray50") +
geom_hline(yintercept = 3, linetype = "dashed", color = "#3498DB",
linewidth = 0.8) +
annotate("text", x = max(resumen$Asimetria)*0.85, y = 3.2,
label = "Normal (curtosis=3)", color = "#3498DB", size = 3.5) +
geom_text(aes(label = Ticker), vjust = -1, size = 3, show.legend = FALSE) +
scale_color_manual(values = PALETTE) +
labs(title = "Forma de la distribución de retornos",
subtitle = "Curtosis > 3 = colas pesadas (leptocúrtica)",
x = "Asimetría (Skewness)", y = "Curtosis",
size = "Volatilidad", color = "Ticker") +
theme_minimal(base_size = 12)
ggplotly(p, tooltip = c("Ticker","Asimetria","Curtosis","Desv_Std"))
```
### 🔥 VaR y CVaR 95% — Riesgo de cola
```{r}
riesgo_df <- resumen %>%
select(Ticker, VaR_95, CVaR_95) %>%
pivot_longer(cols = c(VaR_95, CVaR_95), names_to = "Metrica", values_to = "Valor")
p <- ggplot(riesgo_df, aes(x = reorder(Ticker, Valor, FUN = min),
y = Valor, fill = Metrica)) +
geom_col(position = "dodge", alpha = 0.85) +
scale_fill_manual(values = c("VaR_95" = "#E74C3C", "CVaR_95" = "#C0392B"),
labels = c("CVaR 95%", "VaR 95%")) +
geom_hline(yintercept = 0, color = "gray40") +
labs(title = "Riesgo de Cola: VaR y CVaR al 95%",
subtitle = "Valores más negativos = mayor riesgo extremo",
x = "Ticker", y = "Retorno (%)", fill = "Métrica") +
theme_minimal(base_size = 12) +
coord_flip()
ggplotly(p)
```
<!-- ═══════════════════════════════════════════════════════════════════
PÁGINA 3 · COMPARACIONES POR GRUPOS
═══════════════════════════════════════════════════════════════════ -->
# 🔍 Comparaciones
## Row {data-height=50}
> **Objetivo:** Segmentar los activos por nivel de riesgo y comparar su comportamiento colectivo.
## Row {data-height=420}
### 📊 Clasificación por Volatilidad
```{r}
resumen_grupo <- resumen %>%
mutate(
Grupo = case_when(
Desv_Std < quantile(Desv_Std, 0.33) ~ "🟢 Baja Volatilidad",
Desv_Std < quantile(Desv_Std, 0.66) ~ "🟡 Media Volatilidad",
TRUE ~ "🔴 Alta Volatilidad"
)
)
p <- ggplot(resumen_grupo,
aes(x = reorder(Ticker, Desv_Std), y = Desv_Std,
fill = Grupo)) +
geom_col(alpha = 0.88) +
scale_fill_manual(values = c(
"🟢 Baja Volatilidad" = "#2ECC71",
"🟡 Media Volatilidad" = "#F39C12",
"🔴 Alta Volatilidad" = "#E74C3C"
)) +
geom_text(aes(label = paste0(Desv_Std, "%")), hjust = -0.1, size = 3.2) +
labs(title = "Clasificación de Tickers por Volatilidad",
x = "Ticker", y = "Desv. Std (%)", fill = "Grupo") +
theme_minimal(base_size = 12) +
coord_flip() +
scale_y_continuous(expand = expansion(mult = c(0, 0.15)))
ggplotly(p)
```
### 📦 Boxplot comparativo por grupo
```{r}
sp500_grupos <- sp500_retornos %>%
left_join(resumen_grupo %>% select(Ticker, Grupo), by = "Ticker")
p <- ggplot(sp500_grupos, aes(x = Grupo, y = Retorno, fill = Grupo)) +
geom_boxplot(outlier.color = "#E74C3C", outlier.size = 1, alpha = 0.8) +
geom_jitter(width = 0.1, alpha = 0.05, color = "gray40") +
geom_hline(yintercept = 0, linetype = "dashed", color = "gray40") +
scale_fill_manual(values = c(
"🟢 Baja Volatilidad" = "#2ECC71",
"🟡 Media Volatilidad" = "#F39C12",
"🔴 Alta Volatilidad" = "#E74C3C"
)) +
labs(title = "Distribución de Retornos por Grupo de Riesgo",
x = "Grupo", y = "Retorno Diario (%)") +
theme_minimal(base_size = 12) +
theme(legend.position = "none")
ggplotly(p)
```
## Row {data-height=380}
### 📈 Serie de precios normalizados (base 100)
```{r}
sp500_norm <- sp500_imputado %>%
pivot_longer(cols = -Date, names_to = "Ticker", values_to = "Precio") %>%
group_by(Ticker) %>%
mutate(Precio_Norm = Precio / first(Precio) * 100) %>%
ungroup()
p <- ggplot(sp500_norm, aes(x = Date, y = Precio_Norm,
color = Ticker, group = Ticker)) +
geom_line(alpha = 0.85, linewidth = 0.7) +
geom_hline(yintercept = 100, linetype = "dashed", color = "gray40") +
scale_color_manual(values = PALETTE) +
labs(title = "Evolución de Precios Normalizados (Base 100)",
subtitle = "Permite comparar rendimiento relativo entre activos",
x = "Fecha", y = "Precio Indexado (Base 100)", color = "Ticker") +
theme_minimal(base_size = 12)
ggplotly(p) %>%
layout(legend = list(orientation = "h", y = -0.2))
```
### 📊 Retorno acumulado por Ticker
```{r}
sp500_acum <- sp500_retornos %>%
group_by(Ticker) %>%
arrange(Date) %>%
mutate(Retorno_Acum = cumprod(1 + Retorno / 100) - 1) %>%
ungroup()
p <- ggplot(sp500_acum, aes(x = Date, y = Retorno_Acum * 100,
color = Ticker, group = Ticker)) +
geom_line(alpha = 0.85, linewidth = 0.7) +
geom_hline(yintercept = 0, linetype = "dashed", color = "gray40") +
scale_color_manual(values = PALETTE) +
scale_y_continuous(labels = percent_format(scale = 1, suffix = "%")) +
labs(title = "Retorno Acumulado por Ticker (%)",
x = "Fecha", y = "Retorno Acumulado (%)", color = "Ticker") +
theme_minimal(base_size = 12)
ggplotly(p) %>%
layout(legend = list(orientation = "h", y = -0.2))
```
<!-- ═══════════════════════════════════════════════════════════════════
PÁGINA 4 · DEPENDENCIA (CORRELACIONES)
═══════════════════════════════════════════════════════════════════ -->
# 🔗 Dependencia
## Row {data-height=50}
> **Objetivo:** Identificar relaciones lineales y no lineales entre activos para diversificación de portafolio.
## Row {data-height=500}
### 🟦 Matriz de Correlación de Pearson
```{r, fig.width=9, fig.height=7}
ret_matrix <- sp500_retornos %>%
select(Date, Ticker, Retorno) %>%
pivot_wider(names_from = Ticker, values_from = Retorno) %>%
select(-Date)
cor_matrix <- cor(ret_matrix, use = "complete.obs", method = "pearson")
col_palette <- colorRampPalette(c("#E74C3C", "#FFFFFF", "#3498DB"))(200)
corrplot(
cor_matrix,
method = "color",
col = col_palette,
type = "upper",
order = "hclust",
addCoef.col = "black",
number.cex = 0.7,
tl.col = "#2C3E50",
tl.srt = 45,
title = "Correlación de Pearson — Retornos Diarios",
mar = c(0, 0, 2, 0)
)
```
### 🌐 Heatmap interactivo de correlación
```{r}
cor_df <- as.data.frame(cor_matrix) %>%
rownames_to_column("Ticker_X") %>%
pivot_longer(-Ticker_X, names_to = "Ticker_Y", values_to = "Correlacion")
p <- ggplot(cor_df, aes(x = Ticker_X, y = Ticker_Y,
fill = Correlacion, text = paste0(
Ticker_X, " vs ", Ticker_Y,
"\nCorrelación: ", round(Correlacion, 3)
))) +
geom_tile(color = "white", linewidth = 0.5) +
scale_fill_gradient2(low = "#E74C3C", mid = "#FFFFFF", high = "#3498DB",
midpoint = 0, limits = c(-1, 1)) +
geom_text(aes(label = round(Correlacion, 2)), size = 2.8, color = "gray20") +
labs(title = "Heatmap de Correlaciones",
x = NULL, y = NULL, fill = "Correlación") +
theme_minimal(base_size = 11) +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
ggplotly(p, tooltip = "text")
```
## Row {data-height=380}
### 📉 Dispersión: par más correlacionado
```{r}
# Par con mayor correlación (excluyendo diagonal)
diag(cor_matrix) <- NA
idx <- which(cor_matrix == max(cor_matrix, na.rm = TRUE), arr.ind = TRUE)[1, ]
tick_a <- rownames(cor_matrix)[idx[1]]
tick_b <- colnames(cor_matrix)[idx[2]]
cor_val <- round(max(cor_matrix, na.rm = TRUE), 3)
par_df <- sp500_retornos %>%
filter(Ticker %in% c(tick_a, tick_b)) %>%
select(Date, Ticker, Retorno) %>%
pivot_wider(names_from = Ticker, values_from = Retorno) %>%
drop_na()
p <- ggplot(par_df, aes_string(x = tick_a, y = tick_b)) +
geom_point(alpha = 0.45, color = "#3498DB") +
geom_smooth(method = "lm", color = "#E74C3C", se = TRUE, linewidth = 1) +
labs(
title = paste("Par más correlacionado:", tick_a, "vs", tick_b),
subtitle = paste("Correlación de Pearson:", cor_val),
x = paste("Retorno", tick_a, "(%)"),
y = paste("Retorno", tick_b, "(%)")
) +
theme_minimal(base_size = 12)
ggplotly(p)
```
### 📊 Correlación promedio por Ticker (centralidad)
```{r}
diag(cor_matrix) <- NA
cor_avg <- rowMeans(cor_matrix, na.rm = TRUE)
cor_avg_df <- data.frame(
Ticker = names(cor_avg),
Cor_Prom = round(cor_avg, 4)
) %>% arrange(desc(Cor_Prom))
p <- ggplot(cor_avg_df, aes(x = reorder(Ticker, Cor_Prom),
y = Cor_Prom, fill = Cor_Prom)) +
geom_col(alpha = 0.88) +
scale_fill_gradient2(low = "#E74C3C", mid = "#ECF0F1", high = "#3498DB",
midpoint = median(cor_avg_df$Cor_Prom)) +
geom_text(aes(label = Cor_Prom), hjust = -0.15, size = 3) +
labs(title = "Correlación Promedio con el Resto de Tickers",
subtitle = "Mayor valor = activo más 'sistémico'",
x = "Ticker", y = "Correlación Promedio") +
theme_minimal(base_size = 12) +
theme(legend.position = "none") +
coord_flip() +
scale_y_continuous(expand = expansion(mult = c(0, 0.15)))
ggplotly(p)
```
<!-- ═══════════════════════════════════════════════════════════════════
PÁGINA 5 · DATOS Y DICCIONARIO
═══════════════════════════════════════════════════════════════════ -->
# 📁 Datos
## Row {data-height=380}
### 🗃️ Dataset limpio — Retornos diarios
```{r}
datatable(
sp500_retornos %>%
mutate(Retorno = round(Retorno, 4),
Precio = round(Precio, 2)) %>%
arrange(desc(Date)),
rownames = FALSE,
filter = "top",
options = list(pageLength = 12, scrollX = TRUE, dom = "frtip")
) %>%
formatStyle("Retorno",
color = styleInterval(0, c("#E74C3C", "#2ECC71")),
fontWeight = "bold"
)
```
## Row {data-height=420}
### 📖 Diccionario de Variables
```{r}
diccionario <- data.frame(
Sección = c(
"Pipeline","Pipeline","Pipeline","Pipeline","Pipeline",
"KPIs","KPIs","KPIs","KPIs","KPIs","KPIs",
"Agrupación","Normalización"
),
Variable = c(
"sp500_raw","sp500_reducido","sp500_ancho","sp500_imputado","sp500_retornos",
"Media","Desv_Std","Sharpe","VaR_95","CVaR_95","Curtosis",
"Grupo","Precio_Norm"
),
Tipo = c(
"data.frame","data.frame","data.frame","data.frame","data.frame",
"numeric","numeric","numeric","numeric","numeric","numeric",
"character","numeric"
),
Descripción = c(
"Dataset original cargado desde CSV",
"Columnas Date, Ticker, Close seleccionadas",
"Formato ancho: una columna por Ticker",
"Dataset sin filas con NA (drop_na)",
"Retornos diarios % por Ticker en formato largo",
"Promedio del retorno diario por Ticker (%)",
"Volatilidad: desviación estándar de retornos (%)",
"Retorno medio / desv. std (retorno ajustado por riesgo)",
"Percentil 5%: pérdida máxima esperada al 95% confianza",
"Media de retornos por debajo del VaR (pérdida esperada extrema)",
"Curtosis: >3 indica colas pesadas (leptocúrtica)",
"Baja / Media / Alta volatilidad según terciles de Desv_Std",
"Precio indexado a 100 en el primer día disponible"
),
stringsAsFactors = FALSE
)
datatable(
diccionario,
rownames = FALSE,
options = list(pageLength = 13, dom = "frtip", scrollX = TRUE)
) %>%
formatStyle("Variable",
fontFamily = "Courier New", color = "#C0392B", fontWeight = "bold"
) %>%
formatStyle("Sección",
fontWeight = "bold", color = "#1F4E79"
) %>%
formatStyle("Tipo",
fontStyle = "italic", color = "#27AE60"
)
```