"""forecast_seasonal_median — forecast diario por mediana estacional + tendencia. Funcion PURA (sin I/O, sin datetime.now(), determinista). Predice el valor futuro de una o varias series temporales diarias combinando dos senales: 1. Base estacional: la mediana del valor en las ultimas `dow_weeks` fechas con el MISMO dia de semana que la fecha objetivo (dias ausentes = 0, para series intermitentes donde "sin fila" significa "sin venta"). 2. Factor de tendencia por serie: cuanto ha crecido/caido la actividad reciente respecto al periodo inmediatamente anterior (razon de sumas), acotado a un rango para no amplificar ruido. Disenada para el forecast de ventas diarias de Aurgi (dia x centro x subcategoria CGQ): cada serie es un par centro|subcategoria y el patron semanal domina la demanda (los sabados venden distinto que los martes). Solo usa stdlib (datetime, statistics). """ from datetime import date, datetime, timedelta from statistics import median def _to_date(value: str) -> date: """Convierte una fecha ISO 'YYYY-MM-DD' (o datetime.date) a datetime.date.""" if isinstance(value, date) and not isinstance(value, datetime): return value if isinstance(value, datetime): return value.date() return datetime.strptime(value[:10], "%Y-%m-%d").date() def forecast_seasonal_median( history: list[dict], horizon_dates: list[str], as_of: str, dow_weeks: int = 8, trend_recent_weeks: int = 4, trend_clip: tuple = (0.5, 2.0), ) -> list[dict]: """Predice el valor de cada serie para cada fecha del horizonte. Para cada serie presente en `history` y cada fecha objetivo del horizonte: 1. Base estacional = mediana del valor en las ultimas `dow_weeks` fechas con el MISMO dia de semana que la fecha objetivo, todas <= `as_of`. Se toman las fechas EXACTAS del calendario (la mas reciente <= as_of con ese dia de semana, y de ahi 7 dias hacia atras por punto); una fecha ausente en la historia cuenta como 0 (series intermitentes). 2. Factor de tendencia por serie = suma de los valores de las ultimas `trend_recent_weeks` semanas (desde `as_of` hacia atras) dividida entre la suma de las `trend_recent_weeks` semanas anteriores a esas. Si el denominador es 0 el factor es 1.0. Se acota a `trend_clip`. 3. y_pred = max(0.0, base * factor). Args: history: observaciones {"series_id": str, "date": "YYYY-MM-DD", "value": float}. Filas duplicadas (misma serie y fecha) se suman. Los dias sin fila dentro de las ventanas se tratan como valor 0. horizon_dates: fechas futuras a predecir (strings ISO 'YYYY-MM-DD'). as_of: fecha de corte (ultimo dia de historia utilizable, inclusive). dow_weeks: numero de fechas del mismo dia de semana a promediar para la base estacional. Default 8. trend_recent_weeks: tamano (en semanas) de cada una de las dos ventanas de tendencia (reciente y anterior). Default 4. trend_clip: (min, max) al que se acota el factor de tendencia. Default (0.5, 2.0): la prediccion no puede menos que caer a la mitad ni mas que duplicarse por tendencia. Returns: Lista de {"series_id": str, "date": str, "y_pred": float}, una fila por cada serie presente en `history` y cada fecha del horizonte. Ordenada por series_id (asc) y luego por el orden de `horizon_dates`. """ as_of_d = _to_date(as_of) lo_clip, hi_clip = trend_clip # Mapa (series_id, date) -> valor acumulado + conjunto de series presentes. values: dict[tuple[str, date], float] = {} series_ids: set[str] = set() for obs in history: sid = obs["series_id"] d = _to_date(obs["date"]) v = float(obs.get("value", 0.0) or 0.0) series_ids.add(sid) values[(sid, d)] = values.get((sid, d), 0.0) + v # Ventanas de tendencia (en dias) relativas a as_of. span = 7 * trend_recent_weeks recent_lo = as_of_d - timedelta(days=span) # reciente: recent_lo < d <= as_of prior_lo = as_of_d - timedelta(days=2 * span) # anterior: prior_lo < d <= recent_lo # Factor de tendencia por serie (una sola vez por serie, no depende del horizonte). trend_factor: dict[str, float] = {} for sid in series_ids: recent_sum = 0.0 prior_sum = 0.0 for (s, d), v in values.items(): if s != sid: continue if recent_lo < d <= as_of_d: recent_sum += v elif prior_lo < d <= recent_lo: prior_sum += v if prior_sum == 0.0: factor = 1.0 else: factor = recent_sum / prior_sum trend_factor[sid] = min(hi_clip, max(lo_clip, factor)) horizon = [_to_date(h) for h in horizon_dates] out: list[dict] = [] for sid in sorted(series_ids): factor = trend_factor[sid] for h_str, h_d in zip(horizon_dates, horizon): # Fecha mas reciente <= as_of con el mismo dia de semana que la objetivo. back = (as_of_d.weekday() - h_d.weekday()) % 7 anchor = as_of_d - timedelta(days=back) dow_values = [ values.get((sid, anchor - timedelta(days=7 * i)), 0.0) for i in range(dow_weeks) ] base = median(dow_values) y_pred = max(0.0, base * factor) out.append({"series_id": sid, "date": h_str, "y_pred": y_pred}) return out