From 10f19af9f834c5d11342cf48f4957f080e1193f6 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Tue, 1 Jul 2025 12:19:00 -0300 Subject: [PATCH] feat: Worker Service - API endpoints Implement and configure Worker Service to orchestrate data fetchers - Implement API endpoints for stock market data --- .../Controllers/MercadosController.cs | 57 ++++++++ src/Mercados.Api/Program.cs | 28 ++-- src/Mercados.Api/appsettings.json | 7 + .../DataFetchers/BcrDataFetcher.cs | 138 ++++++++++++++++++ .../DataFetchers/FinnhubDataFetcher.cs | 96 ++++++++++++ .../DataFetchers/YahooFinanceDataFetcher.cs | 85 +++++++++++ .../Mercados.Infrastructure.csproj | 2 + .../Repositories/CotizacionBolsaRepository.cs | 54 +++++++ .../Repositories/CotizacionGranoRepository.cs | 27 ++++ .../ICotizacionBolsaRepository.cs | 10 ++ .../ICotizacionGranoRepository.cs | 9 ++ src/Mercados.Worker/DataFetchingService.cs | 121 +++++++++++++++ src/Mercados.Worker/Program.cs | 52 ++++++- src/Mercados.Worker/Worker.cs | 23 --- src/Mercados.Worker/appsettings.json | 15 +- 15 files changed, 680 insertions(+), 44 deletions(-) create mode 100644 src/Mercados.Api/Controllers/MercadosController.cs create mode 100644 src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs create mode 100644 src/Mercados.Infrastructure/DataFetchers/FinnhubDataFetcher.cs create mode 100644 src/Mercados.Infrastructure/DataFetchers/YahooFinanceDataFetcher.cs create mode 100644 src/Mercados.Infrastructure/Persistence/Repositories/CotizacionBolsaRepository.cs create mode 100644 src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGranoRepository.cs create mode 100644 src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionBolsaRepository.cs create mode 100644 src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGranoRepository.cs create mode 100644 src/Mercados.Worker/DataFetchingService.cs delete mode 100644 src/Mercados.Worker/Worker.cs diff --git a/src/Mercados.Api/Controllers/MercadosController.cs b/src/Mercados.Api/Controllers/MercadosController.cs new file mode 100644 index 0000000..93a3f11 --- /dev/null +++ b/src/Mercados.Api/Controllers/MercadosController.cs @@ -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 _logger; + + // Inyectamos los repositorios que este controlador necesita. + public MercadosController(ICotizacionBolsaRepository bolsaRepo, ILogger logger) + { + _bolsaRepo = bolsaRepo; + _logger = logger; + } + + [HttpGet("bolsa/eeuu")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task 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), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task 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. + } +} \ No newline at end of file diff --git a/src/Mercados.Api/Program.cs b/src/Mercados.Api/Program.cs index 37020de..95feb79 100644 --- a/src/Mercados.Api/Program.cs +++ b/src/Mercados.Api/Program.cs @@ -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(); -// 2. Configurar FluentMigrator +// 2. AÑADIR: Registramos los repositorios que la API necesitará para LEER datos. +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// 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()) { diff --git a/src/Mercados.Api/appsettings.json b/src/Mercados.Api/appsettings.json index 71f17dd..5644844 100644 --- a/src/Mercados.Api/appsettings.json +++ b/src/Mercados.Api/appsettings.json @@ -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" + } } } \ No newline at end of file diff --git a/src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs b/src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs new file mode 100644 index 0000000..d0d668a --- /dev/null +++ b/src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs @@ -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? 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 _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 _logger; + + public BcrDataFetcher( + IHttpClientFactory httpClientFactory, + ICotizacionGranoRepository cotizacionRepository, + IFuenteDatoRepository fuenteDatoRepository, + IConfiguration configuration, + ILogger 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(); + foreach (var grain in _grainIds) + { + var response = await client.GetFromJsonAsync( + $"{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 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(); + 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); + } + } + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/DataFetchers/FinnhubDataFetcher.cs b/src/Mercados.Infrastructure/DataFetchers/FinnhubDataFetcher.cs new file mode 100644 index 0000000..f5c3bcc --- /dev/null +++ b/src/Mercados.Infrastructure/DataFetchers/FinnhubDataFetcher.cs @@ -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 _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 _logger; + + public FinnhubDataFetcher( + IConfiguration configuration, + IHttpClientFactory httpClientFactory, + ICotizacionBolsaRepository cotizacionRepository, + IFuenteDatoRepository fuenteDatoRepository, + ILogger 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(); + + 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); + } + } + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/DataFetchers/YahooFinanceDataFetcher.cs b/src/Mercados.Infrastructure/DataFetchers/YahooFinanceDataFetcher.cs new file mode 100644 index 0000000..c177850 --- /dev/null +++ b/src/Mercados.Infrastructure/DataFetchers/YahooFinanceDataFetcher.cs @@ -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 _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 _logger; + + public YahooFinanceDataFetcher( + ICotizacionBolsaRepository cotizacionRepository, + IFuenteDatoRepository fuenteDatoRepository, + ILogger 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(); + + 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); + } + } + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Mercados.Infrastructure.csproj b/src/Mercados.Infrastructure/Mercados.Infrastructure.csproj index 37d0f0f..e55e3bd 100644 --- a/src/Mercados.Infrastructure/Mercados.Infrastructure.csproj +++ b/src/Mercados.Infrastructure/Mercados.Infrastructure.csproj @@ -9,6 +9,8 @@ + + diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionBolsaRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionBolsaRepository.cs new file mode 100644 index 0000000..b34f5b4 --- /dev/null +++ b/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionBolsaRepository.cs @@ -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 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> 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(sql, new { Mercado = mercado }); + } + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGranoRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGranoRepository.cs new file mode 100644 index 0000000..dbffbb4 --- /dev/null +++ b/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGranoRepository.cs @@ -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 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); + } + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionBolsaRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionBolsaRepository.cs new file mode 100644 index 0000000..ee22b16 --- /dev/null +++ b/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionBolsaRepository.cs @@ -0,0 +1,10 @@ +using Mercados.Core.Entities; + +namespace Mercados.Infrastructure.Persistence.Repositories +{ + public interface ICotizacionBolsaRepository : IBaseRepository + { + Task GuardarMuchosAsync(IEnumerable cotizaciones); + Task> ObtenerUltimasPorMercadoAsync(string mercado); + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGranoRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGranoRepository.cs new file mode 100644 index 0000000..d8ecb4d --- /dev/null +++ b/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGranoRepository.cs @@ -0,0 +1,9 @@ +using Mercados.Core.Entities; + +namespace Mercados.Infrastructure.Persistence.Repositories +{ + public interface ICotizacionGranoRepository : IBaseRepository + { + Task GuardarMuchosAsync(IEnumerable cotizaciones); + } +} \ No newline at end of file diff --git a/src/Mercados.Worker/DataFetchingService.cs b/src/Mercados.Worker/DataFetchingService.cs new file mode 100644 index 0000000..9848bbd --- /dev/null +++ b/src/Mercados.Worker/DataFetchingService.cs @@ -0,0 +1,121 @@ +using Mercados.Infrastructure.DataFetchers; + +namespace Mercados.Worker +{ + public class DataFetchingService : BackgroundService + { + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + + // Diccionario para rastrear la última vez que se ejecutó una tarea diaria. + private readonly Dictionary _lastDailyRun = new(); + + public DataFetchingService(ILogger 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>(); + 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>(); + 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 + } +} \ No newline at end of file diff --git a/src/Mercados.Worker/Program.cs b/src/Mercados.Worker/Program.cs index 84690ec..be13d8d 100644 --- a/src/Mercados.Worker/Program.cs +++ b/src/Mercados.Worker/Program.cs @@ -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(); +// --- 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(); + + // 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(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // --- 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(); + services.AddScoped(); + //services.AddScoped(); + services.AddScoped(); + + // 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(); + }) + .Build(); + +await host.RunAsync(); \ No newline at end of file diff --git a/src/Mercados.Worker/Worker.cs b/src/Mercados.Worker/Worker.cs deleted file mode 100644 index 0e00bb3..0000000 --- a/src/Mercados.Worker/Worker.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Mercados.Worker; - -public class Worker : BackgroundService -{ - private readonly ILogger _logger; - - public Worker(ILogger 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); - } - } -} diff --git a/src/Mercados.Worker/appsettings.json b/src/Mercados.Worker/appsettings.json index b2dcdb6..5644844 100644 --- a/src/Mercados.Worker/appsettings.json +++ b/src/Mercados.Worker/appsettings.json @@ -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" } } -} +} \ No newline at end of file