CtrlK
BlogDocsLog inGet started
Tessl Logo

cash-flow-projection

Patrones para proyección de cash flow y forecasting financiero. Incluye algoritmos de proyección a fin de mes, detección de gastos recurrentes programados, y visualización de tendencias. Usar cuando se trabaje con proyecciones, predicciones o gráficos de futuro.

Install with Tessl CLI

npx tessl i github:iru97/Savvy-ai --skill cash-flow-projection
What are skills?

66

Does it follow best practices?

Validation for skill structure

SKILL.md
Review
Evals

Cash Flow Projection - Savvy AI

Conocimiento especializado para proyectar el flujo de caja del usuario y predecir su situación financiera futura.

Concepto Core

PASADO          PRESENTE          FUTURO
────────────────────┼─────────────────────────►
                    │
  Balance           │    Proyección
  Acumulado         │    Cash Flow
  (datos reales)    │    (predicción)
                    │
                    Hoy

Modelo de Proyección

Entidades

class CashFlowProjection extends Equatable {
  final DateTime fecha;
  final double balanceProyectado;
  final List<ProjectedTransaction> transaccionesProgramadas;
  final double confianza; // 0.0 - 1.0

  const CashFlowProjection({
    required this.fecha,
    required this.balanceProyectado,
    required this.transaccionesProgramadas,
    required this.confianza,
  });

  @override
  List<Object?> get props => [fecha, balanceProyectado, transaccionesProgramadas, confianza];
}

class ProjectedTransaction extends Equatable {
  final DateTime fechaEsperada;
  final String concepto;
  final double monto;
  final TransactionType tipo;
  final String categoriaId;
  final ProjectionSource source;
  final double confianza;

  const ProjectedTransaction({
    required this.fechaEsperada,
    required this.concepto,
    required this.monto,
    required this.tipo,
    required this.categoriaId,
    required this.source,
    required this.confianza,
  });

  @override
  List<Object?> get props => [fechaEsperada, concepto, monto, tipo, categoriaId, source, confianza];
}

enum ProjectionSource {
  recurring,      // Gasto/ingreso recurrente detectado
  scheduled,      // Programado por el usuario
  historical,     // Basado en historial
  budget,         // Basado en presupuesto
}

Algoritmo de Proyección

Paso 1: Detectar Transacciones Recurrentes

class RecurrenceDetector {
  /// Analiza historial y detecta patrones recurrentes
  static List<DetectedRecurrence> detect(
    List<Transaction> history,
    {int minOccurrences = 2, int lookbackDays = 90}
  ) {
    final recurrences = <DetectedRecurrence>[];
    final now = DateTime.now();
    final cutoff = now.subtract(Duration(days: lookbackDays));

    // Filtrar transacciones en el período de análisis
    final relevant = history.where((t) => t.fecha.isAfter(cutoff)).toList();

    // Agrupar por concepto normalizado + monto similar
    final groups = _groupBySimilarity(relevant);

    for (final group in groups.entries) {
      if (group.value.length >= minOccurrences) {
        final pattern = _analyzePattern(group.value);
        if (pattern != null) {
          recurrences.add(pattern);
        }
      }
    }

    return recurrences;
  }

  /// Agrupa transacciones por similitud
  static Map<String, List<Transaction>> _groupBySimilarity(List<Transaction> txns) {
    final groups = <String, List<Transaction>>{};

    for (final t in txns) {
      final key = _generateSimilarityKey(t);
      groups.putIfAbsent(key, () => []).add(t);
    }

    return groups;
  }

