CtrlK
BlogDocsLog inGet started
Tessl Logo

spending-analysis

Patrones para análisis profundo de gastos por subcategoría, detección de anomalías, y comparaciones temporales. Incluye algoritmos de clustering de gastos, detección de outliers, y análisis de tendencias. Usar cuando se trabaje con análisis detallado, reportes, o detección de patrones de gasto.

Install with Tessl CLI

npx tessl i github:iru97/Savvy-ai --skill spending-analysis
What are skills?

77

Does it follow best practices?

Validation for skill structure

SKILL.md
Review
Evals

Spending Analysis - Savvy AI

Conocimiento especializado para análisis profundo de patrones de gasto, detección de anomalías, y generación de reportes inteligentes.

Análisis por Subcategoría

Modelo de Análisis

class SubcategoryAnalysis extends Equatable {
  final String categoriaId;
  final String subcategoriaId;
  final String nombre;
  final double totalMes;
  final double totalMesAnterior;
  final double promedioHistorico;
  final int transaccionesCount;
  final double porcentajeDeCategoria;
  final double porcentajeDeTotal;
  final TrendDirection trend;
  final bool isAnomaly;
  final double? anomalyScore;

  const SubcategoryAnalysis({
    required this.categoriaId,
    required this.subcategoriaId,
    required this.nombre,
    required this.totalMes,
    required this.totalMesAnterior,
    required this.promedioHistorico,
    required this.transaccionesCount,
    required this.porcentajeDeCategoria,
    required this.porcentajeDeTotal,
    required this.trend,
    required this.isAnomaly,
    this.anomalyScore,
  });

  /// Cambio porcentual vs mes anterior
  double get cambioVsMesAnterior {
    if (totalMesAnterior == 0) return totalMes > 0 ? 100 : 0;
    return ((totalMes - totalMesAnterior) / totalMesAnterior) * 100;
  }

  /// Cambio vs promedio histórico
  double get cambioVsPromedio {
    if (promedioHistorico == 0) return totalMes > 0 ? 100 : 0;
    return ((totalMes - promedioHistorico) / promedioHistorico) * 100;
  }

  @override
  List<Object?> get props => [categoriaId, subcategoriaId, totalMes];
}

enum TrendDirection {
  increasing,   // ↑ Subiendo
  decreasing,   // ↓ Bajando
  stable,       // → Estable
  volatile,     // ↕ Volátil
}

Analizador de Subcategorías

class SubcategoryAnalyzer {
  /// Analiza todas las subcategorías del período
  static List<SubcategoryAnalysis> analyze({
    required List<Transaction> currentMonth,
    required List<Transaction> previousMonth,
    required List<Transaction> history, // 3-6 meses
    required Map<String, Category> categories,
  }) {
    final analyses = <SubcategoryAnalysis>[];
    final totalGastosMes = _totalExpenses(currentMonth);

    // Agrupar por categoría > subcategoría
    final currentBySubcat = _groupBySubcategory(currentMonth);
    final previousBySubcat = _groupBySubcategory(previousMonth);
    final historyBySubcat = _groupBySubcategoryWithAverage(history);

    for (final entry in currentBySubcat.entries) {
      final key = entry.key; // "categoria:subcategoria"
      final parts = key.split(':');
      final catId = parts[0];
      final subcatId = parts.length > 1 ? parts[1] : null;

      final currentTotal = entry.value.total;
      final previousTotal = previousBySubcat[key]?.total ?? 0;
      final historicalAvg = historyBySubcat[key]?.average ?? 0;
      final txnCount = entry.value.count;

      // Calcular porcentajes
      final categoryTotal = _getCategoryTotal(catId, currentMonth);
      final pctOfCategory = categoryTotal > 0 ? (currentTotal / categoryTotal) * 100 : 0;
      final pctOfTotal = totalGastosMes > 0 ? (currentTotal / totalGastosMes) * 100 : 0;

      // Detectar tendencia
      final trend = _detectTrend(
        current: currentTotal,
        previous: previousTotal,
        historical: historyBySubcat[key]?.values ?? [],
      );

      // Detectar anomalía
      final anomalyResult = _detectAnomaly(
        current: currentTotal,
        historicalAvg: historicalAvg,
        historicalStdDev: historyBySubcat[key]?.stdDev ?? 0,
      );

      analyses.add(SubcategoryAnalysis(
        categoriaId: catId,
        subcategoriaId: subcatId ?? '',
        nombre: _getSubcategoryName(catId, subcatId, categories),
        totalMes: currentTotal,
        totalMesAnterior: previousTotal,
        promedioHistorico: historicalAvg,
        transaccionesCount: txnCount,
        porcentajeDeCategoria: pctOfCategory,
        porcentajeDeTotal: pctOfTotal,
        trend: trend,
        isAnomaly: anomalyResult.isAnomaly,
        anomalyScore: anomalyResult.score,
      ));
    }

    // Ordenar por monto (mayor a menor)
    analyses.sort((a, b) => b.totalMes.compareTo(a.totalMes));

    return analyses;
  }

