[CONSEJO — Doc 09] Contrarian: El error más frecuente en sistemas de forecast de cash flow es asumir que los flujos futuros son independientes entre sí. No lo son: si el mayor cliente de la empresa paga tarde habitualmente, todos los meses de ese cliente tienen correlación. El motor debe capturar esa correlación cliente-por-cliente (patrón de pago histórico individual), no solo promedios agregados. First Principles: El cash flow de una PyME tiene tres componentes: (1) flujos determinísticos (alquiler fijo, planilla fija — no hay incertidumbre), (2) flujos semi-determinísticos (facturas pendientes cuyo monto es conocido pero la fecha de cobro es incierta), (3) flujos estocásticos (ingresos futuros de ventas aún no realizadas). El motor debe tratar cada componente con su método apropiado, no con un único modelo genérico. Executor: El forecast de tesorería tiene que ser explicable. "Tu P10 es $1,200 porque el modelo estima que Distribuidora Ramírez pagará 12 días tarde su factura de $8,500 en el 10% de los escenarios" es una explicación válida. "El modelo dice $1,200" no lo es. La explicabilidad es un requerimiento funcional, no un nice-to-have.
1. Arquitectura del motor de tesorería
El motor de tesorería de BookMind es un simulador de Monte Carlo condicionado por datos históricos de la empresa, enriquecido con señales de los módulos hermanos.
ENTRADAS:
├── bk_movimientos_bancarios (historial conciliado)
├── bk_fe_documents (AR pendiente + AP esperado)
├── bk_asientos (flujos históricos por cuenta)
├── sl_sales (ventas POS de SalesMind, si activo)
├── sm_purchase_orders (OC aprobadas de StockMind, si activo)
└── Parámetros configurables (días de horizonte, umbrales de alerta)
MOTOR (treasury_worker):
├── Módulo 1: Clasificación de flujos (determinístico / semi / estocástico)
├── Módulo 2: Patrones de pago por cliente (AR scoring)
├── Módulo 3: Simulación Monte Carlo (1,000 escenarios × 90 días)
├── Módulo 4: Percentiles P10/P50/P90
├── Módulo 5: Detección de alertas
└── Módulo 6: Generación de explicaciones (Jinja2)
SALIDAS:
├── bk_forecast_results (series JSON + alertas JSON)
└── Alertas push → tenant dashboard
2. Módulo 1 — Clasificación de flujos
2.1 Flujos determinísticos
Flujos con monto y fecha conocidos con certeza (varianza ≈ 0):
- Planilla fija mensual (si BookMind tiene el dato de PayMind)
- Alquiler fijo mensual (detectado como gasto recurrente en cuenta GL específica)
- Préstamos bancarios con cuotas fijas (detectados por patrón en movimientos bancarios)
def clasificar_flujos_deterministicos(historial: list[Movimiento]) -> list[FlujoDeter]:
candidatos = []
for mov in historial:
# Detectar si aparece el mismo monto ±2% en el mismo período (día ±3)
recurrencia = detectar_recurrencia(mov, historial, tolerancia_monto=0.02, tolerancia_dia=3)
if recurrencia.frecuencia >= 3 and recurrencia.coeficiente_variacion < 0.05:
candidatos.append(FlujoDeter(
descripcion=mov.descripcion,
monto_esperado=recurrencia.monto_promedio,
dia_del_mes=recurrencia.dia_modal,
confianza='alta'
))
return candidatos
2.2 Flujos semi-determinísticos
Facturas emitidas pendientes de cobro (monto conocido, fecha incierta):
@dataclass
class PatronPagoCliente:
cliente_cedula: str
dso_promedio: float # Days Sales Outstanding histórico
dso_std: float # Desviación estándar del DSO
tasa_incobrable: float # % de facturas históricamente no cobradas
def calcular_patron_pago(cliente_cedula: str, historial_fe: list) -> PatronPagoCliente:
pagos = [fe for fe in historial_fe if fe.receptor_cedula == cliente_cedula and fe.cobrada]
if len(pagos) < 3:
return PatronPagoCliente(dso_promedio=45, dso_std=15, tasa_incobrable=0.05) # benchmark
dsos = [(fe.fecha_cobro - fe.fecha_emision).days for fe in pagos]
return PatronPagoCliente(
cliente_cedula=cliente_cedula,
dso_promedio=np.mean(dsos),
dso_std=np.std(dsos),
tasa_incobrable=1 - len(pagos) / len([fe for fe in historial_fe if fe.receptor_cedula == cliente_cedula])
)
2.3 Flujos estocásticos
Ventas futuras aún no realizadas, proyectadas desde el historial bancario y de SalesMind:
- Se ajusta una distribución empírica a los ingresos históricos por día de la semana y semana del mes
- Se usa para generar escenarios de ingresos futuros no determinados
3. Módulo 2 — Scoring de cobranza AR
El scoring de cobranza es un output secundario del motor — informa al usuario qué facturas priorizar para cobrar primero.
def calcular_scoring_cobranza(factura: FEDocument, patron: PatronPagoCliente) -> dict:
dias_vencida = (date.today() - factura.fecha_emision).days
score_base = 100
score_base -= min(40, dias_vencida * 0.5) # penalizar antigüedad
score_base -= patron.tasa_incobrable * 100 * 0.3 # penalizar historial de incobrable
score_base -= min(20, patron.dso_promedio / 3) # penalizar clientes que pagan lento
return {
"score": max(0, round(score_base)),
"dso_esperado": patron.dso_promedio,
"probabilidad_cobro_30d": 1 - patron.tasa_incobrable,
"accion": "urgente" if score_base < 40 else "normal"
}
4. Módulo 3 — Simulación Monte Carlo
N_SIMULACIONES = 1_000
HORIZONTE_DIAS = 90
def simular_tesoreria(
saldo_inicial: Decimal,
flujos_deterministicos: list[FlujoDeter],
facturas_pendientes: list[FEDocument],
patrones_clientes: dict[str, PatronPagoCliente],
historial_ingresos: list[Movimiento],
) -> np.ndarray: # shape: (N_SIMULACIONES, HORIZONTE_DIAS)
resultados = np.zeros((N_SIMULACIONES, HORIZONTE_DIAS))
for sim in range(N_SIMULACIONES):
saldo = float(saldo_inicial)
for dia in range(HORIZONTE_DIAS):
fecha_dia = date.today() + timedelta(days=dia)
# Flujos determinísticos
for flujo in flujos_deterministicos:
if flujo.dia_del_mes == fecha_dia.day:
saldo += flujo.monto_esperado # positivo = ingreso, negativo = egreso
# Flujos AR (facturas pendientes)
for factura in facturas_pendientes:
patron = patrones_clientes.get(factura.receptor_cedula)
dso_simulado = max(0, np.random.normal(patron.dso_promedio, patron.dso_std))
fecha_cobro_esperada = factura.fecha_emision + timedelta(days=dso_simulado)
if fecha_cobro_esperada.date() == fecha_dia:
# ¿Se cobra o resulta incobrable?
if np.random.random() > patron.tasa_incobrable:
saldo += float(factura.total)
# Ingresos estocásticos (ventas futuras no realizadas)
ingreso_dia = muestrear_ingreso_historico(historial_ingresos, fecha_dia)
saldo += ingreso_dia
resultados[sim, dia] = saldo
return resultados
5. Módulo 4 — Percentiles P10/P50/P90
def calcular_percentiles(simulaciones: np.ndarray) -> list[ForecastDataPoint]:
resultado = []
for dia in range(simulaciones.shape[1]):
col = simulaciones[:, dia]
resultado.append(ForecastDataPoint(
fecha=(date.today() + timedelta(days=dia)).isoformat(),
p10=round(float(np.percentile(col, 10)), 2),
p50=round(float(np.percentile(col, 50)), 2),
p90=round(float(np.percentile(col, 90)), 2),
confianza='alta' if dia <= 30 else 'media' if dia <= 60 else 'baja'
))
return resultado
Interpretación de los percentiles: - P50: escenario más probable (mediana de 1,000 simulaciones) - P10: escenario pesimista — solo el 10% de los escenarios son peores que este - P90: escenario optimista — solo el 10% de los escenarios son mejores que este
6. Módulo 5 — Detección de alertas
UMBRAL_LIQUIDEZ_MINIMA = 2000.0 # USD (configurable por empresa)
def detectar_alertas(series: list[ForecastDataPoint]) -> list[Alerta]:
alertas = []
for punto in series:
if punto.p50 < UMBRAL_LIQUIDEZ_MINIMA:
alertas.append(Alerta(
tipo='gap_liquidez',
dia_estimado=(date.fromisoformat(punto.fecha) - date.today()).days,
severidad='critical' if punto.p10 < 0 else 'warning',
saldo_p50=punto.p50,
saldo_p10=punto.p10,
descripcion=f"Saldo proyectado baja a ${punto.p50:,.0f} (escenario base)",
accion_sugerida=_generar_accion_sugerida(punto)
))
break # reportar solo la primera alerta crítica
return alertas
7. Módulo 6 — Explicabilidad (Jinja2)
{# Template: treasury_alert_explanation.j2 #}
Tu saldo proyectado en {{ alerta.dia_estimado }} días es de **${{ alerta.saldo_p50 | formato_moneda }}**
(escenario base, P50).
**¿Por qué?**
{% for factor in factores_principales %}
- **{{ factor.descripcion }}**: {{ factor.impacto_estimado | formato_moneda }}
{% if factor.tipo == 'ar_vencido' %}
({{ factor.cliente_nombre }} históricamente paga {{ factor.dso_promedio | int }} días tarde)
{% elif factor.tipo == 'gasto_fijo' %}
(gasto recurrente detectado en historial — vence el día {{ factor.dia_del_mes }})
{% endif %}
{% endfor %}
**Acción sugerida:** {{ alerta.accion_sugerida }}
8. Guardrails del motor
| # | Guardrail | Condición | Acción |
|---|---|---|---|
| G1 | Historial insuficiente | <90 días de movimientos bancarios conciliados | Mostrar forecast con confianza='baja' + banner de advertencia |
| G2 | Saldo actual desconocido | Cuenta bancaria sin sync en >48h | Suspender forecast + alerta de reconexión |
| G3 | Outliers extremos | P90 - P10 > 3× P50 | Marcar serie completa como 'estimación amplia' |
| G4 | Moneda inconsistente | Facturas en múltiples monedas sin tipo de cambio | Usar tipo de cambio del Banco Central CR del día anterior |
| G5 | Empresa sin historial AR | Menos de 3 clientes con pagos históricos | Usar DSO benchmark sectorial (45 días para servicios, 30 días para distribuidoras) |
9. Métricas de calidad del motor
| Métrica | Cálculo | Target | Alarma |
|---|---|---|---|
| Error de calibración P50 | |Saldo real en día D - P50 en D-30| / P50 | <15% | >25% por 2 semanas |
| Cobertura P10-P90 | % de días donde saldo real cae dentro de la banda | ≥80% | <70% |
| RMSE semanal | √(Σ(real - P50)² / N) en ventana 7 días | Decrece con historial | Estancado >4 semanas |
| Adopción de alertas | % alertas donde usuario tomó acción ≤48h | ≥60% | <30% |
10. Limitaciones declaradas
BookMind declara explícitamente en la UI:
"El forecast de tesorería es una estimación probabilística basada en tus datos históricos y las facturas registradas. No garantiza la exactitud de los saldos futuros. Factores externos (pagos de clientes fuera del patrón histórico, gastos no registrados, imprevistos) pueden causar desviaciones. Las bandas de confianza (P10/P90) representan el 80% central de los escenarios simulados — siempre hay un 20% de escenarios fuera de ese rango."
Ver también: Doc 07 (SRD Backend — treasury_worker y pipeline de ejecución) · Doc 08 (Modelo de Datos — bk_forecast_results) · Doc 13 (KPIs — métricas de calidad del motor) · Doc 06 (SRD Frontend — TreasuryForecastChart)