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:
2025-07-01 12:19:00 -03:00
parent 2fdf80f5b4
commit 10f19af9f8
15 changed files with 680 additions and 44 deletions

View 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.
}
}

View File

@@ -1,17 +1,22 @@
using FluentMigrator.Runner; // <--- AÑADIR using FluentMigrator.Runner;
using Mercados.Database.Migrations; // <--- AÑADIR using Mercados.Database.Migrations;
using Mercados.Infrastructure; using Mercados.Infrastructure;
using Mercados.Infrastructure.Persistence; using Mercados.Infrastructure.Persistence;
using System.Reflection; // <--- AÑADIR using Mercados.Infrastructure.Persistence.Repositories;
using System.Reflection;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// --- V INICIO DE NUESTRO CÓDIGO V --- // 1. Registramos nuestra fábrica de conexiones a la BD.
// 1. Registramos nuestra fábrica de conexiones.
builder.Services.AddSingleton<IDbConnectionFactory, SqlConnectionFactory>(); 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 builder.Services
.AddFluentMigratorCore() .AddFluentMigratorCore()
.ConfigureRunner(rb => rb .ConfigureRunner(rb => rb
@@ -25,8 +30,6 @@ builder.Services
.AddLogging(lb => lb.AddFluentMigratorConsole()); .AddLogging(lb => lb.AddFluentMigratorConsole());
// --- ^ FIN DE NUESTRO CÓDIGO ^ ---
// Add services to the container. // Add services to the container.
builder.Services.AddControllers(); builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
@@ -35,9 +38,7 @@ builder.Services.AddSwaggerGen();
var app = builder.Build(); var app = builder.Build();
// --- V INICIO DE NUESTRO CÓDIGO DE EJECUCIÓN V --- // 4. Ejecutar las migraciones al iniciar la aplicación (ideal para desarrollo y despliegues sencillos)
// 3. 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 // Obtenemos el "scope" de los servicios para poder solicitar el MigrationRunner
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
@@ -46,9 +47,6 @@ using (var scope = app.Services.CreateScope())
migrationRunner.MigrateUp(); migrationRunner.MigrateUp();
} }
// --- ^ FIN DE NUESTRO CÓDIGO DE EJECUCIÓN ^ ---
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {

View File

@@ -8,5 +8,12 @@
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;" "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"
}
} }
} }

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

View File

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

View File

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

View File

@@ -9,6 +9,8 @@
<PackageReference Include="Dapper" Version="2.1.66" /> <PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" /> <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> </ItemGroup>
<PropertyGroup> <PropertyGroup>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
using Mercados.Core.Entities;
namespace Mercados.Infrastructure.Persistence.Repositories
{
public interface ICotizacionGranoRepository : IBaseRepository
{
Task GuardarMuchosAsync(IEnumerable<CotizacionGrano> cotizaciones);
}
}

View 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
}
}

View File

@@ -1,7 +1,51 @@
using Mercados.Infrastructure;
using Mercados.Infrastructure.DataFetchers;
using Mercados.Infrastructure.Persistence;
using Mercados.Infrastructure.Persistence.Repositories;
using Mercados.Worker; using Mercados.Worker;
var builder = Host.CreateApplicationBuilder(args); // --- Configuración del Host ---
builder.Services.AddHostedService<Worker>(); // 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(); // --- 1. Registro de Servicios de Infraestructura ---
host.Run();
// 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();

View File

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

View File

@@ -2,7 +2,18 @@
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "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"
} }
} }
} }