- 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`."
236 lines
11 KiB
C#
236 lines
11 KiB
C#
using Cronos;
|
|
using Mercados.Infrastructure.DataFetchers;
|
|
using Mercados.Infrastructure.Services;
|
|
using Microsoft.Extensions.Configuration;
|
|
|
|
namespace Mercados.Worker
|
|
{
|
|
/// <summary>
|
|
/// Servicio de fondo que orquesta la obtención de datos de diversas fuentes
|
|
/// de forma programada y periódica.
|
|
/// </summary>
|
|
public class DataFetchingService : BackgroundService
|
|
{
|
|
private readonly ILogger<DataFetchingService> _logger;
|
|
private readonly IServiceProvider _serviceProvider;
|
|
private readonly TimeZoneInfo _argentinaTimeZone;
|
|
|
|
// Expresiones Cron
|
|
private readonly CronExpression _agroSchedule;
|
|
private readonly CronExpression _bcrSchedule;
|
|
private readonly CronExpression _bolsasSchedule;
|
|
private readonly CronExpression _holidaysSchedule;
|
|
|
|
// Próximas ejecuciones
|
|
private DateTime? _nextAgroRun;
|
|
private DateTime? _nextBcrRun;
|
|
private DateTime? _nextBolsasRun;
|
|
private DateTime? _nextHolidaysRun;
|
|
|
|
private readonly Dictionary<string, DateTime> _lastAlertSent = new();
|
|
private readonly TimeSpan _alertSilencePeriod = TimeSpan.FromHours(4);
|
|
|
|
// Eliminamos IHolidayService del constructor
|
|
public DataFetchingService(
|
|
ILogger<DataFetchingService> logger,
|
|
IServiceProvider serviceProvider,
|
|
IConfiguration configuration)
|
|
{
|
|
_logger = logger;
|
|
_serviceProvider = serviceProvider;
|
|
|
|
// Se define explícitamente la zona horaria de Argentina.
|
|
try
|
|
{
|
|
_argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires");
|
|
}
|
|
catch (TimeZoneNotFoundException)
|
|
{
|
|
_argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time");
|
|
}
|
|
|
|
// Parseamos las expresiones Cron UNA SOLA VEZ, en el constructor.
|
|
// Si una expresión es inválida o nula, el servicio fallará al iniciar,
|
|
// lo cual es un comportamiento deseable para alertar de una mala configuración.
|
|
// El '!' le dice al compilador que confiamos que estos valores no serán nulos.
|
|
_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>
|
|
/// Método principal del servicio. Se ejecuta una vez cuando el servicio arranca.
|
|
/// </summary>
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
_logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now);
|
|
|
|
// 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.
|
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
|
|
|
|
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
|
|
{
|
|
utcNow = DateTime.UtcNow;
|
|
var nowInArgentina = TimeZoneInfo.ConvertTimeFromUtc(utcNow, _argentinaTimeZone);
|
|
|
|
// 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)
|
|
{
|
|
// 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)
|
|
{
|
|
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 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ejecuta un fetcher específico por su nombre, gestionando el scope de DI y las notificaciones.
|
|
/// </summary>
|
|
private async Task RunFetcherByNameAsync(string sourceName, CancellationToken stoppingToken)
|
|
{
|
|
if (stoppingToken.IsCancellationRequested) return;
|
|
|
|
_logger.LogInformation("Intentando ejecutar fetcher: {sourceName}", sourceName);
|
|
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var fetchers = scope.ServiceProvider.GetRequiredService<IEnumerable<IDataFetcher>>();
|
|
var fetcher = fetchers.FirstOrDefault(f => f.SourceName.Equals(sourceName, StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (fetcher != null)
|
|
{
|
|
var (success, message) = await fetcher.FetchDataAsync();
|
|
if (!success)
|
|
{
|
|
var errorMessage = $"Falló la ejecución del fetcher {sourceName}: {message}";
|
|
_logger.LogError(errorMessage);
|
|
|
|
if (ShouldSendAlert(sourceName))
|
|
{
|
|
var notifier = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
|
await notifier.SendFailureAlertAsync($"Fallo Crítico en el Fetcher: {sourceName}", errorMessage, DateTime.UtcNow);
|
|
_lastAlertSent[sourceName] = DateTime.UtcNow;
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Fallo repetido para {sourceName}. Alerta silenciada temporalmente.", sourceName);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("No se encontró un fetcher con el nombre: {sourceName}", sourceName);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ejecuta todos los fetchers en paralelo al iniciar el servicio.
|
|
/// </summary>
|
|
private async Task RunAllFetchersAsync(CancellationToken stoppingToken)
|
|
{
|
|
_logger.LogInformation("Ejecutando todos los fetchers al iniciar en paralelo...");
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var fetchers = scope.ServiceProvider.GetRequiredService<IEnumerable<IDataFetcher>>();
|
|
|
|
var tasks = fetchers.Select(fetcher => RunFetcherByNameAsync(fetcher.SourceName, stoppingToken));
|
|
|
|
await Task.WhenAll(tasks);
|
|
|
|
_logger.LogInformation("Ejecución inicial de todos los fetchers completada.");
|
|
}
|
|
|
|
#region Funciones de Ayuda para la Planificación
|
|
|
|
/// <summary>
|
|
/// Determina si se debe enviar una alerta o si está en período de silencio.
|
|
/// </summary>
|
|
private bool ShouldSendAlert(string taskName)
|
|
{
|
|
if (!_lastAlertSent.ContainsKey(taskName))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var lastAlertTime = _lastAlertSent[taskName];
|
|
return DateTime.UtcNow.Subtract(lastAlertTime) > _alertSilencePeriod;
|
|
}
|
|
|
|
#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);
|
|
}
|
|
}
|
|
} |