Tienes el modelo estrella del panel cargado en Power BI, las cuatro medidas financieras base calzando con SQL, y la historia del semestre visible en tres visuales.
El notebook de esta clase: https://gaa1104.vercel.app/notebooks/clase-19.ipynb
Ing. en Administración · mención Finanzas · Prof. Tomás Fernández
Margen por sector es magnitud comparada; su evolución es tiempo. Dos gráficos, un eje cada uno.
La forma la decide el trabajo que hace el dato —magnitud, identidad, polaridad, cambio en el tiempo, distribución—, no el gusto. Regla del curso: un solo eje.
Lo que quedó planteado ayer (se proyecta la pregunta, NO la respuesta):
«La página se ve bien. ¿De dónde sale cada número? ¿Hay un modelo detrás o son 1.109 filas planas?»
Tres preguntas, en silencio, 90 segundos (espaciado 1–3–7–21):
GROUP BY sector y cuántos sectores hay de verdad?Par (2 min) → plenaria (3 min).
Se responde la pregunta colgada CON ellos. La respuesta se construye y se escribe:
Hoy son 1.109 filas planas. Y por eso la página de ayer no se puede auditar: si el CFO pregunta de dónde sale el 34,5 %, la respuesta es «de un gráfico». Un modelo convierte esa respuesta en una cadena: la medida sale del hecho, el hecho se une a la dimensión por una llave, y la llave es el RUT. Eso es lo que se construye hoy.
Preguntas que quedan planteadas hoy — se escriben ahora en la pizarra y no se responden hoy: se retoman mañana en la apertura.
El grano se declara por escrito antes de la primera consulta. En la pizarra, con esas palabras:
Una fila de fact_eeff es una empresa en un trimestre. 40 empresas × 28 trimestres = 1.120 posibles; hay 1.109.
Las dimensiones se generan en DuckDB y se exportan. Power BI hereda el modelo, no lo inventa — y esa frase se dice en voz alta porque es la diferencia entre un analista y un usuario de herramienta.
Vienen resueltas: se corren y se lee la salida.
-- 👀 SE MIRA · la vista que limpia texto y abre el trimestre en año y Q CREATE OR REPLACE VIEW eeff AS SELECT periodo, trimestre, CAST(left(trimestre, 4) AS INTEGER) AS anio, right(trimestre, 2) AS q, replace(rut, '.', '') AS rut_n, trim(regexp_replace(empresa, ' +', ' ', 'g')) AS empresa_n, upper(strip_accents(trim(sector))) AS sector_n, * EXCLUDE (periodo, trimestre, rut, empresa, sector) FROM read_csv_auto('datos/finanzas/eeff_empresas.csv');
Y el maestro, que es el que trae los nombres limpios:
-- 👀 SE MIRA · el maestro, que es el que trae los nombres limpios CREATE OR REPLACE VIEW catalogo AS SELECT u.rut, u.razon_social, u.sector, u.reporta_en FROM (SELECT UNNEST(empresas) AS u FROM read_json_auto('datos/finanzas/catalogo_empresas.json', maximum_depth = -1));
El momento de la sesión, antes de exportar nada. Se corre esto y se deja en pantalla:
-- 👀 SE MIRA · el momento de la sesión: 200 formas, 137 tras limpiar, 40 empresas SELECT COUNT(DISTINCT empresa) AS formas_en_bruto, COUNT(DISTINCT trim(regexp_replace(empresa,' +',' ','g'))) AS formas_tras_limpiar, COUNT(DISTINCT replace(rut, '.', '')) AS empresas_reales FROM read_csv_auto('datos/finanzas/eeff_empresas.csv'); -- 200 | 137 | 40
200 formas de escritura. Limpias los espacios como buen alumno, y te quedan 137. Empresas hay 40.
«Normalizar texto hasta que se vea bien es lo que hace casi todo el mundo, y deja 137 empresas donde hay 40. El punto final deS.A..no lo borra ningúntrim. Se agrupa por RUT y el nombre bonito se saca del catálogo. Esa frase la van a repetir el resto de su carrera.»
Las cuatro tablas, exportadas con las sentencias que ya corrieron:
COPY (SELECT replace(c.rut, '.', '') AS rut_n, c.rut, c.razon_social, c.sector, c.reporta_en FROM catalogo c ORDER BY 1) TO 'salidas/dim_empresa.csv' (HEADER); -- 40 filas COPY (SELECT DISTINCT trimestre, CAST(left(trimestre, 4) AS INTEGER) AS anio, right(trimestre, 2) AS q, CAST(periodo AS DATE) AS fecha_cierre FROM eeff ORDER BY fecha_cierre) TO 'salidas/dim_tiempo.csv' (HEADER); -- 28 filas
Y la de sector, que sale del maestro y no del CSV:
COPY (SELECT DISTINCT sector AS sector_nombre, upper(strip_accents(trim(sector))) AS sector_n FROM catalogo ORDER BY 2) TO 'salidas/dim_sector.csv' (HEADER); -- 6 filas
Y el hecho, con las 16 columnas que se van al modelo:
COPY (SELECT rut_n, trimestre, ingresos_mm_clp, costo_venta_mm_clp, gastos_operacionales_mm_clp, ebitda_mm_clp, depreciacion_mm_clp, resultado_operacional_mm_clp, gastos_financieros_mm_clp, utilidad_neta_mm_clp, activos_totales_mm_clp, activos_corrientes_mm_clp, pasivos_corrientes_mm_clp, deuda_financiera_mm_clp, patrimonio_mm_clp, empleados FROM eeff) TO 'salidas/fact_eeff.csv' (HEADER); -- 1.109 filas
Ojo con la dim_sector: sale de catalogo, no del CSV. Del CSV salían 25 filas (lo de ayer). Del catálogo salen 6. La dimensión limpia vive en el maestro; el hecho trae la suciedad. Eso es la regla del curso, no una casualidad de este archivo.
Por qué la clase se dicta en Power BI si acá la herramienta ya es libre. Se dice en una frase, sin abrir debate: «se dicta en Power BI porque ustedes acaban de armar un modelo ahí y hoy lo que se aprende es a heredarlo, no a aprender una herramienta nueva. Pero desde este bloque el instrumento dice «Power BI (o herramienta de BI equivalente)»: el que quiera hacer este mismo panel en Looker Studio puede, y el puente esbd.a_sheets()desde Colab. Las cuatro medidas, la verificación cruzada y la página son idénticas — cambia dónde se aprietan los botones, no lo que se evalúa.» Regla escrita enrecursos/herramienta-de-bi.md; el camino completo, enrecursos/guia-looker-estudiante.md.
Entregas: las cuatro tablas cargadas con relaciones dim → fact en 1:*, dim_tiempo marcada como tabla de fechas, y la verificación de integridad corrida con su número anotado: 1.109 · 1.109 · 0 huérfanas. Dentro de los 28 minutos de este bloque.
En Power BI: cargar las cuatro tablas, relaciones dim → fact en 1:*, dim_tiempo marcada como tabla de fechas. Verificación en la vista de modelo: tiene que verse una estrella con el hecho al centro. Si Power BI dibujó una relación muchos-a-muchos, la dimensión está mal construida y se rehace — no se le pone «ambos» a la dirección del filtro para tapar el problema.
La verificación de integridad, obligatoria y anotada:
-- 🔨 ACTIVIDAD · córrela y anota el resultado: tiene que dar cero huérfanas SELECT COUNT(*) AS filas_del_hecho, COUNT(c.rut) AS filas_con_maestro, COUNT(*) - COUNT(c.rut) AS huerfanas FROM eeff e LEFT JOIN catalogo c ON replace(c.rut, '.', '') = e.rut_n; -- 1109 | 1109 | 0
Cero huérfanas. Y se dice por qué salió cero: porque la unión va por RUT normalizado. Si fuera por nombre, con 200 formas contra 40 razones sociales, la mitad del panel se caía en silencio.
No son cuatro fórmulas: son las cuatro preguntas que un directorio hace siempre, en este orden.
| Medida | La pregunta que responde | Cuidado |
|---|---|---|
| Margen EBITDA = EBITDA / ingresos | ¿El negocio operativo gana plata? | Sólo comparable dentro del mismo sector |
ROE = utilidad neta 12 m / patrimonio promedio de los 4 trimestres (suma ÷ 4, no AVERAGE) | ¿Le rinde al dueño? | Cae por operación o por deuda: hay que separar |
| Deuda / EBITDA | ¿Cuántos años de EBITDA debe? | Tiene numerador y denominador; mañana la clase entera |
| Liquidez corriente = act. corrientes / pas. corrientes | ¿Paga lo de este año con lo de este año? | Bajo no es malo: Utilities vive en 1,18 y está sana |
Pararse, estirar, subir la luz un segundo. Clase nocturna: el cuerpo también aprende. Se reanuda 21:20.
Entregas: las cuatro medidas escritas en tu herramienta y el margen EBITDA calzando contra SQL en al menos dos sectores, celda por celda. 30 minutos.
Se escriben en Power BI, una por una, proyectadas y copiadas:
Ingresos = SUM(fact_eeff[ingresos_mm_clp]) EBITDA = SUM(fact_eeff[ebitda_mm_clp]) Margen EBITDA % = DIVIDE([EBITDA], [Ingresos])
Y sobre ésas, los tres ratios que pide el caso:
Patrimonio promedio = DIVIDE(SUM(fact_eeff[patrimonio_mm_clp]), 4)
ROE % = DIVIDE(SUM(fact_eeff[utilidad_neta_mm_clp]), [Patrimonio promedio])
Deuda cierre = CALCULATE(SUM(fact_eeff[deuda_financiera_mm_clp]),
dim_tiempo[q] = "Q4")
Deuda / EBITDA = DIVIDE([Deuda cierre], [EBITDA])
Liquidez = DIVIDE(SUM(fact_eeff[activos_corrientes_mm_clp]),
SUM(fact_eeff[pasivos_corrientes_mm_clp]))
CALCULATE se presenta en una frase y con un solo uso: es el WHERE de DAX. Nada más hoy.
Verificación cruzada en vivo — el hábito de siempre, ahora en la herramienta. Se pone la matriz de margen EBITDA por sector y año en Power BI y se compara contra SQL, celda por celda, en dos sectores:
-- 🔨 ACTIVIDAD · ésta es la vara: tu matriz de Power BI tiene que dar esto mismo SELECT sector_n, anio, ROUND(100.0 * SUM(ebitda_mm_clp) / SUM(ingresos_mm_clp), 1) AS margen_ebitda_pct FROM eeff WHERE rut_n <> '76660573-7' GROUP BY 1, 2 ORDER BY 1, 2; -- UTILITIES 2019 35.8 · 2021 34.7 · 2023 34.5 · 2025 35.3 -- RETAIL 2019 9.2 · 2021 8.1 · 2023 7.5 · 2025 8.0
Si Power BI no da lo mismo, hay exactamente tres sospechosos y se revisan en este orden: 1 · el filtro de la empresa que reporta en dólares no está puesto en la medida; 2 · la relación quedó en dirección cruzada; 3 · alguien sumó porcentajes en vez de dividir sumas. El tercero es el más frecuente y el más caro: el promedio de los márgenes no es el margen del promedio.
Y hay un cuarto, que sólo muerde en el ROE y por eso se dice ahora: AVERAGE en DAX divide por el número de filas del contexto, no por 4. En la tarjeta de una empresa hay 4 filas al año y da lo correcto; en la matriz por sector hay 4 trimestres × N empresas, el denominador queda N veces más chico y el ROE sale multiplicado por N. Utilities tiene 7 empresas: con AVERAGE, 2021 daría 64,2 % en vez de 9,2 % — siete veces el número, y en una lámina que nadie va a revisar. Por eso el patrimonio promedio se escribe como medida propia, suma ÷ 4, y el ROE la usa a ella. La verificación cruzada contra SQL es la que lo caza:
SELECT anio, ROUND(100.0 * SUM(utilidad_neta_mm_clp) / (SUM(patrimonio_mm_clp) / 4.0), 1) AS roe_pct FROM eeff WHERE sector_n = 'UTILITIES' GROUP BY 1 ORDER BY 1; -- 2019 9.2 · 2021 9.2 · 2023 5.3 · 2025 7.7
La duda concreta del dato de hoy: al construir dim_tiempo con 28 trimestres, tres empresas no tienen las 28 filas.
WITH todos AS (SELECT DISTINCT trimestre FROM eeff), emp AS (SELECT rut_n, any_value(empresa_n) AS empresa FROM eeff GROUP BY 1) SELECT emp.empresa, COUNT(*) AS trimestres_ausentes, list(t.trimestre ORDER BY t.trimestre) AS cuales FROM emp CROSS JOIN todos t LEFT JOIN eeff x ON x.rut_n = emp.rut_n AND x.trimestre = t.trimestre WHERE x.trimestre IS NULL GROUP BY 1 ORDER BY 2 DESC; -- Caja Crédito Pacífico S.A. | 4 | [2024-Q3, 2024-Q4, 2025-Q1, 2025-Q2] -- Clínica Puerto Varas SpA | 4 | [2019-Q1, 2019-Q2, 2019-Q3, 2019-Q4] -- Forestal Chiloé SpA. | 3 | [2021-Q2, 2021-Q3, 2023-Q1]
A quién se le pregunta: Relación con Inversionistas (o Fiscalía, si es una empresa que dejó de reportar). La pregunta textual:
«¿Falta ese trimestre, o la empresa todavía no existía / dejó de reportar?»
Y por qué cambia todo: los tres casos se ven idénticos en el CSV y son tres cosas distintas.
Una serie de tiempo con hoyos miente con gráfico bonito: la línea los une y el ojo ve continuidad donde no hay dato. En la página, los trimestres ausentes se marcan; no se interpolan.
Es el mismo diagnóstico de la sesión 07 con las 639 líneas de Austral sin fecha de pago: la misma celda vacía, tres diagnósticos opuestos. Por eso se pregunta antes de imputar.
Entregas: los tres visuales armados en tu página 1, cada uno con un solo eje, sus unidades escritas y la frase que lo lee. 20 minutos.
Cada uno la arma en su página 1. Son tres frases y sus tres evidencias. Los gráficos de abajo vienen resueltos: se corren para ver la forma que hay que reproducir en la herramienta.
1 · «Los ingresos volvieron después de 2020.»
SELECT anio, ROUND(SUM(ingresos_mm_clp) / 1e6, 2) AS ingresos_billones_clp FROM eeff WHERE rut_n <> '76660573-7' GROUP BY 1 ORDER BY 1; -- 2019 16.60 · 2020 16.49 · 2021 19.81 · 2022 23.03 · 2023 23.39 · 2024 24.33 · 2025 26.16
2 · «Y aun así, el margen EBITDA no recuperó el nivel de 2019 en ningún sector.» Pequeños múltiplos, seis paneles, escala común. Utilities 35,8 → 34,5 en 2023; Retail 9,2 → 7,5; Salud 15,5 → 13,7. Volvió la venta, no volvió el margen: el costo subió más rápido que el precio.
Antes de armarla en Power BI, se dibuja acá. Es la misma consulta de las 21:20, ahora con la forma puesta: seis sectores comparados en una sola medida es magnitud comparada, y eso son barras. Horizontales, porque los nombres de sector son largos, y ordenadas por valor.
# 👀 SE MIRA · el gráfico ya viene escrito: se corre y se mira la forma import matplotlib.pyplot as plt GRIS, TINTA, ALERTA = "#b8b8b8", "#102a38", "#8c2b20" m = bd.sql(""" SELECT sector_n, ROUND(100.0 * SUM(ebitda_mm_clp) FILTER (WHERE anio = 2019) / SUM(ingresos_mm_clp) FILTER (WHERE anio = 2019), 1) AS m2019, ROUND(100.0 * SUM(ebitda_mm_clp) FILTER (WHERE anio = 2023) / SUM(ingresos_mm_clp) FILTER (WHERE anio = 2023), 1) AS m2023 FROM eeff WHERE rut_n <> '76660573-7' GROUP BY 1 ORDER BY m2023""") fig, eje = plt.subplots(figsize=(9.5, 5)) y = range(len(m)) eje.barh([v + 0.20 for v in y], m.m2019, height=0.38, color=GRIS, label="2019") eje.barh([v - 0.20 for v in y], m.m2023, height=0.38, color=ALERTA, label="2023") eje.set_yticks(list(y)) eje.set_yticklabels(m.sector_n, fontsize=10) for i, (a, b) in enumerate(zip(m.m2019, m.m2023)): eje.text(a + 0.4, i + 0.20, f"{a:.1f}".replace(".", ","), va="center", fontsize=9, color="#767676") eje.text(b + 0.4, i - 0.20, f"{b:.1f}".replace(".", ","), va="center", fontsize=9, color=ALERTA) eje.set_title("Volvió la venta, no volvió el margen: los seis sectores cierran 2023\n" "bajo su margen EBITDA de 2019", fontsize=13, color=TINTA, loc="left") eje.set_xlabel("margen EBITDA (EBITDA ÷ ingresos, en %)", fontsize=9) eje.set_xlim(0, 40) eje.legend(frameon=False, fontsize=9, loc="lower right") for lado in ("top", "right", "left"): eje.spines[lado].set_visible(False) eje.tick_params(length=0) fig.tight_layout() plt.show()
Qué se mira: las seis parejas apuntan para el mismo lado, y eso es el hallazgo — no el nivel de cada sector. Utilities baja de 35,8 a 34,5 y Retail de 9,2 a 7,5: distancias parecidas en puntos, negocios incomparables entre sí. Por eso el margen EBITDA sólo se compara dentro del mismo sector, y por eso este gráfico no ordena a los sectores para premiar a nadie.
3 · «Los gastos financieros se triplican entre 2021 y 2023 sin que la deuda se triplique.»
SELECT anio, SUM(gastos_financieros_mm_clp) AS gastos_financieros_mm, SUM(deuda_financiera_mm_clp) FILTER (WHERE q = 'Q4') AS deuda_cierre_mm, ROUND(100.0 * 4 * SUM(gastos_financieros_mm_clp) / SUM(deuda_financiera_mm_clp), 2) AS tasa_implicita_pct FROM eeff GROUP BY 1 ORDER BY 1; -- 2021 | 520.955 | 12.089.286 | 4,32 % -- 2023 | 1.651.271 | 14.565.551 | 11,46 %
El gasto financiero se multiplica por 3,17 y la deuda sube 20,5 %. No se endeudaron más: les subieron el precio del dinero. Esa distinción, hecha con dos columnas, es la diferencia entre un informe que describe y uno que explica.
Y acá está la trampa que esta clase tiene que no caer. Hay dos series —gastos financieros y deuda— y la tentación es ponerlas juntas con dos ejes, uno a cada lado. No se hace, nunca, y ésta es la clase donde se demuestra por qué: con dos ejes uno elige la escala hasta que las líneas cuenten lo que uno quiera. La salida honesta es indexar las dos a una base común y dejar un solo eje. La base la elige el análisis: 2021, que es el año desde el que se hace la afirmación.
# 👀 SE MIRA · dos magnitudes distintas en un solo eje: la salida honesta import matplotlib.pyplot as plt GRIS, TINTA, ALERTA = "#b8b8b8", "#102a38", "#8c2b20" s = bd.sql(""" SELECT anio, SUM(gastos_financieros_mm_clp) AS gastos_financieros, SUM(deuda_financiera_mm_clp) FILTER (WHERE q = 'Q4') AS deuda_cierre FROM eeff GROUP BY 1 ORDER BY 1""") base = s[s.anio == 2021].iloc[0] gf = 100 * s.gastos_financieros / base.gastos_financieros dc = 100 * s.deuda_cierre / base.deuda_cierre fig, eje = plt.subplots(figsize=(9.5, 4.6)) eje.plot(s.anio, dc, color=GRIS, linewidth=2.2, marker="o", label="deuda financiera de cierre") eje.plot(s.anio, gf, color=ALERTA, linewidth=2.4, marker="o", label="gastos financieros del año") eje.axhline(100, color=TINTA, linewidth=0.8, linestyle="--") eje.text(2019, 104, "base 2021 = 100", fontsize=9, color=TINTA) i2023 = list(s.anio).index(2023) eje.annotate(f"{gf.iloc[i2023]:.0f}", xy=(2023, gf.iloc[i2023]), xytext=(6, -2), textcoords="offset points", fontsize=11, color=ALERTA) eje.annotate(f"{dc.iloc[i2023]:.0f}", xy=(2023, dc.iloc[i2023]), xytext=(6, -2), textcoords="offset points", fontsize=11, color="#767676") eje.set_title("No se endeudaron más: les subieron el precio del dinero.\n" "Entre 2021 y 2023 el gasto financiero se multiplica por 3,17 y la deuda sube 20,5 %", fontsize=13, color=TINTA, loc="left") eje.set_ylabel("índice, 2021 = 100", fontsize=9) eje.set_xticks(list(s.anio)) eje.legend(frameon=False, fontsize=9, loc="upper left") for lado in ("top", "right"): eje.spines[lado].set_visible(False) fig.tight_layout() plt.show()
Qué se mira en el resultado:
Y si sobran dos minutos, se comprueba en vez de afirmarse. Hasta acá la tasa es una deducción: el gasto creció mucho más que la deuda, luego subió el precio del dinero. Es correcto, y además se puede verificar — el Banco Central publica la serie.
SELECT year, ROUND(cbrate, 2) AS tpm_promedio_del_anio, ROUND(infl, 1) AS inflacion, ROUND(govdebt_GDP, 1) AS deuda_publica_pct_pib FROM macro WHERE ISO3 = 'CHL' AND year BETWEEN 2019 AND 2024 ORDER BY year; -- 2020: TPM 0,50 · 2022: TPM 11,25 · 2024: TPM 5,00
«La tasa de política monetaria pasó de 0,50 % en 2020 a 11,25 % en 2022. Veintidós veces. Las
empresas del panel no se endeudaron veintidós veces más: pagaron veintidós veces más caro lo mismo.
Eso es lo que dice el visual 3, y recién ahora lo dice con el dato al lado.»
Lo que hay que decir, porque es la lección del bloque completo: el panel de la empresa no explica el panel de la empresa. Un analista que sólo mira el estado de resultados concluye mala gestión financiera y recomienda cambiar al tesorero. El que mira el país al lado concluye ciclo de tasas y recomienda otra cosa. Los dos leyeron el mismo número.
Fuente: Global Macro Data (globalmacrodata.com), serie cbrate para Chile. La tabla macro la carga bd.macro(); en la consola del profesor viene con cargar.sql.
Construida con ellos:
Ticket T29 (3 min, adaptado al panel): a) ¿Qué hace CALCULATE? Una frase. b) Tu margen EBITDA de Utilities y de Retail en 2023: los números, de memoria. c) Del 1 al 5, dónde te sientes y qué te falta.
Preguntas que quedan planteadas (se escriben en la pizarra y no se responden hoy):
→ Utilities pierde 4 puntos de ROE entre 2021 y 2023 (9,2 % → 5,3 %) con el margen EBITDA prácticamente intacto (34,7 % → 34,5 %). ¿Empeoró el negocio?
→ Si un ratio empeora, ¿cómo sabes si fue el numerador o el denominador?
Tres minutos. No suma nota. Sirve para armar la apertura de la próxima clase.
El notebook de esta clase:
https://gaa1104.vercel.app/notebooks/clase-19.ipynb