Registro de cambios v1 → v2

# Cambio Por qué
1 β se llama propensión marginal a gastar (PMG), no “elasticidad”. Se agrega la elasticidad log-log por separado. lm(G ~ Y) en niveles da pesos de gasto por peso de ingreso; una elasticidad sale de log(G) ~ log(Y).
2 La conclusión pasa de “no suavizan” a “suavización parcial”, y se genera desde los resultados. En v1 la Tabla 6 rechazaba β = 1, lo que contradice “gastan lo que entra”.
3 Nueva clasificación de las salidas por función económica (operativo variable, operativo fijo, inversión, personal/hogar, financiero) y de las entradas (operativas vs. financiamiento). Una “Salida” no es consumo: incluye mercancía e insumos, que se mueven con las ventas, y el ahorro, que es justamente suavizar.
4 Nuevo modelo de panel con efectos fijos de empresa y de quincena, con errores agrupados por empresa (fixest). Los β individuales salen de 4 a 11 puntos y son muy ruidosos; el panel usa a todas las empresas con ≥ 2 quincenas bilaterales y controla estacionalidad.
5 Nuevo control de intensidad de registro (días con registro en la quincena). Las quincenas en que se usa más la app tienen más entradas y más salidas a la vez: eso genera β > 0 sin ningún comportamiento económico detrás.
6 Nueva descomposición de la PMG por componente del gasto. Permite decir cuántos de los pesos que responden al ingreso son inventario y cuántos son gasto del hogar.
7 Nuevas pruebas de asimetría (subidas vs. caídas de ingreso) y de rezagos/adelantos. La asimetría es la señal clásica de restricciones de liquidez; los adelantos detectan compra de inventario antes de vender.
8 Nueva prueba del sesgo del filtro bilateral. Quedarse solo con quincenas con entradas y salidas bota las quincenas donde se vería suavización.
9 Se elimina el hallazgo “el gasto es más volátil que el ingreso”; se reemplaza por la comparación en pesos (desviación estándar). El CV divide por la media, y la media del gasto es menor que la del ingreso: el CV del gasto queda inflado mecánicamente.
10 Agro, Industria y Otros se agrupan en “Otros sectores” para tablas y regresiones. No se borra ninguna observación. En la Submuestra B son 2, 3 y 2 empresas: las medianas por celda no son interpretables.
11 La robustez entre submuestras usa IC bootstrap de la mediana en lugar del umbral |Δ| < 0,10. El umbral era arbitrario y D (n = 11) quedaba justo en el borde.
12 La regresión de predictores en dos etapas usa errores robustos HC3 de verdad (sandwich + lmtest). En v1 el comentario decía HC3 pero tidy(lm) reportaba errores clásicos.
13 Decimales con coma y miles con punto en todas las tablas; “1 de cada N” calculado. “822.123.3” era ambiguo; 27% es 1 de cada 4, no 1 de cada 3.
14 Se agrega una sección sobre a quién representa la muestra. La submuestra B es una fracción pequeña de los usuarios: los resultados hablan de usuarios activos de la app, no de los micronegocios bogotanos en general.

Nota metodológica sobre calidad de datos. La base cruda presenta limitaciones estructurales documentadas en el diagnóstico de calidad (González Lozano & Granados Rodríguez, agosto 2026): (1) una proporción alta de filas potencialmente duplicadas por re-ingesta de lotes del sistema de cargue, problema que no puede resolverse completamente con la información disponible; (2) 58 transacciones con fechas futuras (eliminadas en limpieza_MDF.Rmd); (3) 8 valores imposibles de año de inicio (convertidos a NA en este cuaderno). Todo el análisis aplica los filtros de limpieza disponibles y declara los supuestos adoptados. Los resultados son exploratorios y describen a usuarios activos de Mi Diario Financiero, no a los micronegocios bogotanos en general.


0. Librerías, parámetros y carga de datos

library(tidyverse)    # manipulación y visualización (incluye lubridate)
library(readxl)       # lectura de Excel
library(scales)       # formato de ejes
library(ggridges)     # density ridges
library(broom)        # tidying de modelos
library(knitr)        # tablas
library(kableExtra)   # formato de tablas
library(patchwork)    # combinar gráficas
library(writexl)      # exportar Excel
library(fixest)       # panel con efectos fijos   — NUEVO en v2
library(sandwich)     # errores robustos HC3     — NUEVO en v2
library(lmtest)       # coeftest                 — NUEVO en v2

# Paleta institucional
COL_POS  <- "#1baf7a"   # flujo positivo
COL_NEG  <- "#e34948"   # flujo negativo
COL_AZUL <- "#1A5276"   # color principal
COL_GRIS <- "#898781"   # secundario
COL_SECT <- c(Comercio = COL_AZUL, Servicios = "#e67e22", `Otros sectores` = COL_GRIS)
# ── Parámetros del análisis (cambiar aquí, no dentro del código) ─────────────
SEED        <- 20261005   # semilla para el bootstrap
N_BOOT      <- 2000       # réplicas bootstrap
MIN_Q_BETA  <- 4          # quincenas bilaterales mínimas para estimar β por empresa
MIN_Q_PANEL <- 2          # quincenas bilaterales mínimas para entrar al panel con EF de empresa
set.seed(SEED)

# ── Formato colombiano: miles con punto, decimales con coma ─────────────────
options(knitr.kable.NA = "—")
FMT_TABLA <- list(big.mark = ".", decimal.mark = ",")

fmt <- function(x, d = 2) {
  out <- formatC(round(x, d), format = "f", digits = d,
                 big.mark = ".", decimal.mark = ",")
  out[is.na(x)] <- "—"
  out
}
fmt_pct <- function(x, d = 1) ifelse(is.na(x), "—", paste0(fmt(100 * x, d), "%"))
fmt_p   <- function(p) case_when(is.na(p) ~ "—", p < 0.001 ~ "< 0,001", TRUE ~ fmt(p, 3))
txt_p   <- function(p) if_else(!is.na(p) & p < 0.001, "p < 0,001", paste0("p = ", fmt(p, 3)))
fmt_ic  <- function(lo, hi, d = 2) {
  ifelse(is.na(lo) | is.na(hi), "—", paste0("[", fmt(lo, d), "; ", fmt(hi, d), "]"))
}
etiqueta_millones <- function(x) paste0(fmt(x / 1e6, 1), " M")
eje_coma <- label_number(decimal.mark = ",", big.mark = ".")
estilo  <- function(k, ...) kable_styling(k, bootstrap_options = c("striped", "hover"), ...)

# ── Funciones estadísticas ───────────────────────────────────────────────────
# IC bootstrap percentil de la mediana
boot_mediana <- function(x, B = N_BOOT) {
  x <- x[!is.na(x)]
  if (length(x) < 5) return(c(NA_real_, NA_real_))
  meds <- replicate(B, median(sample(x, replace = TRUE)))
  unname(quantile(meds, c(0.025, 0.975)))
}

# Prueba de signo: H0 mediana = mu (a diferencia de Wilcoxon, no supone simetría)
p_signo <- function(x, mu) {
  x <- x[!is.na(x) & x != mu]
  if (length(x) < 1) return(NA_real_)
  binom.test(sum(x > mu), length(x))$p.value
}

# Una fila resumen de un coeficiente de un modelo fixest
fila_fx <- function(m, var, nombre) {
  vacio <- tibble(Modelo = nombre, coef = NA_real_, ee = NA_real_, ic_lo = NA_real_,
                  ic_hi = NA_real_, p = NA_real_, obs = NA_real_, empresas = NA_real_)
  if (is.null(m) || length(var) != 1 || !var %in% names(coef(m))) return(vacio)
  ct <- coeftable(m)
  ci <- confint(m)
  tibble(
    Modelo   = nombre,
    coef     = ct[var, 1],
    ee       = ct[var, 2],
    ic_lo    = ci[var, 1],
    ic_hi    = ci[var, 2],
    p        = ct[var, 4],
    obs      = nobs(m),
    empresas = if ("ID" %in% names(m$fixef_sizes)) unname(m$fixef_sizes["ID"]) else NA_real_
  )
}

# Todos los coeficientes de un modelo fixest
coefs_fx <- function(m, nombre) {
  ct <- coeftable(m)
  ci <- confint(m)
  tibble(Modelo = nombre, termino = rownames(ct), coef = ct[, 1], ee = ct[, 2],
         p = ct[, 4], ic_lo = ci[, 1], ic_hi = ci[, 2])
}

# Tabla estándar para filas de fila_fx()
kable_fx <- function(df, caption, d = 3) {
  df %>%
    transmute(
      Modelo,
      `Coef.`        = fmt(coef, d),
      `EE (cluster)` = fmt(ee, d),
      `IC 95%`       = fmt_ic(ic_lo, ic_hi, d),
      `p-valor`      = fmt_p(p),
      Obs.           = fmt(obs, 0),
      Empresas       = fmt(empresas, 0)
    ) %>%
    kable(caption = caption) %>%
    estilo(full_width = TRUE)
}
# ── PREREQUISITO ─────────────────────────────────────────────────────────────
# Este cuaderno asume que ya corriste limpieza_MDF.Rmd, que produce:
#   ig_limpia.csv — base de ingresos y gastos limpia y estandarizada
# La Caracterización se limpia aquí directamente (910 filas).
# Si aún no has corrido limpieza_MDF.Rmd, hazlo primero, por favor.

ig_limpia <- read_csv("ig_limpia.csv", show_col_types = FALSE) %>%
  mutate(`Fecha de la transacción` = as.Date(`Fecha de la transacción`))

car <- read_excel("Base_Anonimizada_Caracterizacion.xlsx",
                  sheet = "Caracterizacion")

car_clean <- car %>%
  mutate(
    `Año de Inicio` = if_else(!between(`Año de Inicio`, 1950, 2026),
                               NA_real_, `Año de Inicio`),
    across(c("RUT", "Presupuesto", "División de Finanzas"),
           ~ str_to_title(str_trim(.))),
    `Tipo de Registro` = str_to_title(str_trim(`Tipo de Registro`)),
    `Tipo de Registro` = if_else(`Tipo de Registro` == "No",
                                  NA_character_, `Tipo de Registro`)
  )

cat("Base I&G limpia: ", nrow(ig_limpia), "filas |", ncol(ig_limpia), "columnas\n")
## Base I&G limpia:  13113 filas | 25 columnas
cat("Base Car:        ", nrow(car_clean), "filas |", ncol(car_clean), "columnas\n")
## Base Car:         910 filas | 37 columnas

ETAPA 1 — Construcción y justificación de submuestras

1.1 Resumen de la base limpia recibida

La base ig_limpia.csv fue producida por limpieza_MDF.Rmd, que aplicó:

  • Capa 1 — 2.588 filas 100% idénticas eliminadas con distinct().
  • Capa 2 — 3.542 re-ingestas de lotes eliminadas con llave de unicidad.
  • Capa 3 — transacciones cargadas con más de 30 días de retraso, marcadas con es_retroactivo = TRUE (no eliminadas; se controlan con la Submuestra D). Dentro de ellas, el diagnóstico estimó ~1.977 duplicados irresolubles; el número total de filas marcadas aparece en la Tabla 1.
  • Atípicos — excluidos vía Monto_Atipico == TRUE.
  • Fechas futuras — 58 transacciones eliminadas.
  • IDs nulos — 16 filas eliminadas.
  • Categorías — estandarizadas al catálogo de 29 valores.
  • Cuenta — estandarizada en Cuenta_limpia (12 categorías).
# Base de análisis: transacciones sin atípicos de monto.
# q_idx = índice calendario de quincena (1–15 y 16–fin de mes), contado desde ene-2024.
# Se calcula aquí para tener un orden temporal explícito (necesario para rezagos).
ig_clean <- ig_limpia %>%
  filter(Monto_Atipico == FALSE) %>%
  mutate(
    q_idx = (year(`Fecha de la transacción`) - 2024) * 24 +
            (month(`Fecha de la transacción`) - 1) * 2 +
            if_else(day(`Fecha de la transacción`) <= 15, 1, 2)
  )

# Verificar que la quincena de limpieza_MDF coincide 1 a 1 con q_idx
chequeo_q <- ig_clean %>%
  distinct(quincena, q_idx) %>%
  summarise(etiquetas = n_distinct(quincena), indices = n_distinct(q_idx), pares = n())

if (chequeo_q$pares == chequeo_q$etiquetas && chequeo_q$pares == chequeo_q$indices) {
  cat("OK: `quincena` de limpieza_MDF y q_idx coinciden 1 a 1 (",
      chequeo_q$indices, "quincenas ).\n")
} else {
  cat("ATENCIÓN: `quincena` no coincide 1 a 1 con la definición calendario.",
      "Este cuaderno agrupa por q_idx; revisar la definición en limpieza_MDF.\n")
}
## OK: `quincena` de limpieza_MDF y q_idx coinciden 1 a 1 ( 60 quincenas ).
tibble(
  Concepto = c(
    "Filas en ig_limpia.csv",
    "Sin atípicos de monto (base de análisis)",
    "Empresas únicas",
    "Con flag es_retroactivo = TRUE (Capa 3)",
    "Período"
  ),
  Valor = c(
    fmt(nrow(ig_limpia), 0),
    fmt(nrow(ig_clean), 0),
    fmt(n_distinct(ig_clean$ID), 0),
    fmt(sum(ig_clean$es_retroactivo, na.rm = TRUE), 0),
    paste(min(ig_clean$`Fecha de la transacción`),
          "→", max(ig_clean$`Fecha de la transacción`))
  )
) %>%
  kable(caption = "Tabla 1. Resumen de la base limpia recibida") %>%
  estilo(full_width = FALSE)
Tabla 1. Resumen de la base limpia recibida
Concepto Valor
Filas en ig_limpia.csv 13.113
Sin atípicos de monto (base de análisis) 12.339
Empresas únicas 557
Con flag es_retroactivo = TRUE (Capa 3) 4.455
Período 2024-01-15 → 2026-08-08

1.2 Clasificación de entradas y salidas por función económica — NUEVO

La hipótesis de ingresos permanentes (HIP) habla del consumo. Las salidas de un micronegocio incluyen mercancía, insumos y arriendo (costos que se mueven con las ventas), pagos de deuda y ahorro. Si una empresa ahorra más en sus buenas quincenas — que es exactamente suavizar —, en la base eso aparece como más “gasto”. Del lado de las entradas pasa algo parecido: un desembolso de un préstamo entra como ingreso y luego sale como gasto, lo que produce un β alto sin miopía de por medio.

Por eso se clasifican las transacciones según su función. Esta asignación es una propuesta y debe revisarse con el equipo: cambiarla aquí cambia todo el análisis por componentes (sección 2.6).

