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-projection66
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 proyectar el flujo de caja del usuario y predecir su situación financiera futura.
PASADO PRESENTE FUTURO
────────────────────┼─────────────────────────►
│
Balance │ Proyección
Acumulado │ Cash Flow
(datos reales) │ (predicción)
│
Hoyclass 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
}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,
}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;
}
}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,
});
}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',
),
),
],
),
),
);
}
}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 proyectadoBalance 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."1e99f86
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.