  /// Genera clave de similitud
  /// - Normaliza concepto (quita números, fechas)
  /// - Agrupa montos similares (±10%)
  static String _generateSimilarityKey(Transaction t) {
    final normalizedConcept = t.concepto
      .toLowerCase()
      .replaceAll(RegExp(r'\d{1,2}/\d{1,2}'), '')  // Quita fechas
      .replaceAll(RegExp(r'\d+'), '')              // Quita números
      .replaceAll(RegExp(r'\s+'), ' ')             // Normaliza espacios
      .trim();

    // Redondear monto al 10% más cercano para agrupar
    final roundedAmount = (t.monto / 10).round() * 10;

    return '${t.categoriaId}|$normalizedConcept|$roundedAmount';
  }

  /// Analiza patrón de frecuencia
  static DetectedRecurrence? _analyzePattern(List<Transaction> txns) {
    if (txns.length < 2) return null;

    // Ordenar por fecha
    final sorted = List<Transaction>.from(txns)
      ..sort((a, b) => a.fecha.compareTo(b.fecha));

    // Calcular intervalos entre transacciones
    final intervals = <int>[];
    for (int i = 1; i < sorted.length; i++) {
      intervals.add(sorted[i].fecha.difference(sorted[i-1].fecha).inDays);
    }

    // Calcular promedio y desviación
    final avgInterval = intervals.reduce((a, b) => a + b) / intervals.length;
    final stdDev = _standardDeviation(intervals, avgInterval);

    // Determinar frecuencia si la desviación es baja
    final frequency = _inferFrequency(avgInterval);
    if (frequency == null) return null;

    // Calcular día esperado del próximo cargo
    final lastDate = sorted.last.fecha;
    final nextExpected = _calculateNextDate(lastDate, frequency, avgInterval);

    // Confianza basada en consistencia
    final confidence = _calculateConfidence(intervals, avgInterval, stdDev);

    return DetectedRecurrence(
      concepto: sorted.first.concepto,
      montoPromedio: txns.map((t) => t.monto).reduce((a, b) => a + b) / txns.length,
      tipo: sorted.first.tipo,
      categoriaId: sorted.first.categoriaId,
      frequency: frequency,
      dayOfMonth: frequency == RecurrenceFrequency.monthly
          ? _mostCommonDayOfMonth(sorted)
          : null,
      dayOfWeek: frequency == RecurrenceFrequency.weekly
          ? _mostCommonDayOfWeek(sorted)
          : null,
      nextExpectedDate: nextExpected,
      confidence: confidence,
      occurrences: txns.length,
    );
  }

  static RecurrenceFrequency? _inferFrequency(double avgDays) {
    if (avgDays >= 1 && avgDays <= 2) return RecurrenceFrequency.daily;
    if (avgDays >= 6 && avgDays <= 8) return RecurrenceFrequency.weekly;
    if (avgDays >= 13 && avgDays <= 16) return RecurrenceFrequency.biweekly;
    if (avgDays >= 28 && avgDays <= 32) return RecurrenceFrequency.monthly;
    if (avgDays >= 88 && avgDays <= 95) return RecurrenceFrequency.quarterly;
    if (avgDays >= 360 && avgDays <= 370) return RecurrenceFrequency.yearly;
    return null;
  }

  static double _standardDeviation(List<int> values, double mean) {
    if (values.isEmpty) return 0;
    final sumSquaredDiff = values
      .map((v) => pow(v - mean, 2))
      .reduce((a, b) => a + b);
    return sqrt(sumSquaredDiff / values.length);
  }

  static double _calculateConfidence(List<int> intervals, double avg, double stdDev) {
    // Confianza alta si desviación es baja relativa al promedio
    if (avg == 0) return 0;
    final cv = stdDev / avg; // Coeficiente de variación

    if (cv < 0.1) return 0.95;  // Muy consistente
    if (cv < 0.2) return 0.85;  // Consistente
    if (cv < 0.3) return 0.70;  // Algo variable
    if (cv < 0.5) return 0.50;  // Variable
    return 0.30;                 // Muy variable
  }
}

class DetectedRecurrence extends Equatable {
  final String concepto;
  final double montoPromedio;
  final TransactionType tipo;
  final String categoriaId;
  final RecurrenceFrequency frequency;
  final int? dayOfMonth;
  final int? dayOfWeek;
  final DateTime nextExpectedDate;
  final double confidence;
  final int occurrences;