stopifnot("Categoría" %in% names(ig_clean))

# ── Salidas ──────────────────────────────────────────────────────────────────
# Lo que no aparece aquí (incluida Categoría = NA) queda como "Otros / sin clasificar".
grupos_salida <- tribble(
  ~Categoría,                    ~grupo_salida,
  "Materia prima",               "Operativo variable",
  "Mercancías",                  "Operativo variable",
  "Suministros",                 "Operativo variable",
  "Servicios tercerizados",      "Operativo variable",
  "Comisiones",                  "Operativo variable",
  "Transporte",                  "Operativo variable",
  "Gasto de Papelería",          "Operativo variable",
  "Arriendo",                    "Operativo fijo",
  "Servicios públicos",          "Operativo fijo",
  "Pago a empleados",            "Operativo fijo",     # REVISAR: en micronegocios puede ser jornal (variable)
  "Gastos Administrativos",      "Operativo fijo",
  "Gastos de software",          "Operativo fijo",
  "Marketing y publicidad",      "Operativo fijo",
  "Impuestos",                   "Operativo fijo",
  "En seguros",                  "Operativo fijo",
  "Mobiliario y maquinaria",     "Inversión",
  "Gastos personales",           "Personal / hogar",
  "Ahorro",                      "Financiero (ahorro y deuda)",
  "Cuota de préstamo formal",    "Financiero (ahorro y deuda)",
  "Cuota de préstamo informal",  "Financiero (ahorro y deuda)",
  "Financieros",                 "Financiero (ahorro y deuda)"
)

# ── Entradas ─────────────────────────────────────────────────────────────────
grupos_entrada <- tribble(
  ~Categoría,                    ~grupo_entrada,
  "Ventas",                      "Operativo",
  "Servicios",                   "Operativo",
  "Cuentas por cobrar",          "Operativo",
  "Comisiones",                  "Operativo",
  "Préstamos formales",          "Financiamiento",
  "Cuota de préstamo informal",  "Financiamiento",     # REVISAR: como entrada, ¿préstamo informal recibido?
  "Cuota de préstamo formal",    "Financiamiento",
  "Ahorro",                      "Financiamiento",     # retiro de ahorros
  "Financieros",                 "Financiamiento"
)

ig_clean <- ig_clean %>%
  left_join(grupos_salida,  by = "Categoría") %>%
  left_join(grupos_entrada, by = "Categoría") %>%
  mutate(
    grupo_salida = case_when(
      Tipo != "Salida"    ~ NA_character_,
      is.na(grupo_salida) ~ "Otros / sin clasificar",
      TRUE                ~ grupo_salida
    ),
    grupo_entrada = case_when(
      Tipo != "Entrada"    ~ NA_character_,
      is.na(grupo_entrada) ~ "Otros / sin clasificar",
      TRUE                 ~ grupo_entrada
    )
  )

resumen_grupo <- function(df, var, tipo) {
  df %>%
    filter(Tipo == tipo) %>%
    group_by(Grupo = {{ var }}) %>%
    summarise(
      Transacciones = n(),
      Empresas      = n_distinct(ID),
      `Monto total` = sum(Monto, na.rm = TRUE),
      .groups = "drop"
    ) %>%
    mutate(`% del monto` = 100 * `Monto total` / sum(`Monto total`)) %>%
    arrange(desc(`Monto total`))
}

resumen_grupo(ig_clean, grupo_salida, "Salida") %>%
  kable(caption = "Tabla 2a. Salidas por función económica (base sin atípicos)",
        format.args = FMT_TABLA, digits = c(0, 0, 0, 0, 1)) %>%
  estilo(full_width = FALSE)
Tabla 2a. Salidas por función económica (base sin atípicos)
Grupo Transacciones Empresas Monto total % del monto
Operativo variable 2.750 312 1.150.046.172 53,6
Operativo fijo 986 195 475.466.088 22,2
Personal / hogar 1.058 131 331.324.280 15,5
Financiero (ahorro y deuda) 405 56 122.663.100 5,7
Inversión 79 40 45.885.597 2,1
Otros / sin clasificar 154 13 18.602.831 0,9
resumen_grupo(ig_clean, grupo_entrada, "Entrada") %>%
  kable(caption = "Tabla 2b. Entradas por función económica (base sin atípicos)",
        format.args = FMT_TABLA, digits = c(0, 0, 0, 0, 1)) %>%
  estilo(full_width = FALSE)
Tabla 2b. Entradas por función económica (base sin atípicos)
Grupo Transacciones Empresas Monto total % del monto
Operativo 6.422 483 2.974.123.126 95,4
Otros / sin clasificar 431 80 106.767.758 3,4
Financiamiento 54 23 37.061.773 1,2
# Qué quedó sin clasificar — para revisar con el equipo
ig_clean %>%
  filter(grupo_salida == "Otros / sin clasificar" | grupo_entrada == "Otros / sin clasificar") %>%
  mutate(Categoría = replace_na(Categoría, "(sin categoría)")) %>%
  count(Tipo, Categoría, sort = TRUE, name = "Transacciones") %>%
  kable(caption = "Tabla 2c. Categorías que quedaron en 'Otros / sin clasificar' — revisar",
        format.args = FMT_TABLA) %>%
  estilo(full_width = FALSE)
Tabla 2c. Categorías que quedaron en ‘Otros / sin clasificar’ — revisar
Tipo Categoría Transacciones
Entrada (sin categoría) 204
Salida (sin categoría) 152
Entrada Arriendo 113
Entrada Mercancías 42
Entrada Materia prima 32
Entrada Donaciones 25
Entrada Premios 5
Entrada Ventas de acciones 5
Entrada Servicios tercerizados 2
Entrada Gastos Administrativos 1
Entrada Pago a empleados 1
Entrada Suministros 1
Salida Servicios 1
Salida Ventas de acciones 1

1.3 Panel quincenal por empresa

COMPONENTES <- c(G_var = "Operativo variable", G_fijo = "Operativo fijo",
                 G_inv = "Inversión", G_pers = "Personal / hogar",
                 G_fin = "Financiero (ahorro y deuda)", G_otros = "Otros / sin clasificar")

suma_si <- function(monto, cond) sum(monto[cond], na.rm = TRUE)

panel_q <- ig_clean %>%
  group_by(ID, q_idx) %>%
  summarise(
    quincena = first(quincena),
    Y_qt     = suma_si(Monto, Tipo == "Entrada"),
    Y_op     = suma_si(Monto, Tipo == "Entrada" & grupo_entrada == "Operativo"),
    G_qt     = suma_si(Monto, Tipo == "Salida"),
    G_var    = suma_si(Monto, Tipo == "Salida" & grupo_salida == "Operativo variable"),
    G_fijo   = suma_si(Monto, Tipo == "Salida" & grupo_salida == "Operativo fijo"),
    G_inv    = suma_si(Monto, Tipo == "Salida" & grupo_salida == "Inversión"),
    G_pers   = suma_si(Monto, Tipo == "Salida" & grupo_salida == "Personal / hogar"),
    G_fin    = suma_si(Monto, Tipo == "Salida" & grupo_salida == "Financiero (ahorro y deuda)"),
    G_otros  = suma_si(Monto, Tipo == "Salida" & grupo_salida == "Otros / sin clasificar"),
    F_qt     = sum(flujo, na.rm = TRUE),
    n_trans  = n(),
    n_dias   = n_distinct(`Fecha de la transacción`),   # días con algún registro
    .groups  = "drop"
  )

# Verificaciones de consistencia
stopifnot(isTRUE(all.equal(panel_q$G_qt,
  with(panel_q, G_var + G_fijo + G_inv + G_pers + G_fin + G_otros))))
cat("Quincenas donde flujo = entradas − salidas:",
    fmt_pct(mean(abs(panel_q$F_qt - (panel_q$Y_qt - panel_q$G_qt)) < 1)), "\n")
## Quincenas donde flujo = entradas − salidas: 100,0%
# Solo quincenas con AMBOS ingresos y gastos (definición de v1; ver sección 2.5)
panel_bilateral <- panel_q %>%
  filter(Y_qt > 0, G_qt > 0)

cat("Empresas con al menos una quincena bilateral:", n_distinct(panel_bilateral$ID), "\n")
## Empresas con al menos una quincena bilateral: 314
cat("Total quincenas bilaterales:", nrow(panel_bilateral), "\n")
## Total quincenas bilaterales: 816

1.4 Indicadores por empresa y criterios de submuestras

El análisis enfrenta una tensión: más criterios de calidad → muestra más pequeña → menos poder estadístico. Se construyen cuatro submuestras con criterios progresivamente más exigentes para evaluar la robustez (siguiendo la sugerencia de G).

# Ajuste por empresa: PMG en niveles, su error estándar y la elasticidad log-log
ajuste_emp <- function(Y, G) {
  m  <- lm(G ~ Y)
  ml <- lm(log(G) ~ log(Y))
  s  <- summary(m)$coefficients
  tibble(
    corr_ig   = suppressWarnings(cor(Y, G)),
    beta      = unname(coef(m)["Y"]),          # PMG: pesos de salida por peso de entrada
    beta_se   = if ("Y" %in% rownames(s)) s["Y", "Std. Error"] else NA_real_,
    elast_log = unname(coef(ml)["log(Y)"])     # elasticidad: % de salida por 1% de entrada
  )
}

indicadores_emp <- panel_bilateral %>%
  group_by(ID) %>%
  summarise(
    n_quincenas_bilat  = n(),
    ing_medio          = mean(Y_qt),
    gas_medio          = mean(G_qt),
    flujo_medio        = mean(F_qt),
    sd_ingreso         = sd(Y_qt),
    sd_gasto           = sd(G_qt),
    cv_ingreso         = sd_ingreso / ing_medio,
    cv_gasto           = sd_gasto / gas_medio,
    ratio_sd           = sd_gasto / sd_ingreso,   # volatilidad relativa EN PESOS
    pct_quin_negativas = mean(F_qt < 0),
    ratio_gi           = sum(G_qt) / sum(Y_qt),
    .groups = "drop"
  ) %>%
  # Quincenas con cualquier registro (no solo bilaterales)
  left_join(panel_q %>% count(ID, name = "n_quincenas_total"), by = "ID") %>%
  # PMG, elasticidad y correlación (solo con ≥ MIN_Q_BETA quincenas bilaterales)
  left_join(
    panel_bilateral %>%
      group_by(ID) %>%
      filter(n() >= MIN_Q_BETA) %>%
      summarise(ajuste_emp(Y_qt, G_qt), .groups = "drop"),
    by = "ID"
  ) %>%
  # Perfil de la empresa
  left_join(
    car_clean %>%
      select(ID, Sector, Genero, Perfil_Digital,
             `División de Finanzas`, `Año de Inicio`,
             `Acceso a Financiamiento`, Localidad),
    by = "ID"
  ) %>%
  mutate(
    antiguedad = 2026 - `Año de Inicio`,
    es_mujer   = if_else(Genero == "Mujer", 1L, 0L),
    es_digital = if_else(Perfil_Digital == "En transformación digital", 1L, 0L),
    separa_fin = if_else(`División de Finanzas` == "Sí", 1L, 0L),
    # Agro, Industria y Otros se agrupan: con < 5 empresas por celda no hay inferencia posible
    sector_g   = case_when(
      is.na(Sector)                          ~ NA_character_,
      Sector %in% c("Comercio", "Servicios") ~ Sector,
      TRUE                                   ~ "Otros sectores"
    )
  )

# ── SUBMUESTRA A: criterio mínimo ────────────────────────────────────────────
# ≥ 3 quincenas bilaterales. Máxima cobertura.
muestra_A <- indicadores_emp %>%
  filter(n_quincenas_bilat >= 3)

# ── SUBMUESTRA B: criterio intermedio (PRINCIPAL) ────────────────────────────
# ≥ 5 quincenas bilaterales + ratio gasto/ingreso < 3 + β calculable
muestra_B <- indicadores_emp %>%
  filter(n_quincenas_bilat >= 5, ratio_gi < 3, !is.na(beta))

# ── SUBMUESTRA C: criterio estricto ──────────────────────────────────────────
# ≥ 8 quincenas bilaterales + ratio < 2 + CV ingreso < 3
muestra_C <- indicadores_emp %>%
  filter(n_quincenas_bilat >= 8, ratio_gi < 2, cv_ingreso < 3, !is.na(beta))

# ── SUBMUESTRA D: confiabilidad de cargue ────────────────────────────────────
# Empresas sin ninguna transacción cargada con > 30 días de retraso y sin
# fechas de cargue faltantes. Minimiza el riesgo de duplicados irresolubles
# (Capa 3) a costa de una muestra muy pequeña.
riesgo_cargue <- ig_clean %>%
  group_by(ID) %>%
  summarise(
    pct_retroactivo  = mean(es_retroactivo, na.rm = TRUE),
    tiene_sin_cargue = any(is.na(diff_dias_cargue)),
    .groups = "drop"
  )

muestra_D <- indicadores_emp %>%
  left_join(riesgo_cargue, by = "ID") %>%
  filter(n_quincenas_bilat >= 3, pct_retroactivo == 0, !tiene_sin_cargue, !is.na(beta))

SUBMUESTRAS <- list(
  "A — mínimo"        = muestra_A,
  "B — intermedio"    = muestra_B,
  "C — estricto"      = muestra_C,
  "D — confiabilidad" = muestra_D
)

n_emp_trans <- n_distinct(ig_clean$ID)

tibble(
  Submuestra = c(
    "A — mínimo (≥3 q bilaterales)",
    "B — intermedio (≥5 q, ratio<3)",
    "C — estricto (≥8 q, ratio<2, CV<3)",
    "D — confiabilidad de cargue (0% retroactivo)"
  ),
  N = map_int(SUBMUESTRAS, nrow),
  `% de empresas con transacciones` = 100 * N / n_emp_trans,
  `Criterio duplicados` = c(rep("Capas 1 y 2 eliminadas. Capa 3: no controlada.", 3),
                            "Capas 1, 2 y 3 controladas — sin cargue retroactivo"),
  `Mediana quincenas` = map_dbl(SUBMUESTRAS, ~ median(.x$n_quincenas_bilat)),
  `% con β calculado` = map_dbl(SUBMUESTRAS, ~ 100 * mean(!is.na(.x$beta)))
) %>%
  kable(caption = "Tabla 3. Criterios y características de las cuatro submuestras",
        format.args = FMT_TABLA, digits = 1) %>%
  estilo(full_width = TRUE) %>%
  row_spec(2, bold = TRUE, background = "#EBF5FB") %>%
  row_spec(4, bold = TRUE, background = "#D5F5E3")
