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.
77
Does it follow best practices?
If you maintain this skill, you can automatically optimize it using the tessl CLI to improve its score:
npx tessl skill review --optimize ./path/to/skillValidation for skill structure
Conocimiento especializado para análisis profundo de patrones de gasto, detección de anomalías, y generación de reportes inteligentes.
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
}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;
}
}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,
});
}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;
}Muestra jerarquía: Categoría > Subcategoría > Transacciones
Tamaño = Monto gastado
Color = Cambio vs mes anterior (verde=↓, rojo=↑)Filas = Categorías
Columnas = Días de la semana
Color = Intensidad de gasto
Muestra patrones como "Weekend Spender"3 medidores semicirculares:
- Necesidades: Objetivo 50%, actual X%
- Deseos: Objetivo 30%, actual Y%
- Ahorro: Objetivo 20%, actual Z%
Color = Verde si cumple, Rojo si noLínea temporal con:
- Puntos normales (pequeños, gris)
- Anomalías (grandes, rojo)
- Hover muestra detalles1e99f86
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.