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:
2025-07-15 11:20:28 -03:00
parent 640b7d1ece
commit e1e23f5315
20 changed files with 592 additions and 122 deletions

View File

@@ -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);
}
}
}