/** * Cálculo de tendencia sobre una serie mensual. * * Va aparte del módulo de gráficos a propósito: aquí no hay ninguna dependencia * de React ni de Recharts, así que es matemática pura y se puede verificar * ejecutándola directamente contra los datos reales. */ export type Tendencia = { pendiente: number; // cambio por mes intercepto: number; r2: number; // qué parte de la variación explica la recta t: number; // pendiente / error estándar n: number; anual: number; // cambio anualizado cambioTotal: number; // diferencia extremo a extremo según la recta pctTotal: number; direccion: "sube" | "baja" | "estable"; }; /** * Regresión lineal por mínimos cuadrados, igual que la línea de tendencia de * Excel, más el test t de la pendiente. * * El test es lo que permite decir "estable": una recta ajustada a puro ruido * igual sale inclinada, así que sin contrastar la pendiente contra su error * estándar cualquier serie parecería tener dirección. Con |t| < 2 (≈5% de * significancia) la pendiente no se distingue de cero. */ export function regresionLineal(valores: number[]): Tendencia | null { const n = valores.length; if (n < 6) return null; // con menos puntos la recta no dice nada const xs = valores.map((_, i) => i); const mx = xs.reduce((a, b) => a + b, 0) / n; const my = valores.reduce((a, b) => a + b, 0) / n; let sxy = 0, sxx = 0, syy = 0; for (let i = 0; i < n; i++) { sxy += (xs[i] - mx) * (valores[i] - my); sxx += (xs[i] - mx) ** 2; syy += (valores[i] - my) ** 2; } if (sxx === 0) return null; const pendiente = sxy / sxx; const intercepto = my - pendiente * mx; let sse = 0; for (let i = 0; i < n; i++) sse += (valores[i] - (intercepto + pendiente * xs[i])) ** 2; const r2 = syy === 0 ? 0 : Math.max(0, 1 - sse / syy); const errEstandar = n > 2 ? Math.sqrt(sse / (n - 2) / sxx) : Infinity; // Con ajuste perfecto (sse = 0) el error estándar es 0 y el cociente se // indetermina: la pendiente es entonces máximamente significativa, no nula. const t = errEstandar === 0 ? pendiente === 0 ? 0 : Math.sign(pendiente) * Infinity : isFinite(errEstandar) ? pendiente / errEstandar : 0; const cambioTotal = pendiente * (n - 1); return { pendiente, intercepto, r2, t, n, anual: pendiente * 12, cambioTotal, pctTotal: intercepto > 0 ? (cambioTotal / intercepto) * 100 : 0, direccion: Math.abs(t) < 2 ? "estable" : pendiente > 0 ? "sube" : "baja", }; } /** * Media móvil centrada de 12 meses (esquema 2×12, el estándar para series * mensuales: los extremos de la ventana pesan la mitad, de modo que el * resultado cae exactamente sobre un mes en lugar de entre dos). * * Elimina la estacionalidad y deja ver la forma de la tendencia — dónde * aceleró, dónde hizo pico, dónde se dio vuelta. Una recta no muestra nada * de eso. */ export function mediaMovilCentrada(valores: number[], ventana = 12): (number | null)[] { const n = valores.length; const out: (number | null)[] = new Array(n).fill(null); if (n < ventana + 1) return out; const mitad = ventana / 2; for (let i = mitad; i < n - mitad; i++) { let suma = valores[i - mitad] / 2 + valores[i + mitad] / 2; for (let k = i - mitad + 1; k < i + mitad; k++) suma += valores[k]; out[i] = suma / ventana; } return out; } /** * Media móvil centrada con los extremos completados. * * Una media centrada de 12 meses no existe en los 6 primeros ni en los 6 * últimos meses: le faltan datos a un lado. Perder la cola final es justo lo * peor, porque son los meses que más interesan. * * Los extremos se calculan con la ventana recortada a los datos disponibles * (es lo que hacen los paquetes de ajuste estacional como X-13ARIMA-SEATS, * que usan ventanas asimétricas en los bordes). Esos puntos son provisionales: * al llegar meses nuevos se recalculan y se mueven. Por eso se devuelven * marcados, para dibujarlos distinto y no leerlos como definitivos. */ export function mediaMovilConExtremos( valores: number[], ventana = 12 ): { valor: number | null; provisional: boolean }[] { const n = valores.length; const mitad = ventana / 2; const centrada = mediaMovilCentrada(valores, ventana); return valores.map((_, i) => { if (centrada[i] != null) return { valor: centrada[i], provisional: false }; if (n < ventana) return { valor: null, provisional: false }; // Ventana recortada a lo que hay: la mitad completa más lo disponible del // otro lado. Con menos de la mitad de la ventana no se dibuja nada. const desde = Math.max(0, i - mitad); const hasta = Math.min(n - 1, i + mitad); if (hasta - desde + 1 < mitad) return { valor: null, provisional: false }; let suma = 0; for (let k = desde; k <= hasta; k++) suma += valores[k]; return { valor: suma / (hasta - desde + 1), provisional: true }; }); } /** Veredicto legible a partir de la regresión. */ export function leerTendencia(t: Tendencia | null) { if (!t) return null; if (t.direccion === "estable") { return { icono: "▬", clase: "tend-estable", titulo: "Estable", detalle: `sin tendencia estadísticamente significativa en estos ${t.n} meses ` + `(t = ${t.t.toFixed(1)}; hace falta |t| > 2)`, }; } const fuerza = t.r2 >= 0.5 ? "marcada" : t.r2 >= 0.25 ? "moderada" : "débil"; const signo = t.anual >= 0 ? "+" : ""; return { icono: t.direccion === "sube" ? "▲" : "▼", clase: t.direccion === "sube" ? "tend-sube" : "tend-baja", titulo: t.direccion === "sube" ? "Al alza" : "A la baja", detalle: `${signo}${t.anual.toFixed(0)} por año (${signo}${t.pendiente.toFixed(2)}/mes) · ` + `R² ${t.r2.toFixed(2)}, señal ${fuerza}`, }; }