  /// Detecta tendencia basada en historial
  static TrendDirection _detectTrend({
    required double current,
    required double previous,
    required List<double> historical,
  }) {
    if (historical.length < 2) {
      // Sin suficiente historial, comparar con anterior
      final change = previous > 0 ? (current - previous) / previous : 0;
      if (change > 0.1) return TrendDirection.increasing;
      if (change < -0.1) return TrendDirection.decreasing;
      return TrendDirection.stable;
    }

    // Calcular coeficiente de variación
    final mean = historical.reduce((a, b) => a + b) / historical.length;
    final variance = historical.map((v) => pow(v - mean, 2)).reduce((a, b) => a + b) / historical.length;
    final stdDev = sqrt(variance);
    final cv = mean > 0 ? stdDev / mean : 0;

    // Si es muy volátil
    if (cv > 0.5) return TrendDirection.volatile;

    // Calcular pendiente (regresión lineal simple)
    final slope = _calculateSlope(historical);
    final relativeSlope = mean > 0 ? slope / mean : 0;

    if (relativeSlope > 0.05) return TrendDirection.increasing;
    if (relativeSlope < -0.05) return TrendDirection.decreasing;
    return TrendDirection.stable;
  }

  /// Detecta si el gasto actual es anómalo
  static ({bool isAnomaly, double score}) _detectAnomaly({
    required double current,
    required double historicalAvg,
    required double historicalStdDev,
  }) {
    if (historicalAvg == 0) {
      return (isAnomaly: false, score: 0);
    }

    // Z-score
    final zScore = historicalStdDev > 0
      ? (current - historicalAvg) / historicalStdDev
      : 0.0;

    // Anomalía si z-score > 2 (más de 2 desviaciones estándar)
    return (
      isAnomaly: zScore.abs() > 2,
      score: zScore,
    );
  }

  /// Calcula pendiente con regresión lineal
  static double _calculateSlope(List<double> values) {
    if (values.length < 2) return 0;

    final n = values.length;
    double sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;

    for (int i = 0; i < n; i++) {
      sumX += i;
      sumY += values[i];
      sumXY += i * values[i];
      sumX2 += i * i;
    }

    final denominator = n * sumX2 - sumX * sumX;
    if (denominator == 0) return 0;

    return (n * sumXY - sumX * sumY) / denominator;
  }
}

Detección de Patrones de Gasto

Patrones Comunes

enum SpendingPattern {
  weekendSpender,      // Gasta más los fines de semana
  endOfMonthSurge,     // Gasta más a fin de mes
  paydaySpike,         // Pico de gasto después de cobrar
  lunchRush,           // Muchos gastos pequeños al mediodía
  nightOwl,            // Gasta de noche (delivery, entretenimiento)
  impulseBuyer,        // Muchas compras pequeñas no planificadas
  subscriptionHeavy,   // Alto gasto en suscripciones
  diningOut,           // Prefiere comer fuera vs cocinar
  bargainHunter,       // Busca ofertas (gastos variables)
}