Tabla 3. Criterios y características de las cuatro submuestras
Submuestra N % de empresas con transacciones Criterio duplicados Mediana quincenas % con β calculado
A — mínimo (≥3 q bilaterales) 99 17,8 Capas 1 y 2 eliminadas. Capa 3: no controlada. 5 67,7
B — intermedio (≥5 q, ratio<3) 51 9,2 Capas 1 y 2 eliminadas. Capa 3: no controlada. 7 100,0
C — estricto (≥8 q, ratio<2, CV<3) 17 3,1 Capas 1 y 2 eliminadas. Capa 3: no controlada. 11 100,0
D — confiabilidad de cargue (0% retroactivo) 11 2,0 Capas 1, 2 y 3 controladas — sin cargue retroactivo 5 100,0
indicadores_emp %>%
  mutate(
    corte = case_when(
      n_quincenas_bilat >= 8 ~ "≥ 8 quincenas",
      n_quincenas_bilat >= 5 ~ "5 a 7 quincenas",
      n_quincenas_bilat >= 3 ~ "3 a 4 quincenas",
      TRUE                   ~ "Menos de 3 (excluida)"
    ),
    corte = factor(corte, levels = c("≥ 8 quincenas", "5 a 7 quincenas",
                                     "3 a 4 quincenas", "Menos de 3 (excluida)"))
  ) %>%
  ggplot(aes(x = n_quincenas_bilat, fill = corte)) +
  geom_histogram(binwidth = 1, color = "white", linewidth = 0.3) +
  scale_fill_manual(values = c("≥ 8 quincenas" = COL_AZUL, "5 a 7 quincenas" = "#2e86c1",
                               "3 a 4 quincenas" = "#85c1e9", "Menos de 3 (excluida)" = "#d5d8dc"),
                    name = NULL) +
  geom_vline(xintercept = c(3, 5, 8) - 0.5, linetype = "dashed",
             color = "gray40", linewidth = 0.7) +
  annotate("text", x = c(3.2, 5.2, 8.2), y = Inf, label = c("Corte A", "Corte B", "Corte C"),
           vjust = 2, hjust = 0, size = 3, color = "gray40") +
  labs(title = "Figura 1. Distribución de quincenas bilaterales por empresa",
       subtitle = "Los cortes son por número de quincenas; B y C aplican además filtros de ratio gasto/ingreso y CV.",
       x = "Número de quincenas con ingresos Y gastos", y = "Número de empresas") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom", plot.title = element_text(face = "bold"))

1.5 Perfil de cada submuestra

perfil_tabla <- function(df, nombre) {
  df %>%
    summarise(
      N                            = n(),
      `Quincenas (mediana)`        = median(n_quincenas_bilat),
      `Ing. mediano/quincena`      = median(ing_medio),
      `Gas. mediano/quincena`      = median(gas_medio),
      `% quinc. negativas (med.)`  = 100 * median(pct_quin_negativas),
      `% Comercio`                 = 100 * mean(Sector == "Comercio", na.rm = TRUE),
      `% Servicios`                = 100 * mean(Sector == "Servicios", na.rm = TRUE),
      `% Mujer`                    = 100 * mean(es_mujer, na.rm = TRUE),
      `% Separa finanzas`          = 100 * mean(separa_fin, na.rm = TRUE)
    ) %>%
    mutate(Submuestra = nombre, .before = 1)
}

imap_dfr(SUBMUESTRAS, perfil_tabla) %>%
  kable(caption = "Tabla 4. Perfil comparativo de las cuatro submuestras (montos en pesos)",
        format.args = FMT_TABLA, digits = c(0, 0, 0, 0, 0, 1, 1, 1, 1, 1)) %>%
  estilo(full_width = TRUE, font_size = 11)
Tabla 4. Perfil comparativo de las cuatro submuestras (montos en pesos)
Submuestra N Quincenas (mediana) Ing. mediano/quincena Gas. mediano/quincena % quinc. negativas (med.) % Comercio % Servicios % Mujer % Separa finanzas
A — mínimo 99 5 1.009.275 759.928 33,3 58,6 25,3 79,2 60,6
B — intermedio 51 7 1.361.135 822.123 27,3 60,8 25,5 81,6 58,8
C — estricto 17 11 1.717.035 902.818 27,3 52,9 29,4 87,5 58,8
D — confiabilidad 11 5 1.666.469 1.165.029 25,0 45,5 45,5 81,8 54,5

1.6 Transacciones por empresa en cada submuestra

A mayor número de transacciones por empresa, más rica es la serie. Esta tabla muestra si los criterios progresivos seleccionan, como efecto indirecto, a las empresas que más usan la app.

dist_trans <- function(df, nombre) {
  ig_clean %>%
    filter(ID %in% df$ID) %>%
    count(ID, name = "n_trans") %>%
    summarise(
      Submuestra               = nombre,
      Empresas                 = n(),
      `Trans. totales`         = sum(n_trans),
      `Media trans./empresa`   = mean(n_trans),
      `Mediana trans./empresa` = median(n_trans),
      P25                      = quantile(n_trans, 0.25),
      P75                      = quantile(n_trans, 0.75),
      `Mín.`                   = min(n_trans),
      `Máx.`                   = max(n_trans)
    )
}

imap_dfr(SUBMUESTRAS, dist_trans) %>%
  kable(caption = "Tabla 5. Distribución de transacciones por empresa en cada submuestra",
        format.args = FMT_TABLA, digits = c(0, 0, 0, 1, 0, 0, 0, 0, 0)) %>%
  estilo(full_width = FALSE) %>%
  row_spec(2, bold = TRUE, background = "#EBF5FB") %>%
  row_spec(4, bold = TRUE, background = "#D5F5E3")
Tabla 5. Distribución de transacciones por empresa en cada submuestra
Submuestra Empresas Trans. totales Media trans./empresa Mediana trans./empresa P25 P75 Mín. Máx.
A — mínimo 99 9.377 94,7 71 29 126 8 757
B — intermedio 51 7.087 139,0 110 71 162 22 757
C — estricto 17 3.665 215,6 152 106 192 56 757
D — confiabilidad 11 807 73,4 59 32 111 16 156

1.7 ¿A quién representan las submuestras? — NUEVO

rep_tabla <- car_clean %>%
  mutate(
    grupo = case_when(
      ID %in% muestra_B$ID ~ "Submuestra B",
      ID %in% ig_clean$ID  ~ "Resto con transacciones",
      TRUE                 ~ "Sin transacciones"
    ),
    grupo = factor(grupo, levels = c("Submuestra B", "Resto con transacciones", "Sin transacciones"))
  ) %>%
  group_by(Grupo = grupo) %>%
  summarise(
    Empresas              = n(),
    `% Mujer`             = 100 * mean(Genero == "Mujer", na.rm = TRUE),
    `% Comercio`          = 100 * mean(Sector == "Comercio", na.rm = TRUE),
    `% Separa finanzas`   = 100 * mean(`División de Finanzas` == "Sí", na.rm = TRUE),
    `% Perfil digital`    = 100 * mean(Perfil_Digital == "En transformación digital", na.rm = TRUE),
    `Antigüedad mediana`  = median(2026 - `Año de Inicio`, na.rm = TRUE),
    .groups = "drop"
  )

rep_tabla %>%
  kable(caption = "Tabla 6. Perfil de la Submuestra B frente al resto de empresas registradas",
        format.args = FMT_TABLA, digits = c(0, 0, 1, 1, 1, 1, 0)) %>%
  estilo(full_width = FALSE)
Tabla 6. Perfil de la Submuestra B frente al resto de empresas registradas
Grupo Empresas % Mujer % Comercio % Separa finanzas % Perfil digital Antigüedad mediana
Submuestra B 51 81,6 60,8 58,8 31,4 4
Resto con transacciones 506 74,8 76,1 48,0 48,7 5
Sin transacciones 353 70,7 77,0 63,4 53,1 5
pct_B_univ <- nrow(muestra_B) / n_emp_trans

La Submuestra B reúne 51 de las 557 empresas con al menos una transacción (9,2%) y de las 910 registradas en la caracterización. Son las que más usan la app. Por eso los resultados que siguen se refieren a usuarios activos de Mi Diario Financiero: si estas empresas difieren del resto en la Tabla 6, esa diferencia es una razón más para no extrapolar.


ETAPA 2 — Análisis: ¿gastan lo que entra?

Qué mide β y qué no. β es la pendiente de la regresión de las salidas sobre las entradas de la misma quincena: cuántos pesos de salida registrada acompañan a cada peso adicional de entrada registrada. Es una propensión marginal a gastar (PMG), no una elasticidad (la elasticidad sale de la versión log-log, que también se reporta).

Para leerla como una prueba de la HIP (Hall, 1978) hacen falta dos supuestos que esta base no garantiza:

  1. Que las salidas sean consumo. Incluyen inventario, insumos, arriendo, deuda y ahorro. La sección 2.6 descompone la PMG por componente.
  2. Que lo registrado refleje la actividad real y no cuánto se usó la app esa quincena. Las secciones 2.4 y 2.5 lo ponen a prueba.

Mientras esos dos puntos no se resuelvan, β describe la sensibilidad del flujo de salidas al flujo de entradas, y así se reporta.

El análisis principal por empresa se hace sobre la Submuestra B (n = 51). Los modelos de panel usan a todas las empresas con al menos 2 quincenas bilaterales.

2.1 Irregularidad del flujo: déficit y volatilidad

Quincenas en déficit

deficit_med_B <- median(muestra_B$pct_quin_negativas)
uno_de_cada   <- if (deficit_med_B > 0) round(1 / deficit_med_B) else NA

En la empresa mediana de la Submuestra B, el 27,3% de las quincenas bilaterales cierra con más salidas que entradas: más o menos 1 de cada 4 quincenas.

muestra_B %>%
  filter(!is.na(sector_g)) %>%
  ggplot(aes(x = pct_quin_negativas, y = reorder(sector_g, pct_quin_negativas), fill = sector_g)) +
  geom_density_ridges(alpha = 0.7, scale = 1.2, bandwidth = 0.08) +
  scale_fill_manual(values = COL_SECT) +
  scale_x_continuous(labels = percent_format(decimal.mark = ",")) +
  labs(title = "Figura 2. Distribución del % de quincenas en déficit por sector",
       subtitle = paste0("Submuestra B. Un valor de 25% = 1 de cada 4 quincenas el gasto superó al ingreso.\n",
                         "'Otros sectores' agrupa Agro, Industria y Otros (n pequeño)."),
       x = "Proporción de quincenas con flujo neto negativo", y = NULL) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "none", plot.title = element_text(face = "bold"))

Volatilidad: por qué el CV engaña

En v1 se reportó que “el gasto es más volátil que el ingreso” porque el CV del gasto superaba al del ingreso. Pero el CV divide por la media, y la media del gasto es menor que la del ingreso. Formalmente:

\[\frac{CV_G}{CV_Y} = \frac{\sigma_G}{\sigma_Y}\cdot\frac{\bar Y}{\bar G}\]

Si el gasto medio es, digamos, el 60% del ingreso medio, el CV del gasto queda inflado en un factor de 1/0,6 ≈ 1,7 aunque el gasto se mueva menos en pesos. La comparación relevante es la desviación estándar en pesos.

vol_tabla <- function(df, nombre) {
  tibble(
    Submuestra                          = nombre,
    N                                   = nrow(df),
    `% CV gasto > CV ingreso`           = 100 * mean(df$cv_gasto > df$cv_ingreso, na.rm = TRUE),
    `% DE gasto > DE ingreso (pesos)`   = 100 * mean(df$sd_gasto > df$sd_ingreso, na.rm = TRUE),
    `Mediana DE gasto / DE ingreso`     = median(df$ratio_sd, na.rm = TRUE),
    `Mediana gasto medio / ing. medio`  = median(df$gas_medio / df$ing_medio, na.rm = TRUE)
  )
}

tab_vol <- imap_dfr(SUBMUESTRAS, vol_tabla)

tab_vol %>%
  kable(caption = "Tabla 7. Volatilidad del gasto frente al ingreso: CV vs. desviación estándar en pesos",
        format.args = FMT_TABLA, digits = c(0, 0, 1, 1, 2, 2)) %>%
  estilo(full_width = TRUE)
Tabla 7. Volatilidad del gasto frente al ingreso: CV vs. desviación estándar en pesos
Submuestra N % CV gasto > CV ingreso % DE gasto > DE ingreso (pesos) Mediana DE gasto / DE ingreso Mediana gasto medio / ing. medio
A — mínimo 99 60,6 38,4 0,86 0,74
B — intermedio 51 60,8 29,4 0,83 0,70
C — estricto 17 58,8 11,8 0,59 0,70
D — confiabilidad 11 36,4 36,4 0,81 0,71
p1 <- muestra_B %>%
  filter(!is.na(cv_ingreso), !is.na(cv_gasto), cv_ingreso < 4, cv_gasto < 4) %>%
  ggplot(aes(x = cv_ingreso, y = cv_gasto, color = sector_g)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "gray50") +
  geom_point(alpha = 0.7, size = 2) +
  scale_color_manual(values = COL_SECT, na.value = "gray70") +
  scale_x_continuous(labels = eje_coma) + scale_y_continuous(labels = eje_coma) +
  labs(title = "Con CV (v1)", x = "CV ingreso", y = "CV gasto", color = NULL) +
  theme_minimal(base_size = 11) +
  theme(legend.position = "bottom", plot.title = element_text(face = "bold"))

p2 <- muestra_B %>%
  filter(sd_ingreso > 0, sd_gasto > 0) %>%
  ggplot(aes(x = sd_ingreso, y = sd_gasto, color = sector_g)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "gray50") +
  geom_point(alpha = 0.7, size = 2) +
  scale_x_log10(labels = etiqueta_millones) +
  scale_y_log10(labels = etiqueta_millones) +
  scale_color_manual(values = COL_SECT, na.value = "gray70") +
  labs(title = "En pesos (v2)", x = "DE ingreso quincenal (millones, escala log)",
       y = "DE gasto quincenal (millones, escala log)", color = NULL) +
  theme_minimal(base_size = 11) +
  theme(legend.position = "bottom", plot.title = element_text(face = "bold"))

(p1 + p2) +
  plot_annotation(
    title = "Figura 3. Volatilidad del gasto vs. del ingreso por empresa (Submuestra B)",
    subtitle = "Puntos sobre la diagonal = el gasto varía más que el ingreso.",
    theme = theme(plot.title = element_text(face = "bold"))
  ) +
  plot_layout(guides = "collect") & theme(legend.position = "bottom")

Indicadores por sector

n_por_sector <- muestra_B %>% filter(!is.na(Sector)) %>% count(Sector)

