Feat(holidays): Implement database-backed holiday detection system
- Adds a new `MercadosFeriados` table to the database to persist market holidays.
- Implements `HolidayDataFetcher` to update holidays weekly from Finnhub API.
- Implements `IHolidayService` with in-memory caching to check for holidays efficiently.
- Worker service now skips fetcher execution on market holidays.
- Adds a new API endpoint `/api/mercados/es-feriado/{mercado}`.
- Integrates a non-blocking holiday alert into the `BolsaLocalWidget`."
			
			
This commit is contained in:
		| @@ -14,22 +14,23 @@ namespace Mercados.Worker | ||||
|         private readonly ILogger<DataFetchingService> _logger; | ||||
|         private readonly IServiceProvider _serviceProvider; | ||||
|         private readonly TimeZoneInfo _argentinaTimeZone; | ||||
|  | ||||
|         // Almacenamos las expresiones Cron parseadas para no tener que hacerlo en cada ciclo. | ||||
|          | ||||
|         // Expresiones Cron | ||||
|         private readonly CronExpression _agroSchedule; | ||||
|         private readonly CronExpression _bcrSchedule; | ||||
|         private readonly CronExpression _bolsasSchedule; | ||||
|         private readonly CronExpression _holidaysSchedule; | ||||
|  | ||||
|         // Almacenamos la próxima ejecución calculada para cada tarea. | ||||
|         // Próximas ejecuciones | ||||
|         private DateTime? _nextAgroRun; | ||||
|         private DateTime? _nextBcrRun; | ||||
|         private DateTime? _nextBolsasRun; | ||||
|         private DateTime? _nextHolidaysRun; | ||||
|  | ||||
|         // Diccionario para rastrear la hora de la última alerta ENVIADA por cada tarea. | ||||
|         private readonly Dictionary<string, DateTime> _lastAlertSent = new(); | ||||
|         // Definimos el período de "silencio" para las alertas (ej. 4 horas). | ||||
|         private readonly TimeSpan _alertSilencePeriod = TimeSpan.FromHours(4); | ||||
|  | ||||
|         // Eliminamos IHolidayService del constructor | ||||
|         public DataFetchingService( | ||||
|             ILogger<DataFetchingService> logger, | ||||
|             IServiceProvider serviceProvider, | ||||
| @@ -55,6 +56,7 @@ namespace Mercados.Worker | ||||
|             _agroSchedule = CronExpression.Parse(configuration["Schedules:MercadoAgroganadero"]!); | ||||
|             _bcrSchedule = CronExpression.Parse(configuration["Schedules:BCR"]!); | ||||
|             _bolsasSchedule = CronExpression.Parse(configuration["Schedules:Bolsas"]!); | ||||
|             _holidaysSchedule = CronExpression.Parse(configuration["Schedules:Holidays"]!); | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
| @@ -64,44 +66,86 @@ namespace Mercados.Worker | ||||
|         { | ||||
|             _logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now); | ||||
|  | ||||
|             // Ejecutamos una vez al inicio para tener datos frescos inmediatamente. | ||||
|             //await RunAllFetchersAsync(stoppingToken); | ||||
|             // La ejecución inicial sigue comentada | ||||
|             // await RunAllFetchersAsync(stoppingToken); | ||||
|  | ||||
|             // Calculamos las primeras ejecuciones programadas al arrancar. | ||||
|             var utcNow = DateTime.UtcNow; | ||||
|             _nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||
|             _nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||
|             _nextBolsasRun = _bolsasSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||
|             _nextHolidaysRun = _holidaysSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||
|  | ||||
|             // Usamos un PeriodicTimer que "despierta" cada 30 segundos para revisar si hay tareas pendientes. | ||||
|             // Un intervalo más corto aumenta la precisión del disparo de las tareas. | ||||
|             // Usamos un PeriodicTimer que "despierta" cada 30 segundos. | ||||
|             using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30)); | ||||
|  | ||||
|             while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) | ||||
|             { | ||||
|                 utcNow = DateTime.UtcNow; | ||||
|                 var nowInArgentina = TimeZoneInfo.ConvertTimeFromUtc(utcNow, _argentinaTimeZone); | ||||
|  | ||||
|                 // Comprobamos si ha llegado el momento de la próxima ejecución para cada tarea. | ||||
|                 // Tarea de actualización de Feriados (semanal) | ||||
|                 if (_nextHolidaysRun.HasValue && utcNow >= _nextHolidaysRun.Value) | ||||
|                 { | ||||
|                     _logger.LogInformation("Ejecutando tarea semanal de actualización de feriados."); | ||||
|                     await RunFetcherByNameAsync("Holidays", stoppingToken); | ||||
|                     _nextHolidaysRun = _holidaysSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||
|                 } | ||||
|  | ||||
|                 // Tarea de Mercado Agroganadero (diaria) | ||||
|                 if (_nextAgroRun.HasValue && utcNow >= _nextAgroRun.Value) | ||||
|                 { | ||||
|                     await RunFetcherByNameAsync("MercadoAgroganadero", stoppingToken); | ||||
|                     // Inmediatamente después de ejecutar, calculamos la SIGUIENTE ocurrencia. | ||||
|                     // Comprueba si NO es feriado en Argentina para ejecutar | ||||
|                     if (!await IsMarketHolidayAsync("BA", nowInArgentina)) | ||||
|                     { | ||||
|                         await RunFetcherByNameAsync("MercadoAgroganadero", stoppingToken); | ||||
|                     } | ||||
|                     else { _logger.LogInformation("Ejecución de MercadoAgroganadero omitida por ser feriado."); } | ||||
|  | ||||
|                     // Recalcula la próxima ejecución sin importar si corrió o fue feriado | ||||
|                     _nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||
|                 } | ||||
|  | ||||
|                 // Tarea de Granos BCR (diaria) | ||||
|                 if (_nextBcrRun.HasValue && utcNow >= _nextBcrRun.Value) | ||||
|                 { | ||||
|                     await RunFetcherByNameAsync("BCR", stoppingToken); | ||||
|                     if (!await IsMarketHolidayAsync("BA", nowInArgentina)) | ||||
|                     { | ||||
|                         await RunFetcherByNameAsync("BCR", stoppingToken); | ||||
|                     } | ||||
|                     else { _logger.LogInformation("Ejecución de BCR omitida por ser feriado."); } | ||||
|  | ||||
|                     _nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||
|                 } | ||||
|  | ||||
|                 // Tarea de Bolsas (recurrente) | ||||
|                 if (_nextBolsasRun.HasValue && utcNow >= _nextBolsasRun.Value) | ||||
|                 { | ||||
|                     _logger.LogInformation("Ventana de ejecución para Bolsas. Iniciando en paralelo..."); | ||||
|                     await Task.WhenAll( | ||||
|                         RunFetcherByNameAsync("YahooFinance", stoppingToken), | ||||
|                         RunFetcherByNameAsync("Finnhub", stoppingToken) | ||||
|                     ); | ||||
|                     _logger.LogInformation("Ventana de ejecución para Bolsas detectada."); | ||||
|  | ||||
|                     var bolsaTasks = new List<Task>(); | ||||
|  | ||||
|                     // Comprueba el mercado local (Argentina) | ||||
|                     if (!await IsMarketHolidayAsync("BA", nowInArgentina)) | ||||
|                     { | ||||
|                         bolsaTasks.Add(RunFetcherByNameAsync("YahooFinance", stoppingToken)); | ||||
|                     } | ||||
|                     else { _logger.LogInformation("Ejecución de YahooFinance (Mercado Local) omitida por ser feriado."); } | ||||
|  | ||||
|                     // Comprueba el mercado de EEUU | ||||
|                     if (!await IsMarketHolidayAsync("US", nowInArgentina)) | ||||
|                     { | ||||
|                         bolsaTasks.Add(RunFetcherByNameAsync("Finnhub", stoppingToken)); | ||||
|                     } | ||||
|                     else { _logger.LogInformation("Ejecución de Finnhub (Mercado EEUU) omitida por ser feriado."); } | ||||
|  | ||||
|                     // Si hay alguna tarea para ejecutar, las lanza en paralelo | ||||
|                     if (bolsaTasks.Any()) | ||||
|                     { | ||||
|                         _logger.LogInformation("Iniciando {Count} fetcher(s) de bolsa en paralelo...", bolsaTasks.Count); | ||||
|                         await Task.WhenAll(bolsaTasks); | ||||
|                     } | ||||
|  | ||||
|                     _nextBolsasRun = _bolsasSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||
|                 } | ||||
|             } | ||||
| @@ -179,5 +223,14 @@ namespace Mercados.Worker | ||||
|         } | ||||
|  | ||||
|         #endregion | ||||
|  | ||||
|         // Creamos una única función para comprobar feriados que obtiene el servicio | ||||
|         // desde un scope. | ||||
|         private async Task<bool> IsMarketHolidayAsync(string marketCode, DateTime date) | ||||
|         { | ||||
|             using var scope = _serviceProvider.CreateScope(); | ||||
|             var holidayService = scope.ServiceProvider.GetRequiredService<IHolidayService>(); | ||||
|             return await holidayService.IsMarketHolidayAsync(marketCode, date); | ||||
|         } | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user