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:
		| @@ -1,5 +1,6 @@ | ||||
| using Mercados.Core.Entities; | ||||
| using Mercados.Infrastructure.Persistence.Repositories; | ||||
| using Mercados.Infrastructure.Services; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
|  | ||||
| namespace Mercados.Api.Controllers | ||||
| @@ -11,6 +12,7 @@ namespace Mercados.Api.Controllers | ||||
|         private readonly ICotizacionBolsaRepository _bolsaRepo; | ||||
|         private readonly ICotizacionGranoRepository _granoRepo; | ||||
|         private readonly ICotizacionGanadoRepository _ganadoRepo; | ||||
|         private readonly IHolidayService _holidayService; | ||||
|         private readonly ILogger<MercadosController> _logger; | ||||
|  | ||||
|         // Inyectamos TODOS los repositorios que necesita el controlador. | ||||
| @@ -18,11 +20,13 @@ namespace Mercados.Api.Controllers | ||||
|             ICotizacionBolsaRepository bolsaRepo, | ||||
|             ICotizacionGranoRepository granoRepo, | ||||
|             ICotizacionGanadoRepository ganadoRepo, | ||||
|             IHolidayService holidayService, | ||||
|             ILogger<MercadosController> logger) | ||||
|         { | ||||
|             _bolsaRepo = bolsaRepo; | ||||
|             _granoRepo = granoRepo; | ||||
|             _ganadoRepo = ganadoRepo; | ||||
|             _holidayService = holidayService; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
| @@ -147,5 +151,30 @@ namespace Mercados.Api.Controllers | ||||
|                 return StatusCode(500, "Ocurrió un error interno en el servidor."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         [HttpGet("es-feriado/{mercado}")] | ||||
|         [ProducesResponseType(typeof(bool), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||
|         public async Task<IActionResult> IsMarketHoliday(string mercado) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 // Usamos la fecha actual en la zona horaria de Argentina | ||||
|                 TimeZoneInfo argentinaTimeZone; | ||||
|                 try { argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires"); } | ||||
|                 catch { argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time"); } | ||||
|  | ||||
|                 var todayInArgentina = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, argentinaTimeZone); | ||||
|  | ||||
|                 var esFeriado = await _holidayService.IsMarketHolidayAsync(mercado.ToUpper(), todayInArgentina); | ||||
|                 return Ok(esFeriado); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al comprobar si es feriado para el mercado {Mercado}.", mercado); | ||||
|                 // Si hay un error, devolvemos 'false' para no bloquear la UI innecesariamente. | ||||
|                 return Ok(false); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -5,6 +5,7 @@ using Mercados.Infrastructure.Persistence; | ||||
| using Mercados.Infrastructure.Persistence.Repositories; | ||||
| using Mercados.Api.Utils; | ||||
| using Microsoft.AspNetCore.HttpOverrides; | ||||
| using Mercados.Infrastructure.Services; | ||||
|  | ||||
| var builder = WebApplication.CreateBuilder(args); | ||||
|  | ||||
| @@ -32,6 +33,10 @@ builder.Services.AddScoped<ICotizacionGanadoRepository, CotizacionGanadoReposito | ||||
| builder.Services.AddScoped<ICotizacionGranoRepository, CotizacionGranoRepository>(); | ||||
| builder.Services.AddScoped<ICotizacionBolsaRepository, CotizacionBolsaRepository>(); | ||||
| builder.Services.AddScoped<IFuenteDatoRepository, FuenteDatoRepository>(); | ||||
| builder.Services.AddScoped<IMercadoFeriadoRepository, MercadoFeriadoRepository>(); | ||||
| builder.Services.AddMemoryCache(); | ||||
| builder.Services.AddScoped<IMercadoFeriadoRepository, MercadoFeriadoRepository>(); | ||||
| builder.Services.AddScoped<IHolidayService, FinnhubHolidayService>(); | ||||
|  | ||||
| // Configuración de FluentMigrator (perfecto) | ||||
| builder.Services | ||||
|   | ||||
							
								
								
									
										10
									
								
								src/Mercados.Core/Entities/MercadoFeriado.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/Mercados.Core/Entities/MercadoFeriado.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| namespace Mercados.Core.Entities | ||||
| { | ||||
|     public class MercadoFeriado | ||||
|     { | ||||
|         public long Id { get; set; } | ||||
|         public string CodigoMercado { get; set; } = string.Empty; // "US" o "BA" | ||||
|         public DateTime Fecha { get; set; } | ||||
|         public string? Nombre { get; set; } // Nombre del feriado, si la API lo provee | ||||
|     } | ||||
| } | ||||
| @@ -2,7 +2,7 @@ using FluentMigrator; | ||||
| 
 | ||||
| namespace Mercados.Database.Migrations | ||||
| { | ||||
|     [Migration(20240702133000)] | ||||
|     [Migration(20250702133000)] | ||||
|     public class AddNameToStocks : Migration | ||||
|     { | ||||
|         public override void Up() | ||||
| @@ -0,0 +1,31 @@ | ||||
| using FluentMigrator; | ||||
|  | ||||
| namespace Mercados.Database.Migrations | ||||
| { | ||||
|     [Migration(20250714150000)] | ||||
|     public class CreateMercadoFeriadoTable : Migration | ||||
|     { | ||||
|         private const string TableName = "MercadosFeriados"; | ||||
|          | ||||
|         public override void Up() | ||||
|         { | ||||
|             Create.Table(TableName) | ||||
|                 .WithColumn("Id").AsInt64().PrimaryKey().Identity() | ||||
|                 .WithColumn("CodigoMercado").AsString(10).NotNullable() | ||||
|                 .WithColumn("Fecha").AsDate().NotNullable() // Usamos AsDate() para guardar solo la fecha | ||||
|                 .WithColumn("Nombre").AsString(255).Nullable(); | ||||
|  | ||||
|             // Creamos un índice para buscar rápidamente por mercado y fecha | ||||
|             Create.Index($"IX_{TableName}_CodigoMercado_Fecha") | ||||
|                 .OnTable(TableName) | ||||
|                 .OnColumn("CodigoMercado").Ascending() | ||||
|                 .OnColumn("Fecha").Ascending() | ||||
|                 .WithOptions().Unique(); | ||||
|         } | ||||
|  | ||||
|         public override void Down() | ||||
|         { | ||||
|             Delete.Table(TableName); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,84 @@ | ||||
| using Microsoft.Extensions.Configuration; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using System.Net.Http.Json; | ||||
| using Mercados.Core.Entities; | ||||
| using Mercados.Infrastructure.Persistence.Repositories; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace Mercados.Infrastructure.DataFetchers | ||||
| { | ||||
|     // Añadimos las clases DTO necesarias para deserializar la respuesta de Finnhub | ||||
|     public class MarketHolidayResponse | ||||
|     { | ||||
|         [JsonPropertyName("data")] | ||||
|         public List<MarketHoliday>? Data { get; set; } | ||||
|     } | ||||
|     public class MarketHoliday | ||||
|     { | ||||
|         [JsonPropertyName("at")] | ||||
|         public string? At { get; set; } | ||||
|  | ||||
|         [JsonIgnore] | ||||
|         public DateOnly Date => DateOnly.FromDateTime(DateTime.Parse(At!)); | ||||
|     } | ||||
|  | ||||
|     public class HolidayDataFetcher : IDataFetcher | ||||
|     { | ||||
|         public string SourceName => "Holidays"; | ||||
|         private readonly string[] _marketCodes = { "US", "BA" }; | ||||
|  | ||||
|         private readonly IHttpClientFactory _httpClientFactory; | ||||
|         private readonly IMercadoFeriadoRepository _feriadoRepository; | ||||
|         private readonly IConfiguration _configuration; | ||||
|         private readonly ILogger<HolidayDataFetcher> _logger; | ||||
|  | ||||
|         public HolidayDataFetcher( | ||||
|             IHttpClientFactory httpClientFactory, | ||||
|             IMercadoFeriadoRepository feriadoRepository, | ||||
|             IConfiguration configuration, | ||||
|             ILogger<HolidayDataFetcher> logger) | ||||
|         { | ||||
|             _httpClientFactory = httpClientFactory; | ||||
|             _feriadoRepository = feriadoRepository; | ||||
|             _configuration = configuration; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task<(bool Success, string Message)> FetchDataAsync() | ||||
|         { | ||||
|             _logger.LogInformation("Iniciando actualización de feriados desde Finnhub."); | ||||
|             var apiKey = _configuration["ApiKeys:Finnhub"]; | ||||
|             if (string.IsNullOrEmpty(apiKey)) return (false, "API Key de Finnhub no configurada."); | ||||
|  | ||||
|             var client = _httpClientFactory.CreateClient("FinnhubDataFetcher"); | ||||
|  | ||||
|             foreach (var marketCode in _marketCodes) | ||||
|             { | ||||
|                 try | ||||
|                 { | ||||
|                     var apiUrl = $"https://finnhub.io/api/v1/stock/market-holiday?exchange={marketCode}&token={apiKey}"; | ||||
|                     // Ahora la deserialización funcionará porque la clase existe | ||||
|                     var response = await client.GetFromJsonAsync<MarketHolidayResponse>(apiUrl); | ||||
|  | ||||
|                     if (response?.Data != null) | ||||
|                     { | ||||
|                         var nuevosFeriados = response.Data.Select(h => new MercadoFeriado | ||||
|                         { | ||||
|                             CodigoMercado = marketCode, | ||||
|                             Fecha = h.Date.ToDateTime(TimeOnly.MinValue), | ||||
|                             Nombre = "Feriado Bursátil" | ||||
|                         }).ToList(); | ||||
|  | ||||
|                         await _feriadoRepository.ReemplazarFeriadosPorMercadoAsync(marketCode, nuevosFeriados); | ||||
|                         _logger.LogInformation("Feriados para {MarketCode} actualizados exitosamente: {Count} registros.", marketCode, nuevosFeriados.Count); | ||||
|                     } | ||||
|                 } | ||||
|                 catch (Exception ex) | ||||
|                 { | ||||
|                     _logger.LogError(ex, "Falló la obtención de feriados para {MarketCode}.", marketCode); | ||||
|                 } | ||||
|             } | ||||
|             return (true, "Actualización de feriados completada."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -9,6 +9,7 @@ | ||||
|     <PackageReference Include="Dapper" Version="2.1.66" /> | ||||
|     <PackageReference Include="MailKit" Version="4.13.0" /> | ||||
|     <PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.7" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" /> | ||||
|     <PackageReference Include="System.Text.Encoding.CodePages" Version="9.0.6" /> | ||||
|   | ||||
| @@ -0,0 +1,10 @@ | ||||
| using Mercados.Core.Entities; | ||||
|  | ||||
| namespace Mercados.Infrastructure.Persistence.Repositories | ||||
| { | ||||
|     public interface IMercadoFeriadoRepository : IBaseRepository | ||||
|     { | ||||
|         Task<IEnumerable<MercadoFeriado>> ObtenerPorMercadoYAnioAsync(string codigoMercado, int anio); | ||||
|         Task ReemplazarFeriadosPorMercadoAsync(string codigoMercado, IEnumerable<MercadoFeriado> nuevosFeriados); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,56 @@ | ||||
| using Dapper; | ||||
| using Mercados.Core.Entities; | ||||
| using System.Data; | ||||
|  | ||||
| namespace Mercados.Infrastructure.Persistence.Repositories | ||||
| { | ||||
|     public class MercadoFeriadoRepository : IMercadoFeriadoRepository | ||||
|     { | ||||
|         private readonly IDbConnectionFactory _connectionFactory; | ||||
|  | ||||
|         public MercadoFeriadoRepository(IDbConnectionFactory connectionFactory) | ||||
|         { | ||||
|             _connectionFactory = connectionFactory; | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<MercadoFeriado>> ObtenerPorMercadoYAnioAsync(string codigoMercado, int anio) | ||||
|         { | ||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||
|             const string sql = @" | ||||
|                 SELECT * FROM MercadosFeriados  | ||||
|                 WHERE CodigoMercado = @CodigoMercado AND YEAR(Fecha) = @Anio;"; | ||||
|             return await connection.QueryAsync<MercadoFeriado>(sql, new { CodigoMercado = codigoMercado, Anio = anio }); | ||||
|         } | ||||
|  | ||||
|         public async Task ReemplazarFeriadosPorMercadoAsync(string codigoMercado, IEnumerable<MercadoFeriado> nuevosFeriados) | ||||
|         { | ||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||
|             connection.Open(); | ||||
|             using var transaction = connection.BeginTransaction(); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 // Borramos todos los feriados del año en curso para ese mercado | ||||
|                 var anio = nuevosFeriados.FirstOrDefault()?.Fecha.Year; | ||||
|                 if (anio.HasValue) | ||||
|                 { | ||||
|                     const string deleteSql = "DELETE FROM MercadosFeriados WHERE CodigoMercado = @CodigoMercado AND YEAR(Fecha) = @Anio;"; | ||||
|                     await connection.ExecuteAsync(deleteSql, new { CodigoMercado = codigoMercado, Anio = anio.Value }, transaction); | ||||
|                 } | ||||
|  | ||||
|                 // Insertamos los nuevos | ||||
|                 const string insertSql = @" | ||||
|                     INSERT INTO MercadosFeriados (CodigoMercado, Fecha, Nombre)  | ||||
|                     VALUES (@CodigoMercado, @Fecha, @Nombre);"; | ||||
|                 await connection.ExecuteAsync(insertSql, nuevosFeriados, transaction); | ||||
|  | ||||
|                 transaction.Commit(); | ||||
|             } | ||||
|             catch | ||||
|             { | ||||
|                 transaction.Rollback(); | ||||
|                 throw; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,50 @@ | ||||
| using Mercados.Core.Entities; | ||||
| using Mercados.Infrastructure.Persistence.Repositories; | ||||
| using Microsoft.Extensions.Caching.Memory; | ||||
| using Microsoft.Extensions.Logging; | ||||
|  | ||||
| namespace Mercados.Infrastructure.Services | ||||
| { | ||||
|     public class FinnhubHolidayService : IHolidayService | ||||
|     { | ||||
|         private readonly IMercadoFeriadoRepository _feriadoRepository; | ||||
|         private readonly IMemoryCache _cache; | ||||
|         private readonly ILogger<FinnhubHolidayService> _logger; | ||||
|  | ||||
|         public FinnhubHolidayService( | ||||
|             IMercadoFeriadoRepository feriadoRepository, | ||||
|             IMemoryCache cache, | ||||
|             ILogger<FinnhubHolidayService> logger) | ||||
|         { | ||||
|             _feriadoRepository = feriadoRepository; | ||||
|             _cache = cache; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task<bool> IsMarketHolidayAsync(string marketCode, DateTime date) | ||||
|         { | ||||
|             var dateOnly = DateOnly.FromDateTime(date); | ||||
|             var cacheKey = $"holidays_{marketCode}_{date.Year}"; | ||||
|  | ||||
|             if (!_cache.TryGetValue(cacheKey, out HashSet<DateOnly>? holidays)) | ||||
|             { | ||||
|                 _logger.LogInformation("Caché de feriados no encontrada para {MarketCode}. Obteniendo desde la base de datos.", marketCode); | ||||
|                  | ||||
|                 try | ||||
|                 { | ||||
|                     // Llama a NUESTRA base de datos, no a la API externa. | ||||
|                     var feriadosDesdeDb = await _feriadoRepository.ObtenerPorMercadoYAnioAsync(marketCode, date.Year); | ||||
|                     holidays = feriadosDesdeDb.Select(h => DateOnly.FromDateTime(h.Fecha)).ToHashSet(); | ||||
|                     _cache.Set(cacheKey, holidays, TimeSpan.FromHours(24)); | ||||
|                 } | ||||
|                 catch (Exception ex) | ||||
|                 { | ||||
|                     _logger.LogError(ex, "No se pudo obtener la lista de feriados para {MarketCode} desde la DB.", marketCode); | ||||
|                     return false; // Asumimos que no es feriado si la DB falla | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return holidays?.Contains(dateOnly) ?? false; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										16
									
								
								src/Mercados.Infrastructure/Services/IHolidayService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/Mercados.Infrastructure/Services/IHolidayService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| namespace Mercados.Infrastructure.Services | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Define un servicio para consultar si una fecha es feriado para un mercado. | ||||
|     /// </summary> | ||||
|     public interface IHolidayService | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// Comprueba si la fecha dada es un feriado bursátil para el mercado especificado. | ||||
|         /// </summary> | ||||
|         /// <param name="marketCode">El código del mercado (ej. "BA" para Buenos Aires, "US" para EEUU).</param> | ||||
|         /// <param name="date">La fecha a comprobar.</param> | ||||
|         /// <returns>True si es feriado, false si no lo es.</returns> | ||||
|         Task<bool> IsMarketHolidayAsync(string marketCode, DateTime date); | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -31,6 +31,13 @@ IHost host = Host.CreateDefaultBuilder(args) | ||||
|         services.AddHttpClient("BcrDataFetcher").AddPolicyHandler(GetRetryPolicy()); | ||||
|         services.AddHttpClient("FinnhubDataFetcher").AddPolicyHandler(GetRetryPolicy()); | ||||
|  | ||||
|         // Servicio de caché en memoria de .NET | ||||
|         services.AddMemoryCache(); | ||||
|         // Registramos nuestro nuevo servicio de feriados | ||||
|         services.AddScoped<IHolidayService, FinnhubHolidayService>(); | ||||
|         services.AddScoped<IDataFetcher, HolidayDataFetcher>(); | ||||
|         services.AddScoped<IMercadoFeriadoRepository, MercadoFeriadoRepository>(); | ||||
|  | ||||
|         services.AddHostedService<DataFetchingService>(); | ||||
|     }) | ||||
|     .Build(); | ||||
|   | ||||
| @@ -12,7 +12,8 @@ | ||||
|   "Schedules": { | ||||
|     "MercadoAgroganadero": "0 11 * * 1-5", | ||||
|     "BCR": "30 11 * * 1-5", | ||||
|     "Bolsas": "10 11-17 * * 1-5" | ||||
|     "Bolsas": "10 11-17 * * 1-5", | ||||
|     "Holidays": "0 2 * * 1" | ||||
|   }, | ||||
|   "ApiKeys": { | ||||
|     "Finnhub": "", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user