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 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())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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="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>
|
||||||
|
|||||||
@@ -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;
|
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();
|
||||||
@@ -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": {
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user