feat: Worker Service - API endpoints
Implement and configure Worker Service to orchestrate data fetchers - Implement API endpoints for stock market data
This commit is contained in:
		
							
								
								
									
										57
									
								
								src/Mercados.Api/Controllers/MercadosController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/Mercados.Api/Controllers/MercadosController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| using Mercados.Core.Entities; | ||||
| using Mercados.Infrastructure.Persistence.Repositories; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
|  | ||||
| namespace Mercados.Api.Controllers | ||||
| { | ||||
|     [ApiController] | ||||
|     [Route("api/[controller]")] | ||||
|     public class MercadosController : ControllerBase | ||||
|     { | ||||
|         private readonly ICotizacionBolsaRepository _bolsaRepo; | ||||
|         private readonly ILogger<MercadosController> _logger; | ||||
|          | ||||
|         // Inyectamos los repositorios que este controlador necesita. | ||||
|         public MercadosController(ICotizacionBolsaRepository bolsaRepo, ILogger<MercadosController> logger) | ||||
|         { | ||||
|             _bolsaRepo = bolsaRepo; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         [HttpGet("bolsa/eeuu")] | ||||
|         [ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||
|         public async Task<IActionResult> GetBolsaUsa() | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 var data = await _bolsaRepo.ObtenerUltimasPorMercadoAsync("EEUU"); | ||||
|                 return Ok(data); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al obtener cotizaciones de bolsa de EEUU."); | ||||
|                 return StatusCode(500, "Ocurrió un error interno en el servidor."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         [HttpGet("bolsa/local")] | ||||
|         [ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||
|         public async Task<IActionResult> GetBolsaLocal() | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 var data = await _bolsaRepo.ObtenerUltimasPorMercadoAsync("Local"); | ||||
|                 return Ok(data); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al obtener cotizaciones de bolsa local."); | ||||
|                 return StatusCode(500, "Ocurrió un error interno en el servidor."); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         // NOTA: Añadiremos los endpoints para Granos y Ganado en un momento. | ||||
|     } | ||||
| } | ||||
| @@ -1,17 +1,22 @@ | ||||
| using FluentMigrator.Runner; // <--- AÑADIR | ||||
| using Mercados.Database.Migrations; // <--- AÑADIR | ||||
| using FluentMigrator.Runner; | ||||
| using Mercados.Database.Migrations; | ||||
| using Mercados.Infrastructure; | ||||
| using Mercados.Infrastructure.Persistence; | ||||
| using System.Reflection; // <--- AÑADIR | ||||
| using Mercados.Infrastructure.Persistence.Repositories; | ||||
| using System.Reflection; | ||||
|  | ||||
| var builder = WebApplication.CreateBuilder(args); | ||||
|  | ||||
| // --- V INICIO DE NUESTRO CÓDIGO V --- | ||||
|  | ||||
| // 1. Registramos nuestra fábrica de conexiones. | ||||
| // 1. Registramos nuestra fábrica de conexiones a la BD. | ||||
| builder.Services.AddSingleton<IDbConnectionFactory, SqlConnectionFactory>(); | ||||
|  | ||||
| // 2. Configurar FluentMigrator | ||||
| // 2. AÑADIR: Registramos los repositorios que la API necesitará para LEER datos. | ||||
| builder.Services.AddScoped<ICotizacionGanadoRepository, CotizacionGanadoRepository>(); | ||||
| builder.Services.AddScoped<ICotizacionGranoRepository, CotizacionGranoRepository>(); | ||||
| builder.Services.AddScoped<ICotizacionBolsaRepository, CotizacionBolsaRepository>(); | ||||
| builder.Services.AddScoped<IFuenteDatoRepository, FuenteDatoRepository>(); | ||||
|  | ||||
| // 3. Configurar FluentMigrator | ||||
| builder.Services | ||||
|     .AddFluentMigratorCore() | ||||
|     .ConfigureRunner(rb => rb | ||||
| @@ -25,8 +30,6 @@ builder.Services | ||||
|     .AddLogging(lb => lb.AddFluentMigratorConsole()); | ||||
|  | ||||
|  | ||||
| // --- ^ FIN DE NUESTRO CÓDIGO ^ --- | ||||
|  | ||||
| // Add services to the container. | ||||
| builder.Services.AddControllers(); | ||||
| // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle | ||||
| @@ -35,9 +38,7 @@ builder.Services.AddSwaggerGen(); | ||||
|  | ||||
| var app = builder.Build(); | ||||
|  | ||||
| // --- V INICIO DE NUESTRO CÓDIGO DE EJECUCIÓN V --- | ||||
|  | ||||
| // 3. Ejecutar las migraciones al iniciar la aplicación (ideal para desarrollo y despliegues sencillos) | ||||
| // 4. Ejecutar las migraciones al iniciar la aplicación (ideal para desarrollo y despliegues sencillos) | ||||
| // Obtenemos el "scope" de los servicios para poder solicitar el MigrationRunner | ||||
| using (var scope = app.Services.CreateScope()) | ||||
| { | ||||
| @@ -46,9 +47,6 @@ using (var scope = app.Services.CreateScope()) | ||||
|     migrationRunner.MigrateUp(); | ||||
| } | ||||
|  | ||||
| // --- ^ FIN DE NUESTRO CÓDIGO DE EJECUCIÓN ^ --- | ||||
|  | ||||
|  | ||||
| // Configure the HTTP request pipeline. | ||||
| if (app.Environment.IsDevelopment()) | ||||
| { | ||||
|   | ||||
| @@ -8,5 +8,12 @@ | ||||
|   "AllowedHosts": "*", | ||||
|   "ConnectionStrings": { | ||||
|     "DefaultConnection": "Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;" | ||||
|   }, | ||||
|   "ApiKeys": { | ||||
|     "Finnhub": "cuvhr0hr01qs9e81st2gcuvhr0hr01qs9e81st30", | ||||
|     "Bcr": { | ||||
|       "Key": "D1782A51-A5FD-EF11-9445-00155D09E201", | ||||
|       "Secret": "da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3" | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										138
									
								
								src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,138 @@ | ||||
| using Mercados.Core.Entities; | ||||
| using Mercados.Infrastructure.Persistence.Repositories; | ||||
| using Microsoft.Extensions.Configuration; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using System.Net.Http.Headers; | ||||
| using System.Net.Http.Json; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace Mercados.Infrastructure.DataFetchers | ||||
| { | ||||
|     public class BcrDataFetcher : IDataFetcher | ||||
|     { | ||||
|         #region Clases DTO para la respuesta de la API de BCR | ||||
|         private class BcrTokenResponse { | ||||
|             [JsonPropertyName("data")] | ||||
|             public TokenData? Data { get; set; } | ||||
|         } | ||||
|         private class TokenData { | ||||
|             [JsonPropertyName("token")] | ||||
|             public string? Token { get; set; } | ||||
|         } | ||||
|         private class BcrPreciosResponse { | ||||
|             [JsonPropertyName("data")] | ||||
|             public List<BcrPrecioItem>? Data { get; set; } | ||||
|         } | ||||
|         private class BcrPrecioItem { | ||||
|             [JsonPropertyName("precio_Cotizacion")] | ||||
|             public decimal PrecioCotizacion { get; set; } | ||||
|             [JsonPropertyName("variacion_Precio_Cotizacion")] | ||||
|             public decimal VariacionPrecioCotizacion { get; set; } | ||||
|             [JsonPropertyName("fecha_Operacion_Pizarra")] | ||||
|             public DateTime FechaOperacionPizarra { get; set; } | ||||
|         } | ||||
|         #endregion | ||||
|  | ||||
|         public string SourceName => "BCR"; | ||||
|         private const string BaseUrl = "https://api.bcr.com.ar/gix/v1.0"; | ||||
|         private readonly Dictionary<string, int> _grainIds = new() | ||||
|         { | ||||
|             { "Trigo", 1 }, { "Maiz", 2 }, { "Sorgo", 3 }, { "Girasol", 20 }, { "Soja", 21 } | ||||
|         }; | ||||
|  | ||||
|         private readonly IHttpClientFactory _httpClientFactory; | ||||
|         private readonly ICotizacionGranoRepository _cotizacionRepository; | ||||
|         private readonly IFuenteDatoRepository _fuenteDatoRepository; | ||||
|         private readonly IConfiguration _configuration; | ||||
|         private readonly ILogger<BcrDataFetcher> _logger; | ||||
|  | ||||
|         public BcrDataFetcher( | ||||
|             IHttpClientFactory httpClientFactory,  | ||||
|             ICotizacionGranoRepository cotizacionRepository,  | ||||
|             IFuenteDatoRepository fuenteDatoRepository,  | ||||
|             IConfiguration configuration,  | ||||
|             ILogger<BcrDataFetcher> logger) | ||||
|         { | ||||
|             _httpClientFactory = httpClientFactory; | ||||
|             _cotizacionRepository = cotizacionRepository; | ||||
|             _fuenteDatoRepository = fuenteDatoRepository; | ||||
|             _configuration = configuration; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task<(bool Success, string Message)> FetchDataAsync() | ||||
|         { | ||||
|             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); | ||||
|             try | ||||
|             { | ||||
|                 var client = _httpClientFactory.CreateClient(); | ||||
|                 var token = await GetAuthTokenAsync(client); | ||||
|                 if (string.IsNullOrEmpty(token)) | ||||
|                 { | ||||
|                     return (false, "No se pudo obtener el token de autenticación de BCR."); | ||||
|                 } | ||||
|                 client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); | ||||
|  | ||||
|                 var cotizaciones = new List<CotizacionGrano>(); | ||||
|                 foreach (var grain in _grainIds) | ||||
|                 { | ||||
|                     var response = await client.GetFromJsonAsync<BcrPreciosResponse>( | ||||
|                         $"{BaseUrl}/PreciosCamara?idGrano={grain.Value}&fechaConcertacionDesde={DateTime.Now.AddDays(-3):yyyy-MM-dd}&fechaConcertacionHasta={DateTime.Now:yyyy-MM-dd}"); | ||||
|  | ||||
|                     var latestRecord = response?.Data?.OrderByDescending(r => r.FechaOperacionPizarra).FirstOrDefault(); | ||||
|                     if (latestRecord != null) | ||||
|                     { | ||||
|                         cotizaciones.Add(new CotizacionGrano | ||||
|                         { | ||||
|                             Nombre = grain.Key, | ||||
|                             Precio = latestRecord.PrecioCotizacion, | ||||
|                             VariacionPrecio = latestRecord.VariacionPrecioCotizacion, | ||||
|                             FechaOperacion = latestRecord.FechaOperacionPizarra, | ||||
|                             FechaRegistro = DateTime.UtcNow | ||||
|                         }); | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|                 if (!cotizaciones.Any()) return (false, "No se obtuvieron datos de granos de BCR."); | ||||
|  | ||||
|                 await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); | ||||
|                 await UpdateSourceInfoAsync(); | ||||
|                  | ||||
|                 _logger.LogInformation("Fetch para {SourceName} completado. Se guardaron {Count} registros.", SourceName, cotizaciones.Count); | ||||
|                 return (true, $"Proceso completado. Se guardaron {cotizaciones.Count} registros."); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Ocurrió un error durante el fetch para {SourceName}.", SourceName); | ||||
|                 return (false, $"Error: {ex.Message}"); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private async Task<string?> GetAuthTokenAsync(HttpClient client) | ||||
|         { | ||||
|             var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/Login"); | ||||
|             request.Headers.Add("api_key", _configuration["ApiKeys:Bcr:Key"]); | ||||
|             request.Headers.Add("secret", _configuration["ApiKeys:Bcr:Secret"]); | ||||
|              | ||||
|             var response = await client.SendAsync(request); | ||||
|             response.EnsureSuccessStatusCode(); | ||||
|  | ||||
|             var tokenResponse = await response.Content.ReadFromJsonAsync<BcrTokenResponse>(); | ||||
|             return tokenResponse?.Data?.Token; | ||||
|         } | ||||
|          | ||||
|         private async Task UpdateSourceInfoAsync() | ||||
|         { | ||||
|             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); | ||||
|             if (fuente == null) | ||||
|             { | ||||
|                 await _fuenteDatoRepository.CrearAsync(new FuenteDato { Nombre = SourceName, Url = BaseUrl, UltimaEjecucionExitosa = DateTime.UtcNow }); | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 fuente.UltimaEjecucionExitosa = DateTime.UtcNow; | ||||
|                 await _fuenteDatoRepository.ActualizarAsync(fuente); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,96 @@ | ||||
| using ThreeFourteen.Finnhub.Client; | ||||
| using Mercados.Core.Entities; | ||||
| using Mercados.Infrastructure.Persistence.Repositories; | ||||
| using Microsoft.Extensions.Configuration; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using System.Net.Http; | ||||
|  | ||||
| namespace Mercados.Infrastructure.DataFetchers | ||||
| { | ||||
|     public class FinnhubDataFetcher : IDataFetcher | ||||
|     { | ||||
|         public string SourceName => "Finnhub"; | ||||
|         private readonly List<string> _tickers = new() { | ||||
|             "AAPL", "AMD", "AMZN", "BRK-B", "KO", "MSFT", "NVDA", "GLD",  | ||||
|             "XLF", "XLI", "XLE", "XLK", "YPF", "GGAL", "BMA", "TEO", | ||||
|             "PAM", "CEPU", "LOMA", "CRESY", "BBAR", "TGS", "EDN", "MELI", "GLOB" | ||||
|         }; | ||||
|          | ||||
|         private readonly FinnhubClient _client; | ||||
|         private readonly ICotizacionBolsaRepository _cotizacionRepository; | ||||
|         private readonly IFuenteDatoRepository _fuenteDatoRepository; | ||||
|         private readonly ILogger<FinnhubDataFetcher> _logger; | ||||
|  | ||||
|         public FinnhubDataFetcher( | ||||
|             IConfiguration configuration, | ||||
|             IHttpClientFactory httpClientFactory, | ||||
|             ICotizacionBolsaRepository cotizacionRepository, | ||||
|             IFuenteDatoRepository fuenteDatoRepository, | ||||
|             ILogger<FinnhubDataFetcher> logger) | ||||
|         { | ||||
|             var apiKey = configuration["ApiKeys:Finnhub"]; | ||||
|             if (string.IsNullOrEmpty(apiKey)) | ||||
|             { | ||||
|                 throw new InvalidOperationException("La clave de API de Finnhub no está configurada en appsettings.json (ApiKeys:Finnhub)"); | ||||
|             } | ||||
|             _client = new FinnhubClient(httpClientFactory.CreateClient("Finnhub"), apiKey); | ||||
|             _cotizacionRepository = cotizacionRepository; | ||||
|             _fuenteDatoRepository = fuenteDatoRepository; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task<(bool Success, string Message)> FetchDataAsync() | ||||
|         { | ||||
|             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); | ||||
|             var cotizaciones = new List<CotizacionBolsa>(); | ||||
|              | ||||
|             foreach (var ticker in _tickers) | ||||
|             { | ||||
|                 try | ||||
|                 { | ||||
|                     var quote = await _client.Stock.GetQuote(ticker); | ||||
|  | ||||
|                     if (quote.Current == 0 || quote.PreviousClose == 0) continue; | ||||
|  | ||||
|                     var pctChange = ((quote.Current - quote.PreviousClose) / quote.PreviousClose) * 100; | ||||
|                     cotizaciones.Add(new CotizacionBolsa | ||||
|                     { | ||||
|                         Ticker = ticker, | ||||
|                         Mercado = "EEUU", | ||||
|                         PrecioActual = (decimal)quote.Current, | ||||
|                         Apertura = (decimal)quote.Open, | ||||
|                         CierreAnterior = (decimal)quote.PreviousClose, | ||||
|                         PorcentajeCambio = (decimal)Math.Round(pctChange, 4), | ||||
|                         FechaRegistro = DateTime.UtcNow | ||||
|                     }); | ||||
|                 } | ||||
|                 catch (Exception ex) | ||||
|                 { | ||||
|                     _logger.LogWarning(ex, "No se pudo obtener la cotización para el ticker {Ticker} de Finnhub.", ticker); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (!cotizaciones.Any()) return (false, "No se obtuvieron datos de Finnhub."); | ||||
|  | ||||
|             await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); | ||||
|             await UpdateSourceInfoAsync(); | ||||
|              | ||||
|             _logger.LogInformation("Fetch para {SourceName} completado. Se guardaron {Count} registros.", SourceName, cotizaciones.Count); | ||||
|             return (true, $"Proceso completado. Se guardaron {cotizaciones.Count} registros."); | ||||
|         } | ||||
|          | ||||
|         private async Task UpdateSourceInfoAsync() | ||||
|         { | ||||
|             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); | ||||
|             if (fuente == null) | ||||
|             { | ||||
|                 await _fuenteDatoRepository.CrearAsync(new FuenteDato { Nombre = SourceName, Url = "https://finnhub.io/", UltimaEjecucionExitosa = DateTime.UtcNow }); | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 fuente.UltimaEjecucionExitosa = DateTime.UtcNow; | ||||
|                 await _fuenteDatoRepository.ActualizarAsync(fuente); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,85 @@ | ||||
| using Mercados.Core.Entities; | ||||
| using Mercados.Infrastructure.Persistence.Repositories; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using YahooFinanceApi; | ||||
|  | ||||
| namespace Mercados.Infrastructure.DataFetchers | ||||
| { | ||||
|     public class YahooFinanceDataFetcher : IDataFetcher | ||||
|     { | ||||
|         public string SourceName => "YahooFinance"; | ||||
|         private readonly List<string> _tickers = new() { | ||||
|             "^MERV", "GGAL.BA", "YPFD.BA", "PAMP.BA", "BMA.BA", "COME.BA",  | ||||
|             "TECO2.BA", "EDN.BA", "CRES.BA", "TXAR.BA", "MIRG.BA",  | ||||
|             "CEPU.BA", "LOMA.BA", "VALO.BA" | ||||
|         }; | ||||
|          | ||||
|         private readonly ICotizacionBolsaRepository _cotizacionRepository; | ||||
|         private readonly IFuenteDatoRepository _fuenteDatoRepository; | ||||
|         private readonly ILogger<YahooFinanceDataFetcher> _logger; | ||||
|  | ||||
|         public YahooFinanceDataFetcher( | ||||
|             ICotizacionBolsaRepository cotizacionRepository,  | ||||
|             IFuenteDatoRepository fuenteDatoRepository,  | ||||
|             ILogger<YahooFinanceDataFetcher> logger) | ||||
|         { | ||||
|             _cotizacionRepository = cotizacionRepository; | ||||
|             _fuenteDatoRepository = fuenteDatoRepository; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task<(bool Success, string Message)> FetchDataAsync() | ||||
|         { | ||||
|             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); | ||||
|             try | ||||
|             { | ||||
|                 // La librería puede obtener múltiples tickers en una sola llamada. | ||||
|                 var securities = await Yahoo.Symbols(_tickers.ToArray()).Fields(Field.RegularMarketPrice, Field.RegularMarketOpen, Field.RegularMarketPreviousClose, Field.RegularMarketChangePercent).QueryAsync(); | ||||
|                 var cotizaciones = new List<CotizacionBolsa>(); | ||||
|  | ||||
|                 foreach (var sec in securities.Values) | ||||
|                 { | ||||
|                     if (sec.RegularMarketPrice == 0 || sec.RegularMarketPreviousClose == 0) continue; | ||||
|                      | ||||
|                     cotizaciones.Add(new CotizacionBolsa | ||||
|                     { | ||||
|                         Ticker = sec.Symbol, | ||||
|                         Mercado = "Local", | ||||
|                         PrecioActual = (decimal)sec.RegularMarketPrice, | ||||
|                         Apertura = (decimal)sec.RegularMarketOpen, | ||||
|                         CierreAnterior = (decimal)sec.RegularMarketPreviousClose, | ||||
|                         PorcentajeCambio = (decimal)sec.RegularMarketChangePercent, | ||||
|                         FechaRegistro = DateTime.UtcNow | ||||
|                     }); | ||||
|                 } | ||||
|  | ||||
|                 if (!cotizaciones.Any()) return (false, "No se obtuvieron datos de Yahoo Finance."); | ||||
|  | ||||
|                 await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); | ||||
|                 await UpdateSourceInfoAsync(); | ||||
|  | ||||
|                 _logger.LogInformation("Fetch para {SourceName} completado. Se guardaron {Count} registros.", SourceName, cotizaciones.Count); | ||||
|                 return (true, $"Proceso completado. Se guardaron {cotizaciones.Count} registros."); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Ocurrió un error durante el fetch para {SourceName}.", SourceName); | ||||
|                 return (false, $"Error: {ex.Message}"); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         private async Task UpdateSourceInfoAsync() | ||||
|         { | ||||
|             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); | ||||
|             if (fuente == null) | ||||
|             { | ||||
|                 await _fuenteDatoRepository.CrearAsync(new FuenteDato { Nombre = SourceName, Url = "https://finance.yahoo.com/", UltimaEjecucionExitosa = DateTime.UtcNow }); | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 fuente.UltimaEjecucionExitosa = DateTime.UtcNow; | ||||
|                 await _fuenteDatoRepository.ActualizarAsync(fuente); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -9,6 +9,8 @@ | ||||
|     <PackageReference Include="Dapper" Version="2.1.66" /> | ||||
|     <PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" /> | ||||
|     <PackageReference Include="ThreeFourteen.Finnhub.Client" Version="1.2.0" /> | ||||
|     <PackageReference Include="YahooFinanceApi" Version="2.3.3" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <PropertyGroup> | ||||
|   | ||||
| @@ -0,0 +1,54 @@ | ||||
| using Dapper; | ||||
| using Mercados.Core.Entities; | ||||
| using System.Data; | ||||
|  | ||||
| namespace Mercados.Infrastructure.Persistence.Repositories | ||||
| { | ||||
|     public class CotizacionBolsaRepository : ICotizacionBolsaRepository | ||||
|     { | ||||
|         private readonly IDbConnectionFactory _connectionFactory; | ||||
|  | ||||
|         public CotizacionBolsaRepository(IDbConnectionFactory connectionFactory) | ||||
|         { | ||||
|             _connectionFactory = connectionFactory; | ||||
|         } | ||||
|  | ||||
|         public async Task GuardarMuchosAsync(IEnumerable<CotizacionBolsa> cotizaciones) | ||||
|         { | ||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||
|  | ||||
|             const string sql = @" | ||||
|                 INSERT INTO CotizacionesBolsa (Ticker, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro)  | ||||
|                 VALUES (@Ticker, @Mercado, @PrecioActual, @Apertura, @CierreAnterior, @PorcentajeCambio, @FechaRegistro);"; | ||||
|  | ||||
|             await connection.ExecuteAsync(sql, cotizaciones); | ||||
|         } | ||||
|          | ||||
|         public async Task<IEnumerable<CotizacionBolsa>> ObtenerUltimasPorMercadoAsync(string mercado) | ||||
|         { | ||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||
|  | ||||
|             // Esta consulta SQL es un poco más avanzada. Usa una "Common Table Expression" (CTE) | ||||
|             // y la función ROW_NUMBER() para obtener el registro más reciente para cada Ticker | ||||
|             // dentro del mercado especificado. Es extremadamente eficiente. | ||||
|             const string sql = @" | ||||
|                 WITH RankedCotizaciones AS ( | ||||
|                     SELECT  | ||||
|                         *, | ||||
|                         ROW_NUMBER() OVER(PARTITION BY Ticker ORDER BY FechaRegistro DESC) as rn | ||||
|                     FROM  | ||||
|                         CotizacionesBolsa | ||||
|                     WHERE  | ||||
|                         Mercado = @Mercado | ||||
|                 ) | ||||
|                 SELECT  | ||||
|                     Id, Ticker, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro | ||||
|                 FROM  | ||||
|                     RankedCotizaciones | ||||
|                 WHERE  | ||||
|                     rn = 1;"; | ||||
|  | ||||
|             return await connection.QueryAsync<CotizacionBolsa>(sql, new { Mercado = mercado }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,27 @@ | ||||
| using Dapper; | ||||
| using Mercados.Core.Entities; | ||||
| using System.Data; | ||||
|  | ||||
| namespace Mercados.Infrastructure.Persistence.Repositories | ||||
| { | ||||
|     public class CotizacionGranoRepository : ICotizacionGranoRepository | ||||
|     { | ||||
|         private readonly IDbConnectionFactory _connectionFactory; | ||||
|  | ||||
|         public CotizacionGranoRepository(IDbConnectionFactory connectionFactory) | ||||
|         { | ||||
|             _connectionFactory = connectionFactory; | ||||
|         } | ||||
|  | ||||
|         public async Task GuardarMuchosAsync(IEnumerable<CotizacionGrano> cotizaciones) | ||||
|         { | ||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||
|              | ||||
|             const string sql = @" | ||||
|                 INSERT INTO CotizacionesGranos (Nombre, Precio, VariacionPrecio, FechaOperacion, FechaRegistro)  | ||||
|                 VALUES (@Nombre, @Precio, @VariacionPrecio, @FechaOperacion, @FechaRegistro);"; | ||||
|  | ||||
|             await connection.ExecuteAsync(sql, cotizaciones); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,10 @@ | ||||
| using Mercados.Core.Entities; | ||||
|  | ||||
| namespace Mercados.Infrastructure.Persistence.Repositories | ||||
| { | ||||
|     public interface ICotizacionBolsaRepository : IBaseRepository | ||||
|     { | ||||
|         Task GuardarMuchosAsync(IEnumerable<CotizacionBolsa> cotizaciones); | ||||
|         Task<IEnumerable<CotizacionBolsa>> ObtenerUltimasPorMercadoAsync(string mercado); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,9 @@ | ||||
| using Mercados.Core.Entities; | ||||
|  | ||||
| namespace Mercados.Infrastructure.Persistence.Repositories | ||||
| { | ||||
|     public interface ICotizacionGranoRepository : IBaseRepository | ||||
|     { | ||||
|         Task GuardarMuchosAsync(IEnumerable<CotizacionGrano> cotizaciones); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										121
									
								
								src/Mercados.Worker/DataFetchingService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								src/Mercados.Worker/DataFetchingService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,121 @@ | ||||
| using Mercados.Infrastructure.DataFetchers; | ||||
|  | ||||
| namespace Mercados.Worker | ||||
| { | ||||
|     public class DataFetchingService : BackgroundService | ||||
|     { | ||||
|         private readonly ILogger<DataFetchingService> _logger; | ||||
|         private readonly IServiceProvider _serviceProvider; | ||||
|  | ||||
|         // Diccionario para rastrear la última vez que se ejecutó una tarea diaria. | ||||
|         private readonly Dictionary<string, DateTime> _lastDailyRun = new(); | ||||
|  | ||||
|         public DataFetchingService(ILogger<DataFetchingService> logger, IServiceProvider serviceProvider) | ||||
|         { | ||||
|             _logger = logger; | ||||
|             _serviceProvider = serviceProvider; | ||||
|         } | ||||
|  | ||||
|         protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||||
|         { | ||||
|             _logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now); | ||||
|  | ||||
|             // Ejecutamos una vez al inicio para tener datos frescos inmediatamente. | ||||
|             await RunAllFetchersAsync(); | ||||
|  | ||||
|             // Usamos un PeriodicTimer que "despierta" cada minuto para revisar si hay tareas pendientes. | ||||
|             using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); | ||||
|  | ||||
|             while (await timer.WaitForNextTickAsync(stoppingToken)) | ||||
|             { | ||||
|                 await RunScheduledTasksAsync(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private async Task RunScheduledTasksAsync() | ||||
|         { | ||||
|             // --- Lógica de Planificación --- | ||||
|             var now = DateTime.Now; | ||||
|  | ||||
|             // Tarea 1: Mercado Agroganadero (todos los días a las 11:00) | ||||
|             if (now.Hour == 11 && now.Minute == 0 && HasNotRunToday("MercadoAgroganadero")) | ||||
|             { | ||||
|                 await RunFetcherByNameAsync("MercadoAgroganadero"); | ||||
|                 _lastDailyRun["MercadoAgroganadero"] = now.Date; | ||||
|             } | ||||
|  | ||||
|             // Tarea 2: Granos BCR (todos los días a las 11:30) | ||||
|             if (now.Hour == 11 && now.Minute == 30 && HasNotRunToday("BCR")) | ||||
|             { | ||||
|                 await RunFetcherByNameAsync("BCR"); | ||||
|                 _lastDailyRun["BCR"] = now.Date; | ||||
|             } | ||||
|  | ||||
|             // Tarea 3: Mercados de Bolsa (cada 10 minutos si el mercado está abierto) | ||||
|             if (IsMarketOpen(now) && now.Minute % 10 == 0) | ||||
|             { | ||||
|                 _logger.LogInformation("Mercados abiertos. Ejecutando fetchers de bolsa."); | ||||
|                 await RunFetcherByNameAsync("Finnhub"); | ||||
|                 await RunFetcherByNameAsync("YahooFinance"); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         // Esta función crea un "scope" para ejecutar un fetcher específico. | ||||
|         // Esto es crucial para que la inyección de dependencias funcione correctamente. | ||||
|         private async Task RunFetcherByNameAsync(string sourceName) | ||||
|         { | ||||
|             _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) | ||||
|                 { | ||||
|                     _logger.LogError("Falló la ejecución del fetcher {sourceName}: {message}", sourceName, message); | ||||
|                 } | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 _logger.LogWarning("No se encontró un fetcher con el nombre: {sourceName}", sourceName); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Función de ayuda para ejecutar todos los fetchers (usada al inicio). | ||||
|         private async Task RunAllFetchersAsync() | ||||
|         { | ||||
|             _logger.LogInformation("Ejecutando todos los fetchers al iniciar..."); | ||||
|             using var scope = _serviceProvider.CreateScope(); | ||||
|             var fetchers = scope.ServiceProvider.GetRequiredService<IEnumerable<IDataFetcher>>(); | ||||
|             foreach (var fetcher in fetchers) | ||||
|             { | ||||
|                 await RunFetcherByNameAsync(fetcher.SourceName); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         #region Funciones de Ayuda para la Planificación | ||||
|  | ||||
|         private bool HasNotRunToday(string taskName) | ||||
|         { | ||||
|             return !_lastDailyRun.ContainsKey(taskName) || _lastDailyRun[taskName].Date < DateTime.Now.Date; | ||||
|         } | ||||
|  | ||||
|         private bool IsMarketOpen(DateTime now) | ||||
|         { | ||||
|             // Lunes a Viernes (1 a 5, Domingo es 0) | ||||
|             if (now.DayOfWeek == DayOfWeek.Saturday || now.DayOfWeek == DayOfWeek.Sunday) | ||||
|                 return false; | ||||
|              | ||||
|             // Horario de mercado de 11:00 a 17:15 (hora de Argentina) | ||||
|             var marketOpen = new TimeSpan(11, 0, 0); | ||||
|             var marketClose = new TimeSpan(17, 15, 0); | ||||
|  | ||||
|             return now.TimeOfDay >= marketOpen && now.TimeOfDay <= marketClose; | ||||
|         } | ||||
|  | ||||
|         #endregion | ||||
|     } | ||||
| } | ||||
| @@ -1,7 +1,51 @@ | ||||
| using Mercados.Infrastructure; | ||||
| using Mercados.Infrastructure.DataFetchers; | ||||
| using Mercados.Infrastructure.Persistence; | ||||
| using Mercados.Infrastructure.Persistence.Repositories; | ||||
| using Mercados.Worker; | ||||
|  | ||||
| var builder = Host.CreateApplicationBuilder(args); | ||||
| builder.Services.AddHostedService<Worker>(); | ||||
| // --- Configuración del Host --- | ||||
| // Esto prepara el host del servicio, permitiendo la inyección de dependencias, | ||||
| // la configuración desde appsettings.json y el logging. | ||||
| IHost host = Host.CreateDefaultBuilder(args) | ||||
|     .ConfigureServices((hostContext, services) => | ||||
|     { | ||||
|         // Obtenemos la configuración desde el host builder para usarla aquí. | ||||
|         IConfiguration configuration = hostContext.Configuration; | ||||
|  | ||||
| var host = builder.Build(); | ||||
| host.Run(); | ||||
|         // --- 1. Registro de Servicios de Infraestructura --- | ||||
|  | ||||
|         // Registramos la fábrica de conexiones a la BD. Es un Singleton porque | ||||
|         // solo necesita ser creada una vez para leer la cadena de conexión. | ||||
|         services.AddSingleton<IDbConnectionFactory, SqlConnectionFactory>(); | ||||
|  | ||||
|         // Registramos los repositorios. Se crean "por petición" (Scoped). | ||||
|         // En un worker, "Scoped" significa que se creará una instancia por cada | ||||
|         // ejecución del servicio, lo cual es seguro y eficiente. | ||||
|         services.AddScoped<ICotizacionGanadoRepository, CotizacionGanadoRepository>(); | ||||
|         services.AddScoped<ICotizacionGranoRepository, CotizacionGranoRepository>(); | ||||
|         services.AddScoped<ICotizacionBolsaRepository, CotizacionBolsaRepository>(); | ||||
|         services.AddScoped<IFuenteDatoRepository, FuenteDatoRepository>(); | ||||
|  | ||||
|         // --- 2. Registro de los Data Fetchers --- | ||||
|  | ||||
|         // Registramos CADA uno de nuestros fetchers. El contenedor de DI sabrá | ||||
|         // que todos implementan la interfaz IDataFetcher. | ||||
|         services.AddScoped<IDataFetcher, MercadoAgroFetcher>(); | ||||
|         services.AddScoped<IDataFetcher, BcrDataFetcher>(); | ||||
|         //services.AddScoped<IDataFetcher, FinnhubDataFetcher>(); | ||||
|         services.AddScoped<IDataFetcher, YahooFinanceDataFetcher>(); | ||||
|          | ||||
|         // El cliente HTTP es fundamental para hacer llamadas a APIs externas. | ||||
|         // Le damos un nombre al cliente de Finnhub para cumplir con los requisitos de su constructor. | ||||
|         services.AddHttpClient("Finnhub"); | ||||
|  | ||||
|  | ||||
|         // --- 3. Registro del Worker Principal --- | ||||
|          | ||||
|         // Finalmente, registramos nuestro servicio de fondo (el worker en sí). | ||||
|         services.AddHostedService<DataFetchingService>(); | ||||
|     }) | ||||
|     .Build(); | ||||
|  | ||||
| await host.RunAsync(); | ||||
| @@ -1,23 +0,0 @@ | ||||
| namespace Mercados.Worker; | ||||
|  | ||||
| public class Worker : BackgroundService | ||||
| { | ||||
|     private readonly ILogger<Worker> _logger; | ||||
|  | ||||
|     public Worker(ILogger<Worker> logger) | ||||
|     { | ||||
|         _logger = logger; | ||||
|     } | ||||
|  | ||||
|     protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||||
|     { | ||||
|         while (!stoppingToken.IsCancellationRequested) | ||||
|         { | ||||
|             if (_logger.IsEnabled(LogLevel.Information)) | ||||
|             { | ||||
|                 _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now); | ||||
|             } | ||||
|             await Task.Delay(1000, stoppingToken); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -2,7 +2,18 @@ | ||||
|   "Logging": { | ||||
|     "LogLevel": { | ||||
|       "Default": "Information", | ||||
|       "Microsoft.Hosting.Lifetime": "Information" | ||||
|       "Microsoft.AspNetCore": "Warning" | ||||
|     } | ||||
|   }, | ||||
|   "AllowedHosts": "*", | ||||
|   "ConnectionStrings": { | ||||
|     "DefaultConnection": "Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;" | ||||
|   }, | ||||
|   "ApiKeys": { | ||||
|     "Finnhub": "cuvhr0hr01qs9e81st2gcuvhr0hr01qs9e81st30", | ||||
|     "Bcr": { | ||||
|       "Key": "D1782A51-A5FD-EF11-9445-00155D09E201", | ||||
|       "Secret": "da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3" | ||||
|     } | ||||
|   } | ||||
| } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user