class PatternDetector {
  /// Detecta patrones de comportamiento de gasto
  static List<DetectedPattern> detectPatterns(
    List<Transaction> transactions,
    {int minDaysOfData = 30}
  ) {
    final patterns = <DetectedPattern>[];

    // 1. Weekend Spender
    final weekendPattern = _detectWeekendPattern(transactions);
    if (weekendPattern != null) patterns.add(weekendPattern);

    // 2. End of Month Surge
    final eomPattern = _detectEndOfMonthPattern(transactions);
    if (eomPattern != null) patterns.add(eomPattern);

    // 3. Dining Out
    final diningPattern = _detectDiningPattern(transactions);
    if (diningPattern != null) patterns.add(diningPattern);

    // 4. Subscription Heavy
    final subPattern = _detectSubscriptionPattern(transactions);
    if (subPattern != null) patterns.add(subPattern);

    // 5. Impulse Buyer
    final impulsePattern = _detectImpulsePattern(transactions);
    if (impulsePattern != null) patterns.add(impulsePattern);

    return patterns;
  }

  /// Detecta si el usuario gasta más los fines de semana
  static DetectedPattern? _detectWeekendPattern(List<Transaction> txns) {
    final expenses = txns.where((t) => t.tipo == TransactionType.egreso);

    double weekdayTotal = 0, weekendTotal = 0;
    int weekdayCount = 0, weekendCount = 0;

    for (final t in expenses) {
      if (t.fecha.weekday >= 6) { // Sábado o Domingo
        weekendTotal += t.monto;
        weekendCount++;
      } else {
        weekdayTotal += t.monto;
        weekdayCount++;
      }
    }

    // Promediar por día
    final weekdayAvg = weekdayCount > 0 ? weekdayTotal / 5 : 0; // 5 días laborales
    final weekendAvg = weekendCount > 0 ? weekendTotal / 2 : 0; // 2 días fin de semana

    // Si gasta 50% más en fin de semana
    if (weekendAvg > weekdayAvg * 1.5) {
      return DetectedPattern(
        pattern: SpendingPattern.weekendSpender,
        confidence: _calculateConfidence(weekendAvg, weekdayAvg),
        insight: 'Gastas ${((weekendAvg/weekdayAvg - 1) * 100).toStringAsFixed(0)}% más los fines de semana.',
        recommendation: 'Planificar actividades de bajo costo para el fin de semana podría ahorrarte dinero.',
        affectedAmount: weekendTotal - (weekdayAvg * 2),
      );
    }

    return null;
  }

  /// Detecta patrón de comer fuera
  static DetectedPattern? _detectDiningPattern(List<Transaction> txns) {
    final foodExpenses = txns.where((t) =>
      t.tipo == TransactionType.egreso &&
      t.categoriaId == 'alimentacion'
    );

    double restaurants = 0, grocery = 0;

    for (final t in foodExpenses) {
      if (t.subcategoriaId == 'restaurantes' ||
          t.subcategoriaId == 'delivery' ||
          t.subcategoriaId == 'cafeterias') {
        restaurants += t.monto;
      } else if (t.subcategoriaId == 'supermercado') {
        grocery += t.monto;
      }
    }

    final total = restaurants + grocery;
    if (total == 0) return null;

    final restaurantPct = restaurants / total;

    // Si más del 60% es comer fuera
    if (restaurantPct > 0.6) {
      final potentialSavings = restaurants * 0.4; // Podría ahorrar 40%
      return DetectedPattern(
        pattern: SpendingPattern.diningOut,
        confidence: restaurantPct,
        insight: '${(restaurantPct * 100).toStringAsFixed(0)}% de tu gasto en alimentación es comer fuera.',
        recommendation: 'Cocinar 2-3 veces más por semana podría ahorrarte \$${potentialSavings.toStringAsFixed(0)}/mes.',
        affectedAmount: restaurants,
        potentialSavings: potentialSavings,
      );
    }

    return null;
  }