  const DetectedRecurrence({
    required this.concepto,
    required this.montoPromedio,
    required this.tipo,
    required this.categoriaId,
    required this.frequency,
    this.dayOfMonth,
    this.dayOfWeek,
    required this.nextExpectedDate,
    required this.confidence,
    required this.occurrences,
  });

  /// Descripción legible
  String get description {
    switch (frequency) {
      case RecurrenceFrequency.daily:
        return 'Diario';
      case RecurrenceFrequency.weekly:
        return dayOfWeek != null
          ? 'Cada ${_dayName(dayOfWeek!)}'
          : 'Semanal';
      case RecurrenceFrequency.biweekly:
        return 'Quincenal';
      case RecurrenceFrequency.monthly:
        return dayOfMonth != null
          ? 'Día $dayOfMonth de cada mes'
          : 'Mensual';
      case RecurrenceFrequency.quarterly:
        return 'Trimestral';
      case RecurrenceFrequency.yearly:
        return 'Anual';
    }
  }

  @override
  List<Object?> get props => [concepto, montoPromedio, tipo, frequency, confidence];
}

enum RecurrenceFrequency {
  daily,
  weekly,
  biweekly,
  monthly,
  quarterly,
  yearly,
}

Paso 2: Proyectar Cash Flow

class CashFlowProjector {
  /// Proyecta el balance para los próximos N días
  static List<CashFlowProjection> project({
    required double currentBalance,
    required List<DetectedRecurrence> recurrences,
    required List<Transaction> scheduledTransactions,
    required int daysToProject,
    double? expectedIncome, // Ingreso mensual esperado (si se conoce)
  }) {
    final projections = <CashFlowProjection>[];
    final now = DateTime.now();
    double balance = currentBalance;

    for (int day = 0; day <= daysToProject; day++) {
      final date = now.add(Duration(days: day));
      final dayTransactions = <ProjectedTransaction>[];

      // 1. Agregar transacciones recurrentes para esta fecha
      for (final rec in recurrences) {
        if (_isExpectedOn(rec, date)) {
          dayTransactions.add(ProjectedTransaction(
            fechaEsperada: date,
            concepto: rec.concepto,
            monto: rec.montoPromedio,
            tipo: rec.tipo,
            categoriaId: rec.categoriaId,
            source: ProjectionSource.recurring,
            confianza: rec.confidence,
          ));

          // Actualizar balance
          if (rec.tipo == TransactionType.ingreso) {
            balance += rec.montoPromedio;
          } else {
            balance -= rec.montoPromedio;
          }
        }
      }

      // 2. Agregar transacciones programadas por el usuario
      for (final scheduled in scheduledTransactions) {
        if (_isSameDay(scheduled.fecha, date)) {
          dayTransactions.add(ProjectedTransaction(
            fechaEsperada: date,
            concepto: scheduled.concepto,
            monto: scheduled.monto,
            tipo: scheduled.tipo,
            categoriaId: scheduled.categoriaId,
            source: ProjectionSource.scheduled,
            confianza: 1.0, // 100% confianza en programadas
          ));

          if (scheduled.tipo == TransactionType.ingreso) {
            balance += scheduled.monto;
          } else {
            balance -= scheduled.monto;
          }
        }
      }

      // Calcular confianza general del día
      final dayConfidence = _calculateDayConfidence(dayTransactions, day);

      projections.add(CashFlowProjection(
        fecha: date,
        balanceProyectado: balance,
        transaccionesProgramadas: dayTransactions,
        confianza: dayConfidence,
      ));
    }

    return projections;
  }

