Docs BookMind
09
Motor Core — BookMind
Tesorería predictiva P10/P50/P90, scoring de cobranza y anomaly detection
Versión
v1.0
Fecha
Mayo 2026
Audiencia
Founders · Dev · Asesores
Estado
DRAFT
BOOKMIND
El cerebro y las manos de tu finanza.
Módulo #3 — Cheryx Suite

[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):

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:


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)