  /// Detecta si tiene muchas suscripciones
  static DetectedPattern? _detectSubscriptionPattern(List<Transaction> txns) {
    final subscriptions = txns.where((t) =>
      t.tipo == TransactionType.egreso &&
      t.categoriaId == 'suscripciones'
    );

    if (subscriptions.isEmpty) return null;

    // Detectar suscripciones únicas por concepto normalizado
    final uniqueSubs = <String>{};
    double totalSubs = 0;

    for (final t in subscriptions) {
      uniqueSubs.add(_normalizeSubscription(t.concepto));
      totalSubs += t.monto;
    }

    // Si tiene más de 5 suscripciones
    if (uniqueSubs.length >= 5) {
      return DetectedPattern(
        pattern: SpendingPattern.subscriptionHeavy,
        confidence: 0.9,
        insight: 'Tienes ${uniqueSubs.length} suscripciones activas por \$${totalSubs.toStringAsFixed(0)}/mes.',
        recommendation: 'Revisa si usas todas regularmente. Cancelar las no usadas podría ahorrarte dinero.',
        affectedAmount: totalSubs,
        details: uniqueSubs.toList(),
      );
    }

    return null;
  }

  /// Detecta compras impulsivas (muchas pequeñas)
  static DetectedPattern? _detectImpulsePattern(List<Transaction> txns) {
    final smallPurchases = txns.where((t) =>
      t.tipo == TransactionType.egreso &&
      t.monto < 50 && // Menos de $50
      (t.categoriaId == 'compras' || t.categoriaId == 'entretenimiento')
    ).toList();

    if (smallPurchases.length < 10) return null;

    // Calcular frecuencia
    final days = txns.isNotEmpty
      ? txns.last.fecha.difference(txns.first.fecha).inDays + 1
      : 1;
    final avgPerDay = smallPurchases.length / days;

    // Si más de 1 compra pequeña por día en promedio
    if (avgPerDay > 1) {
      final totalSmall = smallPurchases.fold(0.0, (sum, t) => sum + t.monto);
      return DetectedPattern(
        pattern: SpendingPattern.impulseBuyer,
        confidence: min(avgPerDay / 2, 1.0),
        insight: 'Haces ${avgPerDay.toStringAsFixed(1)} compras pequeñas (<\$50) por día en promedio.',
        recommendation: 'Estas compras suman \$${totalSmall.toStringAsFixed(0)}/mes. '
                       'Considera la regla de esperar 24h antes de compras no esenciales.',
        affectedAmount: totalSmall,
        potentialSavings: totalSmall * 0.3,
      );
    }

    return null;
  }
}

class DetectedPattern {
  final SpendingPattern pattern;
  final double confidence;
  final String insight;
  final String recommendation;
  final double affectedAmount;
  final double? potentialSavings;
  final List<String>? details;

  const DetectedPattern({
    required this.pattern,
    required this.confidence,
    required this.insight,
    required this.recommendation,
    required this.affectedAmount,
    this.potentialSavings,
    this.details,
  });
}

Análisis de Regla 50/30/20

class BudgetRuleAnalyzer {
  /// Analiza cumplimiento de regla 50/30/20
  static BudgetRuleAnalysis analyze({
    required List<Transaction> transactions,
    required Map<String, Category> categories,
  }) {
    double ingresos = 0, necesidades = 0, deseos = 0, ahorro = 0;

    for (final t in transactions) {
      if (t.tipo == TransactionType.ingreso) {
        ingresos += t.monto;
      } else {
        final category = categories[t.categoriaId];
        if (category == null) continue;

        switch (category.clasificacion) {
          case ExpenseClassification.fijo:
            necesidades += t.monto;
          case ExpenseClassification.variable:
            deseos += t.monto;
          case ExpenseClassification.ahorro:
            ahorro += t.monto;
        }
      }
    }

    return BudgetRuleAnalysis(
      ingresos: ingresos,
      necesidades: BudgetRuleCategory(
        actual: necesidades,
        porcentaje: ingresos > 0 ? (necesidades / ingresos) * 100 : 0,
        objetivo: 50,
        nombre: 'Necesidades',
        descripcion: 'Gastos fijos esenciales (vivienda, servicios, deudas)',
      ),
      deseos: BudgetRuleCategory(
        actual: deseos,
        porcentaje: ingresos > 0 ? (deseos / ingresos) * 100 : 0,
        objetivo: 30,
        nombre: 'Deseos',
        descripcion: 'Gastos variables opcionales (entretenimiento, restaurantes)',
      ),
      ahorro: BudgetRuleCategory(
        actual: ahorro,
        porcentaje: ingresos > 0 ? (ahorro / ingresos) * 100 : 0,
        objetivo: 20,
        nombre: 'Ahorro',
        descripcion: 'Inversiones y fondo de emergencia',
      ),
    );
  }
}