  /// Verifica si una recurrencia se espera en esta fecha
  static bool _isExpectedOn(DetectedRecurrence rec, DateTime date) {
    switch (rec.frequency) {
      case RecurrenceFrequency.daily:
        return true;
      case RecurrenceFrequency.weekly:
        return rec.dayOfWeek != null && date.weekday == rec.dayOfWeek;
      case RecurrenceFrequency.biweekly:
        // Verificar si han pasado múltiplos de 14 días desde la última
        final daysSinceLast = date.difference(rec.nextExpectedDate).inDays;
        return daysSinceLast >= 0 && daysSinceLast % 14 == 0;
      case RecurrenceFrequency.monthly:
        return rec.dayOfMonth != null && date.day == rec.dayOfMonth;
      case RecurrenceFrequency.quarterly:
        return rec.dayOfMonth != null &&
               date.day == rec.dayOfMonth &&
               (date.month % 3 == rec.nextExpectedDate.month % 3);
      case RecurrenceFrequency.yearly:
        return date.month == rec.nextExpectedDate.month &&
               date.day == rec.nextExpectedDate.day;
    }
  }

  /// La confianza disminuye mientras más lejos en el futuro
  static double _calculateDayConfidence(List<ProjectedTransaction> txns, int daysAhead) {
    if (txns.isEmpty) return 0.5; // Sin datos, 50%

    // Promedio de confianza de transacciones
    final avgTxnConfidence = txns.map((t) => t.confianza).reduce((a, b) => a + b) / txns.length;

    // Factor de decaimiento por distancia temporal
    // La confianza decae 2% por día
    final decayFactor = max(0.5, 1 - (daysAhead * 0.02));

    return avgTxnConfidence * decayFactor;
  }
}

Paso 3: Generar Alertas de Proyección

class ProjectionAlertGenerator {
  /// Genera alertas basadas en la proyección
  static List<ProjectionAlert> generateAlerts(
    List<CashFlowProjection> projections,
    {double lowBalanceThreshold = 100}
  ) {
    final alerts = <ProjectionAlert>[];

    for (final projection in projections) {
      // Alerta de balance bajo
      if (projection.balanceProyectado < lowBalanceThreshold) {
        final daysUntil = projection.fecha.difference(DateTime.now()).inDays;
        alerts.add(ProjectionAlert(
          type: ProjectionAlertType.lowBalance,
          title: daysUntil <= 7
            ? '⚠️ Balance bajo en ${daysUntil} días'
            : 'Balance bajo proyectado',
          message: 'El ${_formatDate(projection.fecha)} tu balance '
                   'proyectado será \$${projection.balanceProyectado.toStringAsFixed(0)}.',
          severity: daysUntil <= 3 ? AlertSeverity.high : AlertSeverity.medium,
          projectedDate: projection.fecha,
          projectedBalance: projection.balanceProyectado,
        ));
        break; // Solo una alerta de balance bajo
      }

      // Alerta de balance negativo
      if (projection.balanceProyectado < 0) {
        final daysUntil = projection.fecha.difference(DateTime.now()).inDays;
        alerts.add(ProjectionAlert(
          type: ProjectionAlertType.negativeBalance,
          title: '🚨 Balance negativo proyectado',
          message: 'Si todo sigue igual, el ${_formatDate(projection.fecha)} '
                   'tendrás un balance de \$${projection.balanceProyectado.toStringAsFixed(0)}.',
          severity: AlertSeverity.critical,
          projectedDate: projection.fecha,
          projectedBalance: projection.balanceProyectado,
        ));
        break;
      }
    }

    // Alerta de gasto grande próximo
    for (final projection in projections) {
      for (final txn in projection.transaccionesProgramadas) {
        if (txn.tipo == TransactionType.egreso && txn.monto > 500) {
          final daysUntil = projection.fecha.difference(DateTime.now()).inDays;
          if (daysUntil <= 7) {
            alerts.add(ProjectionAlert(
              type: ProjectionAlertType.largeExpensecoming,
              title: 'Gasto grande próximo',
              message: '${txn.concepto} (\$${txn.monto.toStringAsFixed(0)}) '
                       'se espera el ${_formatDate(projection.fecha)}.',
              severity: AlertSeverity.low,
              projectedDate: projection.fecha,
              projectedBalance: projection.balanceProyectado,
            ));
          }
        }
      }
    }

    return alerts;
  }
}