muestra_B %>%
  filter(!is.na(sector_g)) %>%
  group_by(Sector = sector_g) %>%
  summarise(
    N                          = n(),
    `% quinc. negativas (med.)` = 100 * median(pct_quin_negativas),
    `DE gasto / DE ing. (med.)` = median(ratio_sd, na.rm = TRUE),
    `Corr. ing-gas (med.)`      = median(corr_ig, na.rm = TRUE),
    `PMG β (med.)`              = median(beta, na.rm = TRUE),
    `Elasticidad log (med.)`    = median(elast_log, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  arrange(desc(N)) %>%
  kable(caption = "Tabla 8. Indicadores de irregularidad y sensibilidad por sector (Submuestra B)",
        format.args = FMT_TABLA, digits = c(0, 0, 1, 2, 2, 2, 2)) %>%
  estilo(full_width = FALSE) %>%
  footnote(general_title = "Nota: ", footnote_as_chunk = TRUE,
           general = paste0("'Otros sectores' agrupa: ",
                            paste(n_por_sector$Sector[!n_por_sector$Sector %in% c("Comercio", "Servicios")],
                                  " (n = ", n_por_sector$n[!n_por_sector$Sector %in% c("Comercio", "Servicios")],
                                  ")", sep = "", collapse = ", "),
                            ". Con menos de 5 empresas por celda las medianas por sector no son interpretables."))
Tabla 8. Indicadores de irregularidad y sensibilidad por sector (Submuestra B)
Sector N % quinc. negativas (med.) DE gasto / DE ing. (med.) Corr. ing-gas (med.) PMG β (med.) Elasticidad log (med.)
Comercio 31 20,0 0,84 0,74 0,41 0,77
Servicios 13 28,6 0,91 0,86 0,57 0,83
Otros sectores 7 40,0 0,62 0,74 0,36 0,65
Nota: ‘Otros sectores’ agrupa: Agro (n = 2), Industria (n = 3), Otros (n = 2). Con menos de 5 empresas por celda las medianas por sector no son interpretables.

2.2 Propensión marginal a gastar por empresa

Distribución de la PMG (β)

CLASES_BETA <- c("Contracíclica (β < 0)", "Absorbe hasta la mitad (0 ≤ β ≤ 0,5)",
                 "Absorbe más de la mitad (0,5 < β ≤ 1)", "Sobre-reacciona (β > 1)")
COL_CLASES  <- setNames(c("#1a5276", COL_POS, "#f39c12", COL_NEG), CLASES_BETA)

clasificar_beta <- function(b) {
  factor(case_when(b < 0 ~ CLASES_BETA[1], b <= 0.5 ~ CLASES_BETA[2],
                   b <= 1 ~ CLASES_BETA[3], TRUE ~ CLASES_BETA[4]),
         levels = CLASES_BETA)
}

beta_clean <- muestra_B %>%
  filter(!is.na(beta)) %>%
  mutate(beta_win = pmin(pmax(beta, -2), 3),   # solo para visualizar
         clase_beta = clasificar_beta(beta))

med_beta <- median(beta_clean$beta)

beta_clean %>%
  ggplot(aes(x = beta_win)) +
  geom_histogram(aes(fill = clase_beta), binwidth = 0.15, color = "white", linewidth = 0.3) +
  geom_vline(xintercept = 0, linetype = "dashed", color = "gray40", linewidth = 0.8) +
  geom_vline(xintercept = 1, linetype = "dashed", color = COL_NEG, linewidth = 0.8) +
  geom_vline(xintercept = med_beta, color = COL_POS, linewidth = 1) +
  annotate("text", x = 0.05, y = Inf, vjust = 2, hjust = 0, size = 2.8, color = "gray40",
           label = "β = 0\n(las salidas no\nresponden)") +
  annotate("text", x = 1.05, y = Inf, vjust = 2, hjust = 0, size = 2.8, color = COL_NEG,
           label = "β = 1\n(sale todo el\ningreso adicional)") +
  annotate("text", x = med_beta + 0.05, y = Inf, vjust = 5, hjust = 0, size = 2.8, color = COL_POS,
           label = paste0("Mediana\n= ", fmt(med_beta))) +
  scale_fill_manual(values = COL_CLASES, name = NULL, drop = FALSE) +
  scale_x_continuous(labels = eje_coma) +
  labs(title = "Figura 4. Distribución de la propensión marginal a gastar (β) por empresa",
       subtitle = paste0("Submuestra B, N = ", nrow(beta_clean),
                         " empresas. β winsorizado en [−2, 3] solo para visualizar."),
       x = "PMG β (pendiente de G_qt sobre Y_qt dentro de cada empresa)",
       y = "Número de empresas") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom", plot.title = element_text(face = "bold"),
        legend.text = element_text(size = 9)) +
  guides(fill = guide_legend(nrow = 2))

beta_clean %>%
  count(clase_beta, name = "N", .drop = FALSE) %>%
  mutate(`%` = 100 * N / sum(N)) %>%
  rename(Clase = clase_beta) %>%
  kable(caption = "Tabla 9. Clasificación de empresas por PMG (Submuestra B)",
        format.args = FMT_TABLA, digits = c(0, 0, 1)) %>%
  estilo(full_width = FALSE)
Tabla 9. Clasificación de empresas por PMG (Submuestra B)
Clase N %
Contracíclica (β < 0) 8 15,7
Absorbe hasta la mitad (0 ≤ β ≤ 0,5) 24 47,1
Absorbe más de la mitad (0,5 < β ≤ 1) 14 27,5
Sobre-reacciona (β > 1) 5 9,8

¿Qué tan precisos son los β individuales? — NUEVO

Cada β individual sale de pocas quincenas. Antes de tratar la mediana de 51 pendientes como un dato exacto, vale la pena ver cuánto ruido trae cada una.

precision <- muestra_B %>%
  filter(!is.na(beta_se), n_quincenas_bilat > 2) %>%
  mutate(
    tc    = qt(0.975, n_quincenas_bilat - 2),
    lo    = beta - tc * beta_se,
    hi    = beta + tc * beta_se,
    inc_0 = lo <= 0 & hi >= 0,
    inc_1 = lo <= 1 & hi >= 1
  )

tibble(
  Indicador = c("Empresas con error estándar calculable",
                "Mediana del error estándar de β individual",
                "Mediana del ancho del IC 95% individual",
                "% de empresas cuyo IC 95% incluye β = 0",
                "% de empresas cuyo IC 95% incluye β = 1",
                "% cuyo IC incluye 0 y 1 a la vez (β no informativo)"),
  Valor = c(fmt(nrow(precision), 0),
            fmt(median(precision$beta_se)),
            fmt(median(precision$hi - precision$lo)),
            fmt_pct(mean(precision$inc_0)),
            fmt_pct(mean(precision$inc_1)),
            fmt_pct(mean(precision$inc_0 & precision$inc_1)))
) %>%
  kable(caption = "Tabla 10. Precisión de las PMG estimadas empresa por empresa (Submuestra B)") %>%
  estilo(full_width = FALSE)
Tabla 10. Precisión de las PMG estimadas empresa por empresa (Submuestra B)
Indicador Valor
Empresas con error estándar calculable 51
Mediana del error estándar de β individual 0,19
Mediana del ancho del IC 95% individual 0,88
% de empresas cuyo IC 95% incluye β = 0 47,1%
% de empresas cuyo IC 95% incluye β = 1 49,0%
% cuyo IC incluye 0 y 1 a la vez (β no informativo) 27,5%

Si una fracción alta de empresas tiene un IC que contiene a la vez 0 y 1, la clasificación de la Tabla 9 es en buena parte ruido de estimación: una empresa puede caer en “sobre-reacciona” solo por tener 5 quincenas. Por eso la v2 trae el panel de la sección 2.3, que agrega la información de todas las empresas antes de estimar.

Mediana de la PMG: IC bootstrap y pruebas

La medida principal es el IC bootstrap de la mediana (2000 réplicas). Se reportan dos pruebas: la de signo, que contrasta la mediana sin supuestos, y la de Wilcoxon (la de v1), que contrasta la pseudo-mediana y supone simetría.

tabla_mediana <- function(df, nombre) {
  b  <- df$beta[!is.na(df$beta)]
  e  <- df$elast_log[!is.na(df$elast_log)]
  ic <- boot_mediana(b)
  tibble(
    Submuestra = nombre,
    N          = length(b),
    med        = median(b),
    lo         = ic[1],
    hi         = ic[2],
    med_e      = if (length(e) > 0) median(e) else NA_real_,
    p_sig0     = p_signo(b, 0),
    p_sig1     = p_signo(b, 1),
    p_wil0     = wilcox.test(b, mu = 0)$p.value,
    p_wil1     = wilcox.test(b, mu = 1)$p.value,
    pct_neg    = mean(b < 0),
    pct_gt1    = mean(b > 1)
  )
}

set.seed(SEED)
res_med <- imap_dfr(SUBMUESTRAS, tabla_mediana)

res_med %>%
  transmute(
    Submuestra, N,
    `PMG mediana`             = fmt(med),
    `IC 95% bootstrap`        = fmt_ic(lo, hi),
    `Elasticidad log mediana` = fmt(med_e),
    `p signo (β=0)`           = fmt_p(p_sig0),
    `p signo (β=1)`           = fmt_p(p_sig1),
    `p Wilcoxon (β=0)`        = fmt_p(p_wil0),
    `p Wilcoxon (β=1)`        = fmt_p(p_wil1),
    `% β < 0`                 = fmt_pct(pct_neg),
    `% β > 1`                 = fmt_pct(pct_gt1)
  ) %>%
  kable(caption = "Tabla 11. Mediana de la PMG por submuestra: IC bootstrap y pruebas contra 0 y 1") %>%
  estilo(full_width = TRUE, font_size = 11) %>%
  row_spec(2, bold = TRUE, background = "#EBF5FB") %>%
  row_spec(4, bold = TRUE, background = "#D5F5E3")
Tabla 11. Mediana de la PMG por submuestra: IC bootstrap y pruebas contra 0 y 1
Submuestra N PMG mediana IC 95% bootstrap Elasticidad log mediana p signo (β=0) p signo (β=1) p Wilcoxon (β=0) p Wilcoxon (β=1) % β < 0 % β > 1
A — mínimo 67 0,43 [0,30; 0,56] 0,63 < 0,001 < 0,001 < 0,001 < 0,001 16,4% 16,4%
B — intermedio 51 0,41 [0,30; 0,53] 0,77 < 0,001 < 0,001 < 0,001 < 0,001 15,7% 9,8%
C — estricto 17 0,36 [0,15; 0,57] 0,65 0,002 < 0,001 0,004 < 0,001 11,8% 5,9%
D — confiabilidad 11 0,50 [0,19; 0,79] 0,74 < 0,001 0,065 < 0,001 0,054 0,0% 18,2%
rb <- res_med %>% filter(Submuestra == "B — intermedio")
txt_B <- case_when(
  rb$lo > 0 & rb$hi < 1 ~ paste0(
    "la mediana es distinta tanto de 0 como de 1: hay **suavización parcial**. ",
    "De cada $100 adicionales que entran en una quincena, salen alrededor de $",
    round(100 * rb$med), " (IC 95%: $", round(100 * rb$lo), " a $", round(100 * rb$hi), ")"),
  rb$lo <= 0 ~ "no se puede descartar que la PMG mediana sea 0",
  TRUE       ~ "no se puede descartar que la PMG mediana sea 1 (gastar todo lo que entra)"
)

Lectura para la Submuestra B: la mediana es distinta tanto de 0 como de 1: hay suavización parcial. De cada $100 adicionales que entran en una quincena, salen alrededor de $41 (IC 95%: $30 a $53).

2.3 Modelo de panel con efectos fijos — NUEVO

En lugar de estimar una pendiente por empresa y luego resumirlas, el panel estima una pendiente común usando solo la variación dentro de cada empresa (efecto fijo de empresa) y quitando choques comunes a todas en una misma quincena (efecto fijo de quincena: temporada, prima, fin de año). Los errores se agrupan por empresa.

  • M1–M3 (log-log): el coeficiente es una elasticidad.
  • M4–M5 (niveles normalizados): ingreso y gasto se dividen por el ingreso medio de cada empresa, para que las empresas grandes no dominen. El coeficiente es una PMG agrupada, comparable con la mediana de la sección 2.2.
panel_fe <- panel_bilateral %>%
  group_by(ID) %>%
  filter(n() >= MIN_Q_PANEL) %>%
  mutate(
    media_Y   = mean(Y_qt),
    y_norm    = Y_qt / media_Y,
    g_norm    = G_qt / media_Y,
    across(all_of(names(COMPONENTES)), ~ .x / media_Y, .names = "{.col}_norm"),
    gcorr_norm = (G_qt - G_fin - G_inv) / media_Y   # salidas corrientes: sin ahorro/deuda ni inversión
  ) %>%
  ungroup() %>%
  mutate(
    log_Y     = log(Y_qt),
    log_G     = log(G_qt),
    log_dias  = log(n_dias),
    log_trans = log(n_trans),
    log_Yop   = if_else(Y_op > 0, log(Y_op), NA_real_)
  )

cat("Panel con EF:", n_distinct(panel_fe$ID), "empresas |", nrow(panel_fe), "quincenas bilaterales\n")
## Panel con EF: 144 empresas | 646 quincenas bilaterales
m1  <- feols(log_G ~ log_Y                  | ID,          panel_fe, cluster = ~ID)
m2  <- feols(log_G ~ log_Y                  | ID + q_idx,  panel_fe, cluster = ~ID)
m3a <- feols(log_G ~ log_Y + log_dias       | ID + q_idx,  panel_fe, cluster = ~ID)
m3b <- feols(log_G ~ log_Y + log_trans      | ID + q_idx,  panel_fe, cluster = ~ID)
m4  <- feols(g_norm ~ y_norm                | ID + q_idx,  panel_fe, cluster = ~ID)
m5  <- feols(g_norm ~ y_norm + log_dias     | ID + q_idx,  panel_fe, cluster = ~ID)
m6  <- feols(log_G ~ log_Yop                | ID + q_idx,  panel_fe, cluster = ~ID)

tab_panel <- bind_rows(
  fila_fx(m1,  "log_Y",   "M1. Elasticidad — EF empresa"),
  fila_fx(m2,  "log_Y",   "M2. Elasticidad — EF empresa + quincena"),
  fila_fx(m3a, "log_Y",   "M3a. M2 + control días con registro"),
  fila_fx(m3b, "log_Y",   "M3b. M2 + control n.º de transacciones"),
  fila_fx(m4,  "y_norm",  "M4. PMG agrupada — EF empresa + quincena"),
  fila_fx(m5,  "y_norm",  "M5. M4 + control días con registro"),
  fila_fx(m6,  "log_Yop", "M6. Elasticidad con ingreso SOLO operativo")
)

kable_fx(tab_panel, "Tabla 12. Modelos de panel con efectos fijos (errores agrupados por empresa)")
Tabla 12. Modelos de panel con efectos fijos (errores agrupados por empresa)
Modelo Coef. EE (cluster) IC 95% p-valor Obs. Empresas
M1. Elasticidad — EF empresa 0,634 0,052 [0,531; 0,737] < 0,001 646 144
M2. Elasticidad — EF empresa + quincena 0,629 0,056 [0,519; 0,739] < 0,001 628 142
M3a. M2 + control días con registro 0,529 0,070 [0,390; 0,667] < 0,001 628 142
M3b. M2 + control n.º de transacciones 0,461 0,081 [0,300; 0,621] < 0,001 628 142
M4. PMG agrupada — EF empresa + quincena 0,491 0,233 [0,030; 0,951] 0,037 628 142
M5. M4 + control días con registro 0,252 0,238 [-0,218; 0,722] 0,291 628 142
M6. Elasticidad con ingreso SOLO operativo 0,646 0,059 [0,530; 0,762] < 0,001 610 141

Con efectos fijos de empresa y de quincena, un aumento de 10% en las entradas de la quincena se asocia con un aumento de 6,3% en las salidas (M2). En niveles, la PMG agrupada es 0,49 (M4): de cada $100 adicionales salen unos $49. Compárese con la mediana empresa por empresa de la sección 2.2 (0,41).

2.4 ¿Comportamiento o uso de la app? Control de intensidad de registro — NUEVO

Si en las quincenas en que la persona usa más la app registra a la vez más entradas y más salidas, β sale positivo aunque el negocio no cambie su comportamiento. Para ponerlo a prueba se controla por la intensidad de registro de la quincena.

  • Control principal: días con al menos un registro.
  • Control más agresivo: número de transacciones. Este control también absorbe actividad real (más ventas implican más transacciones), así que la caída de β con él es una cota superior del confusor.
panel_fe %>%
  summarise(
    `Corr(log días, log ingreso)`        = cor(log_dias, log_Y),
    `Corr(log días, log gasto)`          = cor(log_dias, log_G),
    `Corr(log transacciones, log ingreso)` = cor(log_trans, log_Y),
    `Corr(log transacciones, log gasto)`   = cor(log_trans, log_G)
  ) %>%
  pivot_longer(everything(), names_to = "Correlación (todo el panel)", values_to = "Valor") %>%
  mutate(Valor = fmt(Valor)) %>%
  kable(caption = "Tabla 13. ¿Cuánto se asocia la intensidad de registro con los montos registrados?") %>%
  estilo(full_width = FALSE)
Tabla 13. ¿Cuánto se asocia la intensidad de registro con los montos registrados?
Correlación (todo el panel) Valor
Corr(log días, log ingreso) 0,39
Corr(log días, log gasto) 0,40
Corr(log transacciones, log ingreso) 0,51
Corr(log transacciones, log gasto) 0,49
txt_registro <- case_when(
  abs(cambio_reg) < 0.10 ~ paste0(
    "La elasticidad cambia poco al controlar por días con registro (de ", fmt(e_m2), " a ", fmt(e_m3a),
    ", ", fmt_pct(cambio_reg), "): la co-variación entre entradas y salidas **no es solo** intensidad de uso de la app."),
  abs(cambio_reg) < 0.40 ~ paste0(
    "La elasticidad cae de forma apreciable al controlar por días con registro (de ", fmt(e_m2), " a ", fmt(e_m3a),
    ", ", fmt_pct(cambio_reg), "): una parte de la co-variación es intensidad de uso de la app y debe leerse con cautela."),
  TRUE ~ paste0(
    "La elasticidad cae mucho al controlar por días con registro (de ", fmt(e_m2), " a ", fmt(e_m3a),
    ", ", fmt_pct(cambio_reg), "): **buena parte de lo que se mide es uso de la app**, no comportamiento económico.")
)

txt_operativo <- if (is.na(e_m6)) {
  "No fue posible estimar el modelo con ingreso solo operativo."
} else if (abs(e_m6 - e_m2) / abs(e_m2) < 0.25) {
  paste0("Con ingreso solo operativo (sin préstamos ni retiros de ahorro) la elasticidad es ", fmt(e_m6),
         ", similar a la de M2: el resultado no lo producen los desembolsos de crédito.")
} else {
  paste0("Con ingreso solo operativo (sin préstamos ni retiros de ahorro) la elasticidad pasa a ", fmt(e_m6),
         " (vs. ", fmt(e_m2), " en M2): los flujos de financiamiento sí pesan en la estimación.")
}

La elasticidad cae de forma apreciable al controlar por días con registro (de 0,63 a 0,53, -16,0%): una parte de la co-variación es intensidad de uso de la app y debe leerse con cautela. Con el control más agresivo (número de transacciones) la elasticidad queda en 0,46. En niveles, la PMG agrupada pasa de 0,49 a 0,25.

Con ingreso solo operativo (sin préstamos ni retiros de ahorro) la elasticidad es 0,65, similar a la de M2: el resultado no lo producen los desembolsos de crédito.

2.5 ¿Sesga el filtro bilateral? — NUEVO

Quedarse solo con quincenas con entradas y salidas bota justo las quincenas donde se vería suavización: gastar sin que entre plata, o que entre plata sin gastar. La PMG agrupada (M4) se re-estima incluyendo esas quincenas.

panel_q %>%
  mutate(tipo_q = case_when(
    Y_qt > 0 & G_qt > 0 ~ "Bilateral (entradas y salidas)",
    Y_qt > 0            ~ "Solo entradas",
    G_qt > 0            ~ "Solo salidas",
    TRUE                ~ "Sin montos"
  )) %>%
  count(`Tipo de quincena` = tipo_q, name = "Quincenas") %>%
  mutate(`%` = 100 * Quincenas / sum(Quincenas)) %>%
  kable(caption = "Tabla 14. Quincenas con registro según tipo", format.args = FMT_TABLA,
        digits = c(0, 0, 1)) %>%
  estilo(full_width = FALSE)
Tabla 14. Quincenas con registro según tipo
Tipo de quincena Quincenas %
Bilateral (entradas y salidas) 816 53,8
Solo entradas 462 30,4
Solo salidas 240 15,8
panel_todo <- panel_q %>%
  group_by(ID) %>%
  filter(n() >= MIN_Q_PANEL, mean(Y_qt) > 0) %>%
  mutate(media_Y = mean(Y_qt), y_norm = Y_qt / media_Y, g_norm = G_qt / media_Y) %>%
  ungroup()

m4_todo <- feols(g_norm ~ y_norm | ID + q_idx, panel_todo, cluster = ~ID)

ids_ambos <- intersect(unique(panel_fe$ID), unique(panel_todo$ID))
m4_misma  <- feols(g_norm ~ y_norm | ID + q_idx, panel_fe %>% filter(ID %in% ids_ambos), cluster = ~ID)
m4_todo_m <- feols(g_norm ~ y_norm | ID + q_idx, panel_todo %>% filter(ID %in% ids_ambos), cluster = ~ID)

bind_rows(
  fila_fx(m4,        "y_norm", "M4. Solo quincenas bilaterales (v1)"),
  fila_fx(m4_todo,   "y_norm", "M4'. Todas las quincenas con registro"),
  fila_fx(m4_misma,  "y_norm", "M4 — mismas empresas, solo bilaterales"),
  fila_fx(m4_todo_m, "y_norm", "M4' — mismas empresas, todas las quincenas")
) %>%
  kable_fx("Tabla 15. PMG agrupada con y sin el filtro bilateral")
Tabla 15. PMG agrupada con y sin el filtro bilateral
Modelo Coef. EE (cluster) IC 95% p-valor Obs. Empresas
M4. Solo quincenas bilaterales (v1) 0,491 0,233 [0,030; 0,951] 0,037 628 142
M4’. Todas las quincenas con registro 0,355 0,115 [0,128; 0,582] 0,002 1.216 275
M4 — mismas empresas, solo bilaterales 0,491 0,233 [0,030; 0,951] 0,037 628 142
M4’ — mismas empresas, todas las quincenas 0,470 0,121 [0,230; 0,711] < 0,001 881 144
pmg_todo <- coef(m4_todo_m)[["y_norm"]]
pmg_bil  <- coef(m4_misma)[["y_norm"]]

Para las mismas empresas, la PMG agrupada es 0,49 con el filtro bilateral y 0,47 sin él. El filtro bilateral no cambia de forma importante la PMG agrupada.

2.6 ¿Qué se mueve con el ingreso? Descomposición por componente — NUEVO

Como las regresiones en niveles normalizados son lineales y usan la misma muestra y los mismos efectos fijos, la PMG total es exactamente la suma de las PMG de cada componente. Eso permite decir de dónde salen los pesos que responden al ingreso.

vars_comp <- c("Total salidas" = "g_norm",
               setNames(paste0(names(COMPONENTES), "_norm"), COMPONENTES))

mods_comp <- map(vars_comp, ~ feols(as.formula(paste(.x, "~ y_norm | ID + q_idx")),
                                    panel_fe, cluster = ~ID))

tab_comp <- imap_dfr(mods_comp, ~ fila_fx(.x, "y_norm", .y)) %>%
  rename(Componente = Modelo) %>%
  mutate(`Pesos por cada $100` = 100 * coef,
         `% de la PMG total`   = if_else(Componente == "Total salidas", NA_real_,
                                         100 * coef / coef[Componente == "Total salidas"]))

suma_comp <- sum(tab_comp$coef[tab_comp$Componente != "Total salidas"])
cat("Chequeo de aditividad: suma de componentes =", fmt(suma_comp, 4),
    "| total =", fmt(tab_comp$coef[tab_comp$Componente == "Total salidas"], 4), "\n")
## Chequeo de aditividad: suma de componentes = 0,4905 | total = 0,4905
# Participación de cada componente en el monto de salidas (descriptivo)
part_monto <- panel_fe %>%
  summarise(across(all_of(names(COMPONENTES)), sum)) %>%
  pivot_longer(everything(), names_to = "var", values_to = "monto") %>%
  mutate(Componente = unname(COMPONENTES[var]), `% del monto de salidas` = 100 * monto / sum(monto)) %>%
  select(Componente, `% del monto de salidas`)

# Empresas que registran gasto personal alguna vez
emp_pers <- panel_fe %>%
  group_by(ID) %>%
  summarise(q_pers = sum(G_pers > 0), .groups = "drop")

tab_comp %>%
  left_join(part_monto, by = "Componente") %>%
  transmute(
    Componente,
    `PMG`                    = fmt(coef, 3),
    `IC 95%`                 = fmt_ic(ic_lo, ic_hi, 3),
    `p-valor`                = fmt_p(p),
    `Pesos por cada $100`    = fmt(`Pesos por cada $100`, 1),
    `% de la PMG total`      = fmt(`% de la PMG total`, 1),
    `% del monto de salidas` = fmt(`% del monto de salidas`, 1)
  ) %>%
  kable(caption = paste0("Tabla 16. Descomposición de la PMG agrupada por componente del gasto (",
                         n_distinct(panel_fe$ID), " empresas, EF empresa + quincena)")) %>%
  estilo(full_width = TRUE) %>%
  row_spec(1, bold = TRUE)
Tabla 16. Descomposición de la PMG agrupada por componente del gasto (144 empresas, EF empresa + quincena)
Componente PMG IC 95% p-valor Pesos por cada $100 % de la PMG total % del monto de salidas
Total salidas 0,491 [0,030; 0,951] 0,037 49,1 — —
Operativo variable 0,213 [0,116; 0,309] < 0,001 21,3 43,4 49,8
Operativo fijo 0,287 [-0,076; 0,650] 0,120 28,7 58,5 22,9
Inversión -0,005 [-0,022; 0,012] 0,544 -0,5 -1,1 1,3
Personal / hogar 0,101 [0,013; 0,189] 0,024 10,1 20,6 18,3
Financiero (ahorro y deuda) -0,109 [-0,363; 0,145] 0,398 -10,9 -22,2 6,9
Otros / sin clasificar 0,004 [-0,007; 0,015] 0,464 0,4 0,9 0,8
tab_comp %>%
  filter(Componente != "Total salidas") %>%
  mutate(Componente = fct_reorder(Componente, coef)) %>%
  ggplot(aes(x = 100 * coef, y = Componente, xmin = 100 * ic_lo, xmax = 100 * ic_hi)) +
  geom_vline(xintercept = 0, linetype = "dashed", color = "gray50") +
  geom_pointrange(color = COL_AZUL, linewidth = 0.8) +
  scale_x_continuous(labels = eje_coma) +
  labs(title = "Figura 5. ¿Cuántos pesos de cada componente salen por cada $100 adicionales de ingreso?",
       subtitle = "PMG agrupada por componente con IC 95% (errores agrupados por empresa). Los componentes suman la PMG total.",
       x = "Pesos de salida por cada $100 adicionales de entrada", y = NULL) +
  theme_minimal(base_size = 12) +
  theme(plot.title = element_text(face = "bold"))

# ── Gasto personal, solo empresas que lo registran (≥ 2 quincenas) ──────────
ids_pers <- emp_pers %>% filter(q_pers >= 2) %>% pull(ID)

m_pers_sub <- if (length(ids_pers) >= 5) {
  feols(G_pers_norm ~ y_norm | ID + q_idx, panel_fe %>% filter(ID %in% ids_pers), cluster = ~ID)
} else NULL

m_corr <- feols(gcorr_norm ~ y_norm | ID + q_idx, panel_fe, cluster = ~ID)

bind_rows(
  fila_fx(m_corr,     "y_norm", "Salidas corrientes (sin ahorro, deuda ni inversión) — todas"),
  fila_fx(m_pers_sub, "y_norm", "Personal / hogar — solo empresas que lo registran (≥ 2 quincenas)")
) %>%
  kable_fx("Tabla 17. PMG de las salidas corrientes y del gasto personal")
Tabla 17. PMG de las salidas corrientes y del gasto personal
Modelo Coef. EE (cluster) IC 95% p-valor Obs. Empresas
Salidas corrientes (sin ahorro, deuda ni inversión) — todas 0,605 0,187 [0,236; 0,974] 0,002 628 142
Personal / hogar — solo empresas que lo registran (≥ 2 quincenas) 0,137 0,064 [0,009; 0,265] 0,037 285 54
c_tot  <- tab_comp$coef[tab_comp$Componente == "Total salidas"]
c_oper <- sum(tab_comp$coef[tab_comp$Componente %in% c("Operativo variable", "Operativo fijo")])
c_pers <- tab_comp$coef[tab_comp$Componente == "Personal / hogar"]
c_fin  <- tab_comp$coef[tab_comp$Componente == "Financiero (ahorro y deuda)"]
sh_oper <- c_oper / c_tot
pct_emp_pers <- mean(emp_pers$q_pers >= 1)
f_pers <- fila_fx(m_pers_sub, "y_norm", "pers")

txt_comp <- paste0(
  "De cada $100 adicionales de ingreso, las salidas suben unos $", round(100 * c_tot), ": $",
  round(100 * c_oper), " son costos operativos (variables y fijos), $", round(100 * c_pers),
  " gasto personal o del hogar y $", round(100 * c_fin), " ahorro y pagos de deuda. ",
  if (pct_emp_pers < 0.5) {
    paste0("Solo el ", fmt_pct(pct_emp_pers), " de las empresas del panel registra gasto personal alguna vez. ")
  } else {
    paste0("El ", fmt_pct(pct_emp_pers), " de las empresas del panel registra gasto personal al menos una vez, ",
           "aunque no hay forma de saber si todo el gasto del hogar pasa por la app. ")
  },
  if (sh_oper > 0.5) {
    paste0("Como más de la mitad de la respuesta (", fmt_pct(sh_oper), ") es costo operativo, ",
           "**el β agregado describe sobre todo la estructura de costos del negocio** — una tienda que vende más ",
           "compra más inventario —, no miopía financiera. ")
  } else {
    paste0("La respuesta no está dominada por los costos operativos (", fmt_pct(sh_oper), " del total), ",
           "lo que hace más plausible leer parte de β como respuesta del gasto no operativo al ingreso corriente. ")
  },
  if (!is.na(f_pers$coef)) {
    paste0("La prueba más cercana a la HIP es la del gasto personal entre las empresas que lo registran (Tabla 17): ",
           "PMG de ", fmt(f_pers$coef, 3), " ", fmt_ic(f_pers$ic_lo, f_pers$ic_hi, 3), ", es decir, unos $",
           fmt(100 * f_pers$coef, 1), " de gasto del hogar por cada $100 adicionales que entran al negocio. ",
           if (f_pers$ic_lo <= 0) "No se distingue de cero: es compatible con que el hogar suavice su gasto frente a la caja del negocio. "
           else "Es distinta de cero: el gasto del hogar sí se mueve con la caja del negocio, aunque en una proporción pequeña frente al total. ")
  } else {
    "No hay suficientes empresas que registren gasto personal para estimar su PMG por separado. "
  },
  if (!is.na(c_fin) && c_fin > 0) {
    "Además, el componente financiero es positivo: en las buenas quincenas se ahorra o se paga más deuda, lo cual es **suavización**, pero en v1 se contaba como \"gasto\"."
  } else ""
)

Lectura. De cada $100 adicionales de ingreso, las salidas suben unos $49: $50 son costos operativos (variables y fijos), $10 gasto personal o del hogar y $-11 ahorro y pagos de deuda. El 56,2% de las empresas del panel registra gasto personal al menos una vez, aunque no hay forma de saber si todo el gasto del hogar pasa por la app. Como más de la mitad de la respuesta (101,9%) es costo operativo, el β agregado describe sobre todo la estructura de costos del negocio — una tienda que vende más compra más inventario —, no miopía financiera. La prueba más cercana a la HIP es la del gasto personal entre las empresas que lo registran (Tabla 17): PMG de 0,137 [0,009; 0,265], es decir, unos $13,7 de gasto del hogar por cada $100 adicionales que entran al negocio. Es distinta de cero: el gasto del hogar sí se mueve con la caja del negocio, aunque en una proporción pequeña frente al total.

2.7 Asimetría: ¿responde igual el gasto cuando el ingreso sube que cuando cae? — NUEVO

Con restricciones de liquidez (Zeldes, 1989; Shea, 1995), el gasto debería caer cuando el ingreso cae por debajo de lo habitual, porque no hay colchón, y subir menos cuando el ingreso sube, porque se ahorra el excedente. Se separa la desviación del log-ingreso respecto a la media de la empresa en su parte positiva y su parte negativa.

panel_asim <- panel_fe %>%
  group_by(ID) %>%
  mutate(dev = log_Y - mean(log_Y), dev_pos = pmax(dev, 0), dev_neg = pmin(dev, 0)) %>%
  ungroup()

m_asim  <- feols(log_G ~ dev_pos + dev_neg | ID + q_idx, panel_asim, cluster = ~ID)
# Reparametrización: el coeficiente de dev_neg aquí es (β− − β+) → prueba directa de asimetría
m_asim2 <- feols(log_G ~ dev + dev_neg     | ID + q_idx, panel_asim, cluster = ~ID)

bind_rows(
  fila_fx(m_asim,  "dev_pos", "β+ — ingreso por ENCIMA de lo habitual"),
  fila_fx(m_asim,  "dev_neg", "β− — ingreso por DEBAJO de lo habitual"),
  fila_fx(m_asim2, "dev_neg", "Diferencia β− − β+")
) %>%
  kable_fx("Tabla 18. Respuesta asimétrica del gasto (elasticidades, EF empresa + quincena)")
Tabla 18. Respuesta asimétrica del gasto (elasticidades, EF empresa + quincena)
Modelo Coef. EE (cluster) IC 95% p-valor Obs. Empresas
β+ — ingreso por ENCIMA de lo habitual 0,879 0,094 [0,693; 1,064] < 0,001 628 142
β− — ingreso por DEBAJO de lo habitual 0,382 0,112 [0,160; 0,603] < 0,001 628 142
Diferencia β− − β+ -0,497 0,175 [-0,842; -0,152] 0,005 628 142
b_pos  <- coef(m_asim)[["dev_pos"]]
b_neg  <- coef(m_asim)[["dev_neg"]]
p_asim <- coeftable(m_asim2)["dev_neg", 4]

txt_asim <- case_when(
  p_asim < 0.10 & b_neg > b_pos ~ paste0(
    "El gasto responde más cuando el ingreso cae por debajo de lo habitual (β− = ", fmt(b_neg),
    ") que cuando sube (β+ = ", fmt(b_pos), "; ", txt_p(p_asim), " para la diferencia",
    "). Es el patrón esperado con **restricciones de liquidez**: en las malas quincenas no hay colchón para sostener el gasto. ",
    "Esto conecta directamente con la pregunta de acceso a crédito."),
  p_asim < 0.10 & b_pos > b_neg ~ paste0(
    "El gasto responde más a las subidas del ingreso (β+ = ", fmt(b_pos), ") que a las caídas (β− = ",
    fmt(b_neg), "; ", txt_p(p_asim), " para la diferencia): en las malas quincenas el gasto se sostiene, ",
    "lo que sugiere algún mecanismo de amortiguación (ahorro previo, fiado o crédito informal)."),
  TRUE ~ paste0(
    "No hay evidencia estadística de asimetría (β+ = ", fmt(b_pos), ", β− = ", fmt(b_neg),
    "; ", txt_p(p_asim), " para la diferencia). Con este tamaño de muestra la prueba tiene poco poder: ",
    "la ausencia de evidencia no es evidencia de simetría.")
)

El gasto responde más a las subidas del ingreso (β+ = 0,88) que a las caídas (β− = 0,38; p = 0,005 para la diferencia): en las malas quincenas el gasto se sostiene, lo que sugiere algún mecanismo de amortiguación (ahorro previo, fiado o crédito informal).

2.8 Rezagos y adelantos: ¿se gasta antes o después de que entre la plata? — NUEVO

Si las salidas de hoy predicen las entradas de la quincena siguiente (por ejemplo, se compra inventario y luego se vende), el β contemporáneo está mezclando tiempos. Este modelo solo usa empresas con quincenas bilaterales consecutivas, así que pierde muchas observaciones.

# Adelanto y rezago construidos a mano: solo cuentan quincenas CALENDARIO contiguas
panel_rez <- panel_fe %>%
  arrange(ID, q_idx) %>%
  group_by(ID) %>%
  mutate(
    log_Y_sig = if_else(lead(q_idx) == q_idx + 1, lead(log_Y), NA_real_),   # quincena siguiente
    log_Y_ant = if_else(lag(q_idx)  == q_idx - 1, lag(log_Y),  NA_real_)    # quincena anterior
  ) %>%
  ungroup() %>%
  filter(!is.na(log_Y_sig), !is.na(log_Y_ant))

m_rez <- if (nrow(panel_rez) >= 30 && n_distinct(panel_rez$ID) >= 5) {
  tryCatch(feols(log_G ~ log_Y_sig + log_Y + log_Y_ant | ID, panel_rez, cluster = ~ID),
           error = function(e) { cat("No se pudo estimar:", conditionMessage(e), "\n"); NULL })
} else NULL

if (!is.null(m_rez)) {
  tab_rez <- bind_rows(
    fila_fx(m_rez, "log_Y_sig", "Ingreso de la quincena SIGUIENTE (adelanto)"),
    fila_fx(m_rez, "log_Y",     "Ingreso de la MISMA quincena"),
    fila_fx(m_rez, "log_Y_ant", "Ingreso de la quincena ANTERIOR (rezago)")
  )

  ct_rez <- coeftable(m_rez)
  p_lead <- ct_rez["log_Y_sig", 4]; b_lead <- ct_rez["log_Y_sig", 1]
  p_lag  <- ct_rez["log_Y_ant", 4]; b_lag  <- ct_rez["log_Y_ant", 1]

  txt_rez <- paste(
    if (nobs(m_rez) < 100) paste0("**Advertencia:** solo ", nobs(m_rez),
                                  " observaciones con tres quincenas bilaterales seguidas; leer con mucha cautela.") else "",
    if (p_lead < 0.10 & b_lead > 0) {
      "Las salidas de hoy se asocian con las entradas de la quincena siguiente: es consistente con comprar inventario antes de vender, y quiere decir que parte del β contemporáneo es un efecto de calendario y no de miopía."
    } else {
      "No hay asociación significativa entre las salidas de hoy y las entradas de la quincena siguiente."
    },
    if (p_lag < 0.10 & b_lag > 0) {
      "Las salidas también responden al ingreso de la quincena anterior: el gasto se ajusta con rezago a la caja disponible."
    } else {
      "Las salidas no responden de forma significativa al ingreso de la quincena anterior."
    }
  )
} else {
  txt_rez <- paste0("Solo hay ", nrow(panel_rez), " observaciones con tres quincenas bilaterales seguidas: ",
                    "no alcanzan para estimar este modelo.")
}

# La tabla va al final para que knitr la imprima
if (!is.null(m_rez)) {
  kable_fx(tab_rez, "Tabla 19. Elasticidad del gasto a ingresos pasados, presentes y futuros (EF empresa)")
}
Tabla 19. Elasticidad del gasto a ingresos pasados, presentes y futuros (EF empresa)
Modelo Coef. EE (cluster) IC 95% p-valor Obs. Empresas
Ingreso de la quincena SIGUIENTE (adelanto) 0,009 0,046 [-0,084; 0,101] 0,853 195 44
Ingreso de la MISMA quincena 0,806 0,112 [0,580; 1,033] < 0,001 195 44
Ingreso de la quincena ANTERIOR (rezago) 0,056 0,065 [-0,076; 0,187] 0,397 195 44

No hay asociación significativa entre las salidas de hoy y las entradas de la quincena siguiente. Las salidas no responden de forma significativa al ingreso de la quincena anterior.

2.9 Heterogeneidad: ¿quién suaviza más?

Interacciones en el panel — NUEVO

En vez de regresar los β ruidosos de cada empresa sobre sus características (v1), se estima la PMG agrupada permitiendo que cambie según el perfil. Se hace para el total de salidas y para el gasto personal. La hipótesis con teoría detrás es la de fungibilidad: quien no separa las finanzas del negocio y las del hogar debería tener una PMG del gasto personal más alta.

panel_het <- panel_fe %>%
  inner_join(indicadores_emp %>% select(ID, separa_fin, es_mujer, es_digital, sector_g), by = "ID") %>%
  filter(if_all(c(separa_fin, es_mujer, es_digital, sector_g), ~ !is.na(.x)))

rhs_het <- paste("y_norm + y_norm:separa_fin + y_norm:es_mujer + y_norm:es_digital +",
                 "i(sector_g, y_norm, ref = 'Comercio') | ID + q_idx")

m_het      <- feols(as.formula(paste("g_norm ~",      rhs_het)), panel_het, cluster = ~ID)
m_het_pers <- feols(as.formula(paste("G_pers_norm ~", rhs_het)), panel_het, cluster = ~ID)

etiqueta_het <- function(t) case_when(
  t == "y_norm"                  ~ "PMG base (Comercio, no separa, hombre, tradicional)",
  str_detect(t, "separa_fin")    ~ "× Separa finanzas",
  str_detect(t, "es_mujer")      ~ "× Propietaria mujer",
  str_detect(t, "es_digital")    ~ "× Perfil digital",
  str_detect(t, "Servicios")     ~ "× Servicios (vs. Comercio)",
  str_detect(t, "Otros sectores") ~ "× Otros sectores (vs. Comercio)",
  TRUE ~ t
)

tab_het <- bind_rows(coefs_fx(m_het, "Total salidas"),
                     coefs_fx(m_het_pers, "Gasto personal / hogar")) %>%
  mutate(Término = etiqueta_het(termino))

tab_het %>%
  transmute(Modelo, Término, `Coef.` = fmt(coef, 3), `IC 95%` = fmt_ic(ic_lo, ic_hi, 3),
            `p-valor` = fmt_p(p)) %>%
  kable(caption = paste0("Tabla 20. PMG agrupada según perfil de la empresa (",
                         n_distinct(panel_het$ID), " empresas, EF empresa + quincena)")) %>%
  estilo(full_width = TRUE) %>%
  collapse_rows(columns = 1, valign = "top")
Tabla 20. PMG agrupada según perfil de la empresa (139 empresas, EF empresa + quincena)
Modelo Término Coef. IC 95% p-valor
Total salidas PMG base (Comercio, no separa, hombre, tradicional) -1,223 [-3,307; 0,860] 0,248
× Otros sectores (vs. Comercio) 0,268 [-1,070; 1,606] 0,693
× Servicios (vs. Comercio) 1,522 [0,025; 3,019] 0,046
× Separa finanzas -0,104 [-1,164; 0,955] 0,846
× Propietaria mujer 1,021 [-0,588; 2,631] 0,212
× Perfil digital 0,800 [-0,777; 2,377] 0,317
Gasto personal / hogar PMG base (Comercio, no separa, hombre, tradicional) 0,220 [-0,010; 0,451] 0,061
× Otros sectores (vs. Comercio) -0,057 [-0,248; 0,134] 0,554
× Servicios (vs. Comercio) 0,074 [-0,075; 0,223] 0,327
× Separa finanzas -0,212 [-0,364; -0,060] 0,007
× Propietaria mujer 0,001 [-0,153; 0,155] 0,990
× Perfil digital -0,020 [-0,140; 0,101] 0,750
tab_het %>%
  filter(termino != "y_norm") %>%
  ggplot(aes(x = coef, y = Término, xmin = ic_lo, xmax = ic_hi, color = Modelo)) +
  geom_vline(xintercept = 0, linetype = "dashed", color = "gray50") +
  geom_pointrange(position = position_dodge(width = 0.5), linewidth = 0.8) +
  scale_color_manual(values = c("Total salidas" = COL_AZUL, "Gasto personal / hogar" = "#e67e22"),
                     name = NULL) +
  scale_x_continuous(labels = eje_coma) +
  labs(title = "Figura 6. Diferencias en la PMG según perfil de la empresa",
       subtitle = "Coeficiente > 0 = la PMG es más alta (menos suavización) en ese grupo. Barras = IC 95%.",
       x = "Cambio en la PMG agrupada", y = NULL) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom", plot.title = element_text(face = "bold"))

Regresión en dos etapas (v1, corregida)

Se conserva como referencia, ahora con errores HC3 de verdad y sectores agrupados. Con una empresa por observación y pocas empresas por categoría, su poder es bajo.

reg_data <- muestra_B %>%
  filter(!is.na(beta), !is.na(sector_g), !is.na(es_mujer),
         !is.na(es_digital), !is.na(separa_fin)) %>%
  mutate(beta_win = pmin(pmax(beta, -2), 3),
         sector_g = relevel(factor(sector_g), ref = "Comercio"))

modelo <- lm(beta_win ~ sector_g + es_mujer + es_digital + separa_fin + n_quincenas_bilat,
             data = reg_data)

ct_hc3 <- coeftest(modelo, vcov. = vcovHC(modelo, type = "HC3"))
tc     <- qt(0.975, df.residual(modelo))

tibble(term = rownames(ct_hc3), est = ct_hc3[, 1], se = ct_hc3[, 2], p = ct_hc3[, 4]) %>%
  mutate(
    Variable = recode(term,
      "(Intercept)"              = "Constante",
      "sector_gServicios"        = "Servicios (ref. Comercio)",
      "sector_gOtros sectores"   = "Otros sectores (ref. Comercio)",
      "es_mujer"                 = "Propietaria mujer",
      "es_digital"               = "Perfil digital",
      "separa_fin"               = "Separa finanzas",
      "n_quincenas_bilat"        = "N.º quincenas observadas (control)"),
    `Coef.`      = fmt(est, 3),
    `EE (HC3)`   = fmt(se, 3),
    `IC 95%`     = fmt_ic(est - tc * se, est + tc * se, 3),
    `p-valor`    = fmt_p(p)
  ) %>%
  select(Variable, `Coef.`, `EE (HC3)`, `IC 95%`, `p-valor`) %>%
  kable(caption = paste0("Tabla 21. Regresión en dos etapas: predictores de β (N = ",
                         nrow(reg_data), ", Submuestra B, errores HC3)")) %>%
  estilo(full_width = TRUE)
Tabla 21. Regresión en dos etapas: predictores de β (N = 49, Submuestra B, errores HC3)
Variable Coef. EE (HC3) IC 95% p-valor
Constante 0,327 0,533 [-0,750; 1,403] 0,544
Otros sectores (ref. Comercio) -0,029 0,228 [-0,490; 0,432] 0,899
Servicios (ref. Comercio) 0,278 0,291 [-0,309; 0,865] 0,344
Propietaria mujer -0,027 0,417 [-0,869; 0,815] 0,950
Perfil digital 0,202 0,216 [-0,234; 0,638] 0,356
Separa finanzas 0,141 0,230 [-0,323; 0,605] 0,543
N.º quincenas observadas (control) -0,015 0,022 [-0,060; 0,030] 0,498

2.10 Robustez entre submuestras

En lugar del umbral |Δ| < 0,10 de v1, se comparan tres estimadores con sus intervalos de confianza en cada submuestra: la mediana de β por empresa (IC bootstrap), la PMG agrupada del panel (M4) y la elasticidad del panel (M2). También se reporta B sin las empresas de Agro, como sensibilidad.

panel_sub <- function(ids, nombre) {
  d <- panel_fe %>% filter(ID %in% ids)
  m_e <- tryCatch(feols(log_G ~ log_Y | ID + q_idx, d, cluster = ~ID), error = function(e) NULL)
  m_p <- tryCatch(feols(g_norm ~ y_norm | ID + q_idx, d, cluster = ~ID), error = function(e) NULL)
  bind_rows(
    fila_fx(m_p, "y_norm", nombre) %>% mutate(Estimador = "PMG agrupada (panel M4)"),
    fila_fx(m_e, "log_Y",  nombre) %>% mutate(Estimador = "Elasticidad (panel M2)")
  )
}

ids_B_sin_agro <- muestra_B %>% filter(is.na(Sector) | Sector != "Agro") %>% pull(ID)

set.seed(SEED)
med_B_sin_agro <- tabla_mediana(muestra_B %>% filter(ID %in% ids_B_sin_agro), "B sin Agro")

rob <- bind_rows(
  imap_dfr(SUBMUESTRAS, ~ panel_sub(.x$ID, .y)),
  panel_sub(ids_B_sin_agro, "B sin Agro"),
  bind_rows(res_med, med_B_sin_agro) %>%
    transmute(Modelo = Submuestra, coef = med, ic_lo = lo, ic_hi = hi, empresas = N,
              Estimador = "Mediana de β por empresa (bootstrap)")
) %>%
  rename(Submuestra = Modelo) %>%
  mutate(Submuestra = factor(Submuestra, levels = rev(c(names(SUBMUESTRAS), "B sin Agro"))),
         Estimador  = factor(Estimador, levels = c("Mediana de β por empresa (bootstrap)",
                                                   "PMG agrupada (panel M4)",
                                                   "Elasticidad (panel M2)")))

rob %>%
  arrange(Estimador, desc(Submuestra)) %>%
  transmute(Estimador, Submuestra, Empresas = fmt(empresas, 0),
            Estimación = fmt(coef, 3), `IC 95%` = fmt_ic(ic_lo, ic_hi, 3)) %>%
  kable(caption = "Tabla 22. Robustez: tres estimadores en cada submuestra") %>%
  estilo(full_width = TRUE) %>%
  collapse_rows(columns = 1, valign = "top")
Tabla 22. Robustez: tres estimadores en cada submuestra
Estimador Submuestra Empresas Estimación IC 95%
Mediana de β por empresa (bootstrap) A — mínimo 67 0,429 [0,302; 0,560]
B — intermedio 51 0,409 [0,302; 0,530]
C — estricto 17 0,361 [0,154; 0,565]
D — confiabilidad 11 0,502 [0,191; 0,794]
B sin Agro 49 0,409 [0,275; 0,530]
PMG agrupada (panel M4) A — mínimo 98 0,563 [0,114; 1,011]
B — intermedio 51 0,335 [-0,046; 0,717]
C — estricto 17 0,538 [0,263; 0,814]
D — confiabilidad 11 0,762 [0,357; 1,167]
B sin Agro 49 0,331 [-0,134; 0,796]
Elasticidad (panel M2) A — mínimo 98 0,642 [0,531; 0,754]
B — intermedio 51 0,641 [0,499; 0,784]
C — estricto 17 0,653 [0,446; 0,861]
D — confiabilidad 11 0,654 [0,296; 1,012]
B sin Agro 49 0,634 [0,476; 0,792]
rob %>%
  ggplot(aes(x = coef, y = Submuestra, xmin = ic_lo, xmax = ic_hi)) +
  geom_vline(xintercept = 0, linetype = "dashed", color = "gray50") +
  geom_vline(xintercept = 1, linetype = "dashed", color = COL_NEG) +
  geom_pointrange(aes(color = Submuestra == "B — intermedio"), linewidth = 0.8, show.legend = FALSE) +
  scale_color_manual(values = c(`TRUE` = COL_AZUL, `FALSE` = COL_GRIS)) +
  scale_x_continuous(labels = eje_coma) +
  facet_wrap(~ Estimador, ncol = 1, scales = "free_x") +
  labs(title = "Figura 7. Robustez de la sensibilidad del gasto al ingreso entre submuestras",
       subtitle = "Puntos = estimación; barras = IC 95%. Líneas punteadas en 0 y 1.",
       x = NULL, y = NULL) +
  theme_minimal(base_size = 11) +
  theme(plot.title = element_text(face = "bold"), strip.text = element_text(face = "bold"))

rD <- res_med %>% filter(Submuestra == "D — confiabilidad")
ancho_D <- rD$hi - rD$lo
ancho_B <- rb$hi - rb$lo
# D se considera imprecisa si su IC es más del doble de ancho que el de B
D_impreciso <- !is.na(ancho_D) && ancho_D > 2 * ancho_B
txt_D <- if (is.na(ancho_D)) {
  "La Submuestra D no tiene suficientes empresas para un IC bootstrap."
} else if (rb$med >= rD$lo & rb$med <= rD$hi & D_impreciso) {
  paste0("La mediana de D (", fmt(rD$med), ") es compatible con la de B, pero su IC (", fmt_ic(rD$lo, rD$hi),
         ") es ", fmt(ancho_D / ancho_B, 1), " veces más ancho que el de B: **D no permite confirmar ni descartar** ",
         "que los cargues retroactivos sesguen el resultado.")
} else if (rb$med >= rD$lo & rb$med <= rD$hi) {
  paste0("La mediana de D (", fmt(rD$med), ") es compatible con la de B y su IC es razonablemente estrecho (",
         fmt_ic(rD$lo, rD$hi), "): no hay señal de que los cargues retroactivos sesguen el resultado.")
} else {
  paste0("La mediana de B queda fuera del IC de D (", fmt_ic(rD$lo, rD$hi),
         "): hay señal de que los cargues retroactivos (Capa 3) afectan la estimación.")
}

La mediana de D (0,50) es compatible con la de B, pero su IC ([0,19; 0,79]) es 2,6 veces más ancho que el de B: D no permite confirmar ni descartar que los cargues retroactivos sesguen el resultado. Con solo 11 empresas, además, los errores agrupados del panel en D son poco confiables.


3. Síntesis de hallazgos

r_A <- res_med %>% filter(Submuestra == "A — mínimo")
r_C <- res_med %>% filter(Submuestra == "C — estricto")
vol_B <- tab_vol %>% filter(Submuestra == "B — intermedio")

estable_ABC <- (rb$med >= r_A$lo & rb$med <= r_A$hi) & (rb$med >= r_C$lo & rb$med <= r_C$hi)
D_informativa <- !is.na(ancho_D) && !D_impreciso
D_compatible  <- !is.na(ancho_D) && rb$med >= rD$lo && rb$med <= rD$hi

impl_rob <- paste0(
  if (estable_ABC) "La mediana de B cae dentro de los IC de A y de C: la sensibilidad es estable entre criterios de selección. "
  else "La mediana de B cae fuera del IC de A o de C: el resultado depende del criterio de selección. ",
  if (!D_informativa) "D (sin cargue retroactivo) es demasiado imprecisa para concluir sobre la Capa 3 (Tabla 22)."
  else if (D_compatible) "D (sin cargue retroactivo) es compatible con B: no hay señal de sesgo por la Capa 3."
  else "D (sin cargue retroactivo) difiere de B: hay señal de sesgo por la Capa 3."
)

impl_comp <- paste0(
  if (sh_oper > 0.5) "La mayor parte de la respuesta es estructura de costos (inventario, insumos), no miopía. "
  else "La respuesta no es solo costo operativo; el componente no operativo merece análisis propio. ",
  "La HIP propiamente dicha solo puede probarse con el gasto personal",
  if (pct_emp_pers < 0.5) ", que pocas empresas registran." else " (Tabla 17)."
)

impl_vol <- if (vol_B$`% DE gasto > DE ingreso (pesos)` < 50) {
  "La irregularidad del flujo viene más del ingreso que del gasto."
} else {
  "En la mayoría de empresas el gasto varía más que el ingreso incluso en pesos: la irregularidad también viene del lado del gasto."
}

tibble(
  `#` = 1:9,
  Hallazgo = c(
    paste0("PMG mediana por empresa (Submuestra B): ", fmt(rb$med), " ", fmt_ic(rb$lo, rb$hi),
           ". De cada $100 adicionales que entran, salen alrededor de $", round(100 * rb$med), "."),
    paste0("Panel con EF de empresa y quincena (", n_distinct(panel_fe$ID), " empresas): elasticidad ",
           fmt(e_m2), "; PMG agrupada ", fmt(pmg_m4), ". Al controlar por días con registro, la elasticidad pasa a ",
           fmt(e_m3a), " (", fmt_pct(cambio_reg), ")."),
    paste0("Descomposición: de cada $100 adicionales de ingreso, $", round(100 * c_oper),
           " de la respuesta son costos operativos, $", round(100 * c_pers), " gasto personal y $",
           round(100 * c_fin), " ahorro y deuda."),
    paste0("Asimetría: β+ = ", fmt(b_pos), " cuando el ingreso sube, β− = ", fmt(b_neg),
           " cuando cae (", txt_p(p_asim), " para la diferencia)."),
    paste0("Mediana de quincenas en déficit: ", fmt_pct(deficit_med_B), " — más o menos 1 de cada ",
           uno_de_cada, " quincenas."),
    paste0("Con CV, el gasto parece más volátil que el ingreso en el ", fmt(vol_B$`% CV gasto > CV ingreso`, 1),
           "% de las empresas; en pesos, solo en el ", fmt(vol_B$`% DE gasto > DE ingreso (pesos)`, 1),
           "%. La diferencia es el artefacto del CV."),
    paste0("Medianas de β entre submuestras: A = ", fmt(r_A$med), ", B = ", fmt(rb$med), ", C = ",
           fmt(r_C$med), ", D = ", fmt(rD$med), " (n = ", rD$N, ")."),
    paste0("La Submuestra B es el ", fmt_pct(pct_B_univ),
           " de las empresas con transacciones: usuarios muy activos de la app."),
    "Limitación central: duplicados irresolubles, registro voluntario e incompleto, y salidas que mezclan costos del negocio con gasto del hogar."
  ),
  Implicación = c(
    if_else(rb$lo > 0 & rb$hi < 1,
            "Suavización parcial: se rechaza tanto 'no responden' (β = 0) como 'gastan todo lo que entra' (β = 1).",
            "La evidencia no permite distinguir con precisión el grado de suavización."),
    txt_registro %>% str_remove_all("\\*\\*"),
    impl_comp,
    case_when(p_asim < 0.10 & b_neg > b_pos ~ "Compatible con restricciones de liquidez: sin colchón en las malas quincenas.",
              p_asim < 0.10 & b_pos > b_neg ~ "El gasto se sostiene en las malas quincenas: hay algún amortiguador.",
              TRUE ~ "Sin evidencia de asimetría; la prueba tiene poco poder."),
    "Exposición a choques negativos: una fracción no despreciable de quincenas cierra en rojo.",
    impl_vol,
    impl_rob,
    "Los resultados describen a usuarios activos de MDF, no a los micronegocios bogotanos en general.",
    "Publicar como documento de trabajo exploratorio, no como evidencia causal."
  )
) %>%
  kable(caption = "Tabla 23. Síntesis de hallazgos e implicaciones") %>%
  estilo(full_width = TRUE, font_size = 11) %>%
  row_spec(9, italic = TRUE, color = COL_GRIS)
Tabla 23. Síntesis de hallazgos e implicaciones
# Hallazgo Implicación
1 PMG mediana por empresa (Submuestra B): 0,41 [0,30; 0,53]. De cada $100 adicionales que entran, salen alrededor de $41. Suavización parcial: se rechaza tanto ‘no responden’ (β = 0) como ‘gastan todo lo que entra’ (β = 1).
2 Panel con EF de empresa y quincena (144 empresas): elasticidad 0,63; PMG agrupada 0,49. Al controlar por días con registro, la elasticidad pasa a 0,53 (-16,0%). La elasticidad cae de forma apreciable al controlar por días con registro (de 0,63 a 0,53, -16,0%): una parte de la co-variación es intensidad de uso de la app y debe leerse con cautela.
3 Descomposición: de cada $100 adicionales de ingreso, $50 de la respuesta son costos operativos, $10 gasto personal y $-11 ahorro y deuda. La mayor parte de la respuesta es estructura de costos (inventario, insumos), no miopía. La HIP propiamente dicha solo puede probarse con el gasto personal (Tabla 17).
4 Asimetría: β+ = 0,88 cuando el ingreso sube, β− = 0,38 cuando cae (p = 0,005 para la diferencia). El gasto se sostiene en las malas quincenas: hay algún amortiguador.
5 Mediana de quincenas en déficit: 27,3% — más o menos 1 de cada 4 quincenas. Exposición a choques negativos: una fracción no despreciable de quincenas cierra en rojo.
6 Con CV, el gasto parece más volátil que el ingreso en el 60,8% de las empresas; en pesos, solo en el 29,4%. La diferencia es el artefacto del CV. La irregularidad del flujo viene más del ingreso que del gasto.
7 Medianas de β entre submuestras: A = 0,43, B = 0,41, C = 0,36, D = 0,50 (n = 11). La mediana de B cae dentro de los IC de A y de C: la sensibilidad es estable entre criterios de selección. D (sin cargue retroactivo) es demasiado imprecisa para concluir sobre la Capa 3 (Tabla 22).
8 La Submuestra B es el 9,2% de las empresas con transacciones: usuarios muy activos de la app. Los resultados describen a usuarios activos de MDF, no a los micronegocios bogotanos en general.
9 Limitación central: duplicados irresolubles, registro voluntario e incompleto, y salidas que mezclan costos del negocio con gasto del hogar. Publicar como documento de trabajo exploratorio, no como evidencia causal.

Lo que este análisis no permite concluir

  • Que los micronegocios “no suavizan”. La evidencia es de suavización parcial, y una parte de la respuesta del gasto es costo operativo.
  • Que el producto adecuado sea un crédito con cuota fija. Nada en el análisis lleva a esa conclusión. Con una fracción relevante de quincenas en déficit, una cuota fija podría ser justamente el producto más riesgoso. Cualquier recomendación de producto financiero requiere evidencia que esta base no tiene: demanda, mora, uso del crédito.
  • Efectos causales de separar finanzas, del género de la propietaria o del perfil digital. Son asociaciones, en una muestra autoseleccionada de usuarios activos.
  • Resultados por sector fuera de Comercio y Servicios.

Pendientes

  1. Revisar con el equipo la clasificación de categorías de la sección 1.2, en especial “Pago a empleados”, “Comisiones” y “Cuota de préstamo informal” cuando aparece como entrada.
  2. Si ANIF puede entregar una marca de duplicado confiable, re-estimar sin la Capa 3 en lugar de depender de la Submuestra D.
  3. Explorar la prueba de fungibilidad (Tabla 20) con la base completa de empresas que registran gasto personal, si aumenta con nuevas cargas.

4. Exportación de submuestras analíticas

Propósito: generar una base completa por cada submuestra para que los colaboradores puedan hacer sus propios análisis sin reproducir todo el pipeline. Cada base incluye las transacciones limpias (de ig_limpia.csv) + los indicadores calculados aquí (con prefijo ind_) + el perfil de la empresa (de car_limpia.csv, prefijo car_), unidos por ID. Nuevo en v2: también se exporta el panel quincenal con los componentes del gasto.

if (file.exists("car_limpia.csv")) {
  car_limpia <- read_csv("car_limpia.csv", show_col_types = FALSE)
  cat("car_limpia.csv cargado:", nrow(car_limpia), "empresas\n")
} else {
  cat("ADVERTENCIA: car_limpia.csv no encontrado.\n")
  cat("Ejecute limpieza_caracterizacion.Rmd primero.\n")
  cat("Las submuestras se exportarán sin el perfil de la empresa.\n")
  car_limpia <- tibble(ID = character())
}
## car_limpia.csv cargado: 910 empresas
construir_submuestra <- function(ids_submuestra, nombre_sub) {
  transacciones <- ig_clean %>% filter(ID %in% ids_submuestra)

  indicadores_sub <- indicadores_emp %>%
    filter(ID %in% ids_submuestra) %>%
    rename_with(~ paste0("ind_", .), -ID)

  perfil_sub <- if (nrow(car_limpia) > 0) {
    car_limpia %>% filter(ID %in% ids_submuestra) %>% rename_with(~ paste0("car_", .), -ID)
  } else {
    tibble(ID = ids_submuestra)
  }

  transacciones %>%
    left_join(indicadores_sub, by = "ID") %>%
    left_join(perfil_sub,      by = "ID") %>%
    mutate(submuestra = nombre_sub) %>%
    arrange(ID, `Fecha de la transacción`)
}

base_A <- construir_submuestra(muestra_A$ID, "A")
base_B <- construir_submuestra(muestra_B$ID, "B")
base_C <- construir_submuestra(muestra_C$ID, "C")
base_D <- construir_submuestra(muestra_D$ID, "D")

# Panel quincenal con componentes (una fila por empresa-quincena) — NUEVO
panel_export <- panel_q %>%
  mutate(
    bilateral = Y_qt > 0 & G_qt > 0,
    en_A = ID %in% muestra_A$ID, en_B = ID %in% muestra_B$ID,
    en_C = ID %in% muestra_C$ID, en_D = ID %in% muestra_D$ID
  ) %>%
  arrange(ID, q_idx)

tibble(
  Base        = c("A — mínimo", "B — intermedio", "C — estricto", "D — confiabilidad",
                  "Panel quincenal"),
  Criterio    = c("≥3 quincenas bilaterales", "≥5 q + ratio_gi<3", "≥8 q + ratio_gi<2 + CV<3",
                  "≥3 q + 0% retroactivo + sin cargues sin fecha",
                  "Todas las empresa-quincena con registro"),
  Empresas    = c(n_distinct(base_A$ID), n_distinct(base_B$ID), n_distinct(base_C$ID),
                  n_distinct(base_D$ID), n_distinct(panel_export$ID)),
  Filas       = c(nrow(base_A), nrow(base_B), nrow(base_C), nrow(base_D), nrow(panel_export)),
  Columnas    = c(ncol(base_A), ncol(base_B), ncol(base_C), ncol(base_D), ncol(panel_export))
) %>%
  kable(caption = "Tabla 24. Bases exportadas", format.args = FMT_TABLA) %>%
  estilo(full_width = FALSE) %>%
  row_spec(2, bold = TRUE, background = "#EBF5FB")
Tabla 24. Bases exportadas
Base Criterio Empresas Filas Columnas
A — mínimo ≥3 quincenas bilaterales 99 9.377 100
B — intermedio ≥5 q + ratio_gi<3 51 7.087 100
C — estricto ≥8 q + ratio_gi<2 + CV<3 17 3.665 100
D — confiabilidad ≥3 q + 0% retroactivo + sin cargues sin fecha 11 807 100
Panel quincenal Todas las empresa-quincena con registro 557 1.518 20
write_csv(base_A, "submuestra_A.csv")
write_csv(base_B, "submuestra_B.csv")
write_csv(base_C, "submuestra_C.csv")
write_csv(base_D, "submuestra_D.csv")
write_csv(panel_export,    "panel_quincenal_componentes.csv")
write_csv(indicadores_emp, "indicadores_por_empresa.csv")

write_xlsx(list(
  "Submuestra_A" = base_A,
  "Submuestra_B" = base_B,
  "Submuestra_C" = base_C,
  "Submuestra_D" = base_D
), "submuestras_analiticas.xlsx")

write_xlsx(base_A, "submuestra_A.xlsx")
write_xlsx(base_B, "submuestra_B.xlsx")
write_xlsx(base_C, "submuestra_C.xlsx")
write_xlsx(base_D, "submuestra_D.xlsx")

cat("Archivos exportados:\n")
## Archivos exportados:
cat("  submuestra_A/B/C/D.csv y .xlsx — transacciones + indicadores + perfil\n")
##   submuestra_A/B/C/D.csv y .xlsx — transacciones + indicadores + perfil
cat("  submuestras_analiticas.xlsx — las cuatro en un archivo con 4 hojas\n")
##   submuestras_analiticas.xlsx — las cuatro en un archivo con 4 hojas
cat("  panel_quincenal_componentes.csv —", nrow(panel_export), "empresa-quincenas (NUEVO)\n")
##   panel_quincenal_componentes.csv — 1518 empresa-quincenas (NUEVO)
cat("  indicadores_por_empresa.csv —", nrow(indicadores_emp), "empresas (NUEVO)\n")
##   indicadores_por_empresa.csv — 314 empresas (NUEVO)
cat("\nLlave de unión: campo 'ID' (UUID). Para recrear el join: left_join(ig_limpia, car_limpia, by = 'ID')\n")
## 
## Llave de unión: campo 'ID' (UUID). Para recrear el join: left_join(ig_limpia, car_limpia, by = 'ID')

5. Referencias

  • Hall, R. E. (1978). Stochastic implications of the life cycle–permanent income hypothesis: Theory and evidence. Journal of Political Economy, 86(6), 971–987.
  • Morduch, J. (1995). Income smoothing and consumption smoothing. Journal of Economic Perspectives, 9(3), 103–114.
  • Shea, J. (1995). Myopia, liquidity constraints, and aggregate consumption: A simple test. Journal of Money, Credit and Banking, 27(3), 798–805.
  • Townsend, R. M. (1994). Risk and insurance in village India. Econometrica, 62(3), 539–591.
  • Zeldes, S. P. (1989). Consumption and liquidity constraints: An empirical investigation. Journal of Political Economy, 97(2), 305–346.

6. Información de sesión

sessionInfo()
## R version 4.5.2 (2025-10-31 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 10 x64 (build 19045)
## 
## Matrix products: default
##   LAPACK version 3.12.1
## 
## locale:
## [1] LC_COLLATE=Spanish_Colombia.utf8  LC_CTYPE=Spanish_Colombia.utf8   
## [3] LC_MONETARY=Spanish_Colombia.utf8 LC_NUMERIC=C                     
## [5] LC_TIME=Spanish_Colombia.utf8    
## 
## time zone: America/Bogota
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
##  [1] lmtest_0.9-40    zoo_1.8-14       sandwich_3.1-1   fixest_0.13.2   
##  [5] writexl_1.5.4    patchwork_1.3.2  kableExtra_1.4.1 knitr_1.51      
##  [9] broom_1.0.9      ggridges_0.5.7   scales_1.4.0     readxl_1.5.0    
## [13] lubridate_1.9.4  forcats_1.0.0    stringr_1.6.0    dplyr_1.2.1     
## [17] purrr_1.2.2      readr_2.1.5      tidyr_1.3.2      tibble_3.3.1    
## [21] ggplot2_4.0.3    tidyverse_2.0.0 
## 
## loaded via a namespace (and not attached):
##  [1] gtable_0.3.6        xfun_0.52           bslib_0.9.0        
##  [4] lattice_0.22-7      tzdb_0.5.0          numDeriv_2016.8-1.1
##  [7] vctrs_0.7.3         tools_4.5.2         generics_0.1.4     
## [10] parallel_4.5.2      pkgconfig_2.0.3     RColorBrewer_1.1-3 
## [13] S7_0.2.0            stringmagic_1.2.0   lifecycle_1.0.5    
## [16] compiler_4.5.2      farver_2.1.2        textshaping_1.0.1  
## [19] htmltools_0.5.8.1   sass_0.4.10         yaml_2.3.10        
## [22] Formula_1.2-5       crayon_1.5.3        pillar_1.11.0      
## [25] jquerylib_0.1.4     cachem_1.1.0        nlme_3.1-168       
## [28] tidyselect_1.2.1    digest_0.6.37       mvtnorm_1.3-3      
## [31] stringi_1.8.7       labeling_0.4.3      fastmap_1.2.0      
## [34] grid_4.5.2          cli_3.6.5           magrittr_2.0.5     
## [37] withr_3.0.2         dreamerr_1.5.0      backports_1.5.0    
## [40] bit64_4.6.0-1       estimability_1.5.1  timechange_0.3.0   
## [43] rmarkdown_2.29      emmeans_1.11.2-8    bit_4.6.0          
## [46] cellranger_1.1.0    hms_1.1.3           coda_0.19-4.1      
## [49] evaluate_1.0.5      viridisLite_0.4.2   rlang_1.3.0        
## [52] Rcpp_1.1.0          xtable_1.8-4        glue_1.8.0         
## [55] xml2_1.4.0          vroom_1.6.5         svglite_2.2.1      
## [58] rstudioapi_0.17.1   jsonlite_2.0.0      R6_2.6.1           
## [61] systemfonts_1.2.3

González Lozano, L.E. & Granados Rodríguez, H. (2026). ¿Gastan lo que entra? Sensibilidad del gasto al ingreso en micronegocios usuarios de Mi Diario Financiero — SDDE. Documento de trabajo exploratorio, versión 2. Subdirección de Estudios Estratégicos / ODEB.