class BudgetRuleAnalysis {
  final double ingresos;
  final BudgetRuleCategory necesidades;
  final BudgetRuleCategory deseos;
  final BudgetRuleCategory ahorro;

  const BudgetRuleAnalysis({
    required this.ingresos,
    required this.necesidades,
    required this.deseos,
    required this.ahorro,
  });

  /// Verifica si cumple la regla
  bool get cumpleRegla =>
    necesidades.cumple && deseos.cumple && ahorro.cumple;

  /// Genera recomendaciones
  List<String> get recomendaciones {
    final recs = <String>[];

    if (!necesidades.cumple) {
      final exceso = necesidades.porcentaje - 50;
      recs.add('Tus gastos fijos representan ${necesidades.porcentaje.toStringAsFixed(0)}% '
               '(${exceso.toStringAsFixed(0)}% más del objetivo). '
               'Considera refinanciar deudas o buscar alternativas más económicas.');
    }

    if (!deseos.cumple) {
      final exceso = deseos.porcentaje - 30;
      recs.add('Tus gastos variables representan ${deseos.porcentaje.toStringAsFixed(0)}% '
               '(${exceso.toStringAsFixed(0)}% más del objetivo). '
               'Aquí hay oportunidades de ahorro más fáciles.');
    }

    if (!ahorro.cumple) {
      final faltante = 20 - ahorro.porcentaje;
      final montoFaltante = (ingresos * faltante / 100);
      recs.add('Solo estás ahorrando ${ahorro.porcentaje.toStringAsFixed(0)}% '
               '(objetivo: 20%). Intenta ahorrar \$${montoFaltante.toStringAsFixed(0)} más al mes.');
    }

    if (cumpleRegla) {
      recs.add('¡Excelente! Tu distribución de gastos cumple la regla 50/30/20.');
    }

    return recs;
  }
}

class BudgetRuleCategory {
  final double actual;
  final double porcentaje;
  final double objetivo;
  final String nombre;
  final String descripcion;

  const BudgetRuleCategory({
    required this.actual,
    required this.porcentaje,
    required this.objetivo,
    required this.nombre,
    required this.descripcion,
  });

  bool get cumple {
    if (nombre == 'Ahorro') {
      return porcentaje >= objetivo; // Ahorro debe ser >= 20%
    }
    return porcentaje <= objetivo; // Necesidades y deseos deben ser <= objetivo
  }

  double get diferencia => porcentaje - objetivo;
}

Visualizaciones Recomendadas

1. Treemap de Gastos

Muestra jerarquía: Categoría > Subcategoría > Transacciones
Tamaño = Monto gastado
Color = Cambio vs mes anterior (verde=↓, rojo=↑)

2. Heatmap Semanal

Filas = Categorías
Columnas = Días de la semana
Color = Intensidad de gasto
Muestra patrones como "Weekend Spender"

3. Gauge de Regla 50/30/20

3 medidores semicirculares:
- Necesidades: Objetivo 50%, actual X%
- Deseos: Objetivo 30%, actual Y%
- Ahorro: Objetivo 20%, actual Z%
Color = Verde si cumple, Rojo si no

4. Timeline de Anomalías

Línea temporal con:
- Puntos normales (pequeños, gris)
- Anomalías (grandes, rojo)
- Hover muestra detalles

Notas de Implementación

  1. Mínimo de datos - Necesita 2+ meses para detectar patrones confiables
  2. Actualización - Recalcular análisis diariamente
  3. Persistencia - Guardar patrones detectados para no recalcular
  4. Privacidad - No enviar datos detallados a la nube
  5. UX - Mostrar insights gradualmente, no abrumar al usuario
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.