enum ProjectionAlertType {
  lowBalance,
  negativeBalance,
  largeExpensecoming,
  unusualPattern,
}

enum AlertSeverity { low, medium, high, critical }

class ProjectionAlert {
  final ProjectionAlertType type;
  final String title;
  final String message;
  final AlertSeverity severity;
  final DateTime projectedDate;
  final double projectedBalance;

  const ProjectionAlert({
    required this.type,
    required this.title,
    required this.message,
    required this.severity,
    required this.projectedDate,
    required this.projectedBalance,
  });
}

Visualización

Gráfico de Proyección

class ProjectionChartWidget extends StatelessWidget {
  final List<MonthlyData> historicalData;      // Datos pasados (línea sólida)
  final List<CashFlowProjection> projections;  // Proyección (línea punteada)

  @override
  Widget build(BuildContext context) {
    return LineChart(
      LineChartData(
        lineBarsData: [
          // Línea histórica (sólida)
          LineChartBarData(
            spots: _buildHistoricalSpots(),
            color: AppColors.primary,
            barWidth: 3,
            dotData: FlDotData(show: true),
          ),
          // Línea proyectada (punteada)
          LineChartBarData(
            spots: _buildProjectionSpots(),
            color: AppColors.primary.withOpacity(0.5),
            barWidth: 2,
            dashArray: [5, 5],  // Línea punteada
            dotData: FlDotData(show: false),
          ),
          // Banda de confianza (área sombreada)
          LineChartBarData(
            spots: _buildUpperConfidenceBand(),
            color: Colors.transparent,
            belowBarData: BarAreaData(
              show: true,
              color: AppColors.primary.withOpacity(0.1),
              cutOffY: _getMinProjectedBalance(),
              applyCutOffY: true,
            ),
          ),
        ],
        // Línea vertical en "hoy"
        extraLinesData: ExtraLinesData(
          verticalLines: [
            VerticalLine(
              x: _getTodayX(),
              color: Colors.grey,
              strokeWidth: 1,
              dashArray: [3, 3],
              label: VerticalLineLabel(
                show: true,
                labelResolver: (_) => 'Hoy',
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Casos de Uso

Caso 1: Usuario con salario mensual

Detectado:
- Ingreso: "Nómina" - $3,000 el día 15
- Gasto: "Renta" - $800 el día 1
- Gasto: "Netflix" - $15 el día 10
- Gasto: "Spotify" - $10 el día 5

Proyección (hoy es día 20):
- Día 1 próximo mes: Balance actual - $800 = $X
- Día 5: $X - $10 = $Y
- Día 10: $Y - $15 = $Z
- Día 15: $Z + $3,000 = Balance proyectado

Caso 2: Usuario con balance bajo

Balance actual: $500
Proyección:
- En 5 días: Renta $800
- Balance proyectado: -$300

Alerta generada:
"🚨 Balance negativo proyectado
 Si todo sigue igual, el 1 de febrero tendrás un balance de -$300.
 Considera reducir gastos o conseguir ingresos adicionales."

Notas de Implementación

  1. Mínimo de datos - Necesita al menos 2 meses de historial para proyecciones confiables
  2. Recalcular diariamente - Las proyecciones deben actualizarse cada día
  3. Comunicar incertidumbre - Siempre mostrar confianza/incertidumbre
  4. No alarmar innecesariamente - Solo alertar si la confianza es alta (>70%)
  5. Permitir ajustes - El usuario debe poder agregar/quitar transacciones programadas
Repository
iru97/Savvy-ai
Last updated
Created

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.