diff --git a/frontend/src/components/BolsaLocalWidget.tsx b/frontend/src/components/BolsaLocalWidget.tsx index 0714ff9..3d6455e 100644 --- a/frontend/src/components/BolsaLocalWidget.tsx +++ b/frontend/src/components/BolsaLocalWidget.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react'; +// Importaciones de React y Material-UI +import React, { useState, useRef } from 'react'; import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Typography, Dialog, DialogTitle, @@ -8,12 +9,19 @@ import CloseIcon from '@mui/icons-material/Close'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import RemoveIcon from '@mui/icons-material/Remove'; -import { formatFullDateTime, formatCurrency } from '../utils/formatters'; -import type { CotizacionBolsa } from '../models/mercadoModels'; -import { useApiData } from '../hooks/useApiData'; -import { HistoricalChartWidget } from './HistoricalChartWidget'; import { PiChartLineUpBold } from 'react-icons/pi'; +// Importaciones de nuestros modelos, hooks y utilidades +import type { CotizacionBolsa } from '../models/mercadoModels'; +import { useApiData } from '../hooks/useApiData'; +import { useIsHoliday } from '../hooks/useIsHoliday'; +import { formatFullDateTime, formatCurrency } from '../utils/formatters'; +import { HistoricalChartWidget } from './HistoricalChartWidget'; +import { HolidayAlert } from './common/HolidayAlert'; + +/** + * Sub-componente para mostrar la variación porcentual con un icono y color apropiado. + */ const Variacion = ({ value }: { value: number }) => { const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary'; const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon; @@ -25,91 +33,155 @@ const Variacion = ({ value }: { value: number }) => { ); }; +/** + * Sub-componente que renderiza la tabla de acciones detalladas. + * Se extrae para mantener el componente principal más limpio. + */ +const RenderContent = ({ data, handleOpenModal }: { + data: CotizacionBolsa[], + handleOpenModal: (ticker: string, event: React.MouseEvent) => void, +}) => { + // Filtramos para obtener solo las acciones, excluyendo el índice. + const panelPrincipal = data.filter(d => d.ticker !== '^MERV'); + + if (panelPrincipal.length === 0) { + return No hay acciones líderes para mostrar en este momento.; + } + + return ( + + + + Última actualización de acciones: {formatFullDateTime(panelPrincipal[0].fechaRegistro)} + + + + + + Símbolo + Precio Actual + Apertura + Cierre Anterior + % Cambio + Historial + + + + {panelPrincipal.map((row) => ( + + {row.ticker} + ${formatCurrency(row.precioActual)} + ${formatCurrency(row.apertura)} + ${formatCurrency(row.cierreAnterior)} + + + handleOpenModal(row.ticker, event)} + sx={{ + boxShadow: '0 1px 3px rgba(0,0,0,0.1)', + transition: 'all 0.2s ease-in-out', + '&:hover': { transform: 'scale(1.1)', boxShadow: '0 2px 6px rgba(0,0,0,0.2)' } + }} + > + + + + + ))} + +
+
+ ); +}; + + +/** + * Widget principal para la sección "Bolsa Local". + * Muestra una tarjeta de héroe para el MERVAL y una tabla detallada para las acciones líderes. + */ export const BolsaLocalWidget = () => { - const { data, loading, error } = useApiData('/mercados/bolsa/local'); + // Hooks para obtener los datos y el estado de feriado. Las llamadas se disparan en paralelo. + const { data, loading: dataLoading, error: dataError } = useApiData('/mercados/bolsa/local'); + const isHoliday = useIsHoliday('BA'); + + // Estado y referencia para manejar el modal del gráfico. const [selectedTicker, setSelectedTicker] = useState(null); + const triggerButtonRef = useRef(null); - const handleRowClick = (ticker: string) => setSelectedTicker(ticker); - const handleCloseDialog = () => setSelectedTicker(null); + const handleOpenModal = (ticker: string, event: React.MouseEvent) => { + triggerButtonRef.current = event.currentTarget; + setSelectedTicker(ticker); + }; + + const handleCloseDialog = () => { + setSelectedTicker(null); + // Devuelve el foco al botón que abrió el modal para mejorar la accesibilidad. + setTimeout(() => { + triggerButtonRef.current?.focus(); + }, 0); + }; + + // Estado de carga unificado: el componente está "cargando" si los datos principales + // o la información del feriado todavía no han llegado. + const isLoading = dataLoading || isHoliday === null; - const panelPrincipal = data?.filter(d => d.ticker !== '^MERV') || []; - - if (loading) { + if (isLoading) { return ; } - if (error) { - return {error}; + if (dataError) { + return {dataError}; } - + + // Si no hay ningún dato en absoluto, mostramos un mensaje final. if (!data || data.length === 0) { - return No hay datos disponibles para el mercado local.; + // Si sabemos que es feriado, la alerta de feriado tiene prioridad. + if (isHoliday) { + return ; + } + return No hay datos disponibles para el mercado local.; } return ( <> - - {panelPrincipal.length > 0 && ( - - - - Última actualización de acciones: {formatFullDateTime(panelPrincipal[0].fechaRegistro)} - - - - - - Símbolo - Precio Actual - Apertura - Cierre Anterior - % Cambio - Historial - - - - {panelPrincipal.map((row) => ( - - {row.ticker} - ${formatCurrency(row.precioActual)} - ${formatCurrency(row.apertura)} - ${formatCurrency(row.cierreAnterior)} - - - handleRowClick(row.ticker)} - sx={{ - boxShadow: '0 1px 3px rgba(0,0,0,0.1)', - transition: 'all 0.2s ease-in-out', - '&:hover': { transform: 'scale(1.1)', boxShadow: '0 2px 6px rgba(0,0,0,0.2)' } - }} - > - - - - - ))} - -
-
+ {/* Si es feriado, mostramos la alerta informativa en la parte superior. */} + {isHoliday && ( + + + )} + {/* La tabla de acciones detalladas se muestra siempre que haya datos para ella. */} + + + {/* El Dialog para mostrar el gráfico histórico. */} theme.palette.grey[500], - backgroundColor: 'white', boxShadow: 3, '&:hover': { backgroundColor: 'grey.100' }, + position: 'absolute', top: -15, right: -15, + color: (theme) => theme.palette.grey[500], + backgroundColor: 'white', boxShadow: 3, + '&:hover': { backgroundColor: 'grey.100' }, }} > - Historial de 30 días para: {selectedTicker} + + Historial de 30 días para: {selectedTicker} + {selectedTicker && } diff --git a/frontend/src/components/GranosCardWidget.tsx b/frontend/src/components/GranosCardWidget.tsx index a7f85db..55fcebe 100644 --- a/frontend/src/components/GranosCardWidget.tsx +++ b/frontend/src/components/GranosCardWidget.tsx @@ -17,6 +17,9 @@ import { formatInteger, formatDateOnly } from '../utils/formatters'; import { GrainsHistoricalChartWidget } from './GrainsHistoricalChartWidget'; import { LuBean } from 'react-icons/lu'; +import { useIsHoliday } from '../hooks/useIsHoliday'; +import { HolidayAlert } from './common/HolidayAlert'; + const getGrainIcon = (nombre: string) => { switch (nombre.toLowerCase()) { case 'girasol': return ; @@ -68,7 +71,7 @@ const GranoCard = ({ grano, onChartClick }: { grano: CotizacionGrano, onChartCli {getGrainIcon(grano.nombre)} - {grano.nombre} + {grano.nombre} @@ -96,6 +99,7 @@ const GranoCard = ({ grano, onChartClick }: { grano: CotizacionGrano, onChartCli export const GranosCardWidget = () => { const { data, loading, error } = useApiData('/mercados/granos'); + const isHoliday = useIsHoliday('BA'); const [selectedGrano, setSelectedGrano] = useState(null); const triggerButtonRef = useRef(null); @@ -107,11 +111,12 @@ export const GranosCardWidget = () => { const handleCloseDialog = () => { setSelectedGrano(null); setTimeout(() => { - triggerButtonRef.current?.focus(); + triggerButtonRef.current?.focus(); }, 0); }; if (loading) { + // El spinner de carga sigue siendo prioritario return ; } @@ -125,41 +130,47 @@ export const GranosCardWidget = () => { return ( <> - - {data.map((grano) => ( + {/* Si es feriado (y la comprobación ha terminado), mostramos la alerta encima */} + {isHoliday === true && ( + {/* Añadimos un margen inferior a la alerta */} + + + )} + + {data.map((grano) => ( handleChartClick(grano.nombre, event)} /> ))} theme.palette.grey[500], - backgroundColor: 'white', - boxShadow: 3, // Añade una sombra para que destaque - '&:hover': { - backgroundColor: 'grey.100', // Un leve cambio de color al pasar el mouse - }, - }} - > - - + aria-label="close" + onClick={handleCloseDialog} + sx={{ + position: 'absolute', + top: -15, // Mueve el botón hacia arriba, fuera del Dialog + right: -15, // Mueve el botón hacia la derecha, fuera del Dialog + color: (theme) => theme.palette.grey[500], + backgroundColor: 'white', + boxShadow: 3, // Añade una sombra para que destaque + '&:hover': { + backgroundColor: 'grey.100', // Un leve cambio de color al pasar el mouse + }, + }} + > + + Mensual de {selectedGrano} {selectedGrano && } diff --git a/frontend/src/components/MervalHeroCard.tsx b/frontend/src/components/MervalHeroCard.tsx index 620cd31..1be9f45 100644 --- a/frontend/src/components/MervalHeroCard.tsx +++ b/frontend/src/components/MervalHeroCard.tsx @@ -52,8 +52,8 @@ export const MervalHeroCard = () => { - Índice S&P MERVAL - {formatCurrency2Decimal(mervalData.precioActual)} + Índice S&P MERVAL + {formatCurrency2Decimal(mervalData.precioActual)} diff --git a/frontend/src/components/UsaIndexHeroCard.tsx b/frontend/src/components/UsaIndexHeroCard.tsx index 4a55b27..3904efe 100644 --- a/frontend/src/components/UsaIndexHeroCard.tsx +++ b/frontend/src/components/UsaIndexHeroCard.tsx @@ -51,8 +51,8 @@ export const UsaIndexHeroCard = () => { - S&P 500 Index - {formatInteger(indexData.precioActual)} + S&P 500 Index + {formatInteger(indexData.precioActual)} diff --git a/frontend/src/components/common/HolidayAlert.tsx b/frontend/src/components/common/HolidayAlert.tsx new file mode 100644 index 0000000..3730fa6 --- /dev/null +++ b/frontend/src/components/common/HolidayAlert.tsx @@ -0,0 +1,10 @@ +import { Alert } from '@mui/material'; +import CelebrationIcon from '@mui/icons-material/Celebration'; + +export const HolidayAlert = () => { + return ( + }> + Mercado cerrado por feriado. + + ); +}; \ No newline at end of file diff --git a/frontend/src/hooks/useIsHoliday.ts b/frontend/src/hooks/useIsHoliday.ts new file mode 100644 index 0000000..bbf141d --- /dev/null +++ b/frontend/src/hooks/useIsHoliday.ts @@ -0,0 +1,24 @@ +import { useState, useEffect } from 'react'; +import apiClient from '../api/apiClient'; + +export function useIsHoliday(marketCode: 'BA' | 'US') { + const [isHoliday, setIsHoliday] = useState(null); + + useEffect(() => { + const checkHoliday = async () => { + try { + const response = await apiClient.get(`/api/mercados/es-feriado/${marketCode}`); + setIsHoliday(response.data); + console.log(`Feriado para ${marketCode}: ${response.data}`); + } catch (error) { + console.error(`Error al verificar feriado para ${marketCode}:`, error); + // Si la API de feriados falla, asumimos que no es feriado para no bloquear la UI. + setIsHoliday(false); + } + }; + + checkHoliday(); + }, [marketCode]); + + return isHoliday; +} \ No newline at end of file diff --git a/src/Mercados.Api/Controllers/MercadosController.cs b/src/Mercados.Api/Controllers/MercadosController.cs index f657d09..f3dfd37 100644 --- a/src/Mercados.Api/Controllers/MercadosController.cs +++ b/src/Mercados.Api/Controllers/MercadosController.cs @@ -1,5 +1,6 @@ using Mercados.Core.Entities; using Mercados.Infrastructure.Persistence.Repositories; +using Mercados.Infrastructure.Services; using Microsoft.AspNetCore.Mvc; namespace Mercados.Api.Controllers @@ -11,6 +12,7 @@ namespace Mercados.Api.Controllers private readonly ICotizacionBolsaRepository _bolsaRepo; private readonly ICotizacionGranoRepository _granoRepo; private readonly ICotizacionGanadoRepository _ganadoRepo; + private readonly IHolidayService _holidayService; private readonly ILogger _logger; // Inyectamos TODOS los repositorios que necesita el controlador. @@ -18,11 +20,13 @@ namespace Mercados.Api.Controllers ICotizacionBolsaRepository bolsaRepo, ICotizacionGranoRepository granoRepo, ICotizacionGanadoRepository ganadoRepo, + IHolidayService holidayService, ILogger logger) { _bolsaRepo = bolsaRepo; _granoRepo = granoRepo; _ganadoRepo = ganadoRepo; + _holidayService = holidayService; _logger = logger; } @@ -147,5 +151,30 @@ namespace Mercados.Api.Controllers return StatusCode(500, "Ocurrió un error interno en el servidor."); } } + + [HttpGet("es-feriado/{mercado}")] + [ProducesResponseType(typeof(bool), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task IsMarketHoliday(string mercado) + { + try + { + // Usamos la fecha actual en la zona horaria de Argentina + TimeZoneInfo argentinaTimeZone; + try { argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires"); } + catch { argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time"); } + + var todayInArgentina = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, argentinaTimeZone); + + var esFeriado = await _holidayService.IsMarketHolidayAsync(mercado.ToUpper(), todayInArgentina); + return Ok(esFeriado); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error al comprobar si es feriado para el mercado {Mercado}.", mercado); + // Si hay un error, devolvemos 'false' para no bloquear la UI innecesariamente. + return Ok(false); + } + } } } \ No newline at end of file diff --git a/src/Mercados.Api/Program.cs b/src/Mercados.Api/Program.cs index b94dee4..39a6822 100644 --- a/src/Mercados.Api/Program.cs +++ b/src/Mercados.Api/Program.cs @@ -5,6 +5,7 @@ using Mercados.Infrastructure.Persistence; using Mercados.Infrastructure.Persistence.Repositories; using Mercados.Api.Utils; using Microsoft.AspNetCore.HttpOverrides; +using Mercados.Infrastructure.Services; var builder = WebApplication.CreateBuilder(args); @@ -32,6 +33,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddMemoryCache(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Configuración de FluentMigrator (perfecto) builder.Services diff --git a/src/Mercados.Core/Entities/MercadoFeriado.cs b/src/Mercados.Core/Entities/MercadoFeriado.cs new file mode 100644 index 0000000..0e471cb --- /dev/null +++ b/src/Mercados.Core/Entities/MercadoFeriado.cs @@ -0,0 +1,10 @@ +namespace Mercados.Core.Entities +{ + public class MercadoFeriado + { + public long Id { get; set; } + public string CodigoMercado { get; set; } = string.Empty; // "US" o "BA" + public DateTime Fecha { get; set; } + public string? Nombre { get; set; } // Nombre del feriado, si la API lo provee + } +} \ No newline at end of file diff --git a/src/Mercados.Database/Migrations/20240702133000_AddNameToStocks.cs b/src/Mercados.Database/Migrations/20250702133000_AddNameToStocks.cs similarity index 92% rename from src/Mercados.Database/Migrations/20240702133000_AddNameToStocks.cs rename to src/Mercados.Database/Migrations/20250702133000_AddNameToStocks.cs index f5b3df5..9844edb 100644 --- a/src/Mercados.Database/Migrations/20240702133000_AddNameToStocks.cs +++ b/src/Mercados.Database/Migrations/20250702133000_AddNameToStocks.cs @@ -2,7 +2,7 @@ using FluentMigrator; namespace Mercados.Database.Migrations { - [Migration(20240702133000)] + [Migration(20250702133000)] public class AddNameToStocks : Migration { public override void Up() diff --git a/src/Mercados.Database/Migrations/20250714150000_CreateMercadoFeriadoTable.cs b/src/Mercados.Database/Migrations/20250714150000_CreateMercadoFeriadoTable.cs new file mode 100644 index 0000000..078704a --- /dev/null +++ b/src/Mercados.Database/Migrations/20250714150000_CreateMercadoFeriadoTable.cs @@ -0,0 +1,31 @@ +using FluentMigrator; + +namespace Mercados.Database.Migrations +{ + [Migration(20250714150000)] + public class CreateMercadoFeriadoTable : Migration + { + private const string TableName = "MercadosFeriados"; + + public override void Up() + { + Create.Table(TableName) + .WithColumn("Id").AsInt64().PrimaryKey().Identity() + .WithColumn("CodigoMercado").AsString(10).NotNullable() + .WithColumn("Fecha").AsDate().NotNullable() // Usamos AsDate() para guardar solo la fecha + .WithColumn("Nombre").AsString(255).Nullable(); + + // Creamos un índice para buscar rápidamente por mercado y fecha + Create.Index($"IX_{TableName}_CodigoMercado_Fecha") + .OnTable(TableName) + .OnColumn("CodigoMercado").Ascending() + .OnColumn("Fecha").Ascending() + .WithOptions().Unique(); + } + + public override void Down() + { + Delete.Table(TableName); + } + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/DataFetchers/HolidayDataFetcher.cs b/src/Mercados.Infrastructure/DataFetchers/HolidayDataFetcher.cs new file mode 100644 index 0000000..9638307 --- /dev/null +++ b/src/Mercados.Infrastructure/DataFetchers/HolidayDataFetcher.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System.Net.Http.Json; +using Mercados.Core.Entities; +using Mercados.Infrastructure.Persistence.Repositories; +using System.Text.Json.Serialization; + +namespace Mercados.Infrastructure.DataFetchers +{ + // Añadimos las clases DTO necesarias para deserializar la respuesta de Finnhub + public class MarketHolidayResponse + { + [JsonPropertyName("data")] + public List? Data { get; set; } + } + public class MarketHoliday + { + [JsonPropertyName("at")] + public string? At { get; set; } + + [JsonIgnore] + public DateOnly Date => DateOnly.FromDateTime(DateTime.Parse(At!)); + } + + public class HolidayDataFetcher : IDataFetcher + { + public string SourceName => "Holidays"; + private readonly string[] _marketCodes = { "US", "BA" }; + + private readonly IHttpClientFactory _httpClientFactory; + private readonly IMercadoFeriadoRepository _feriadoRepository; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public HolidayDataFetcher( + IHttpClientFactory httpClientFactory, + IMercadoFeriadoRepository feriadoRepository, + IConfiguration configuration, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _feriadoRepository = feriadoRepository; + _configuration = configuration; + _logger = logger; + } + + public async Task<(bool Success, string Message)> FetchDataAsync() + { + _logger.LogInformation("Iniciando actualización de feriados desde Finnhub."); + var apiKey = _configuration["ApiKeys:Finnhub"]; + if (string.IsNullOrEmpty(apiKey)) return (false, "API Key de Finnhub no configurada."); + + var client = _httpClientFactory.CreateClient("FinnhubDataFetcher"); + + foreach (var marketCode in _marketCodes) + { + try + { + var apiUrl = $"https://finnhub.io/api/v1/stock/market-holiday?exchange={marketCode}&token={apiKey}"; + // Ahora la deserialización funcionará porque la clase existe + var response = await client.GetFromJsonAsync(apiUrl); + + if (response?.Data != null) + { + var nuevosFeriados = response.Data.Select(h => new MercadoFeriado + { + CodigoMercado = marketCode, + Fecha = h.Date.ToDateTime(TimeOnly.MinValue), + Nombre = "Feriado Bursátil" + }).ToList(); + + await _feriadoRepository.ReemplazarFeriadosPorMercadoAsync(marketCode, nuevosFeriados); + _logger.LogInformation("Feriados para {MarketCode} actualizados exitosamente: {Count} registros.", marketCode, nuevosFeriados.Count); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Falló la obtención de feriados para {MarketCode}.", marketCode); + } + } + return (true, "Actualización de feriados completada."); + } + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Mercados.Infrastructure.csproj b/src/Mercados.Infrastructure/Mercados.Infrastructure.csproj index d44f12e..f94cbc5 100644 --- a/src/Mercados.Infrastructure/Mercados.Infrastructure.csproj +++ b/src/Mercados.Infrastructure/Mercados.Infrastructure.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/IMercadoFeriadoRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/IMercadoFeriadoRepository.cs new file mode 100644 index 0000000..47996f5 --- /dev/null +++ b/src/Mercados.Infrastructure/Persistence/Repositories/IMercadoFeriadoRepository.cs @@ -0,0 +1,10 @@ +using Mercados.Core.Entities; + +namespace Mercados.Infrastructure.Persistence.Repositories +{ + public interface IMercadoFeriadoRepository : IBaseRepository + { + Task> ObtenerPorMercadoYAnioAsync(string codigoMercado, int anio); + Task ReemplazarFeriadosPorMercadoAsync(string codigoMercado, IEnumerable nuevosFeriados); + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/MercadoFeriadoRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/MercadoFeriadoRepository.cs new file mode 100644 index 0000000..a2fcd2f --- /dev/null +++ b/src/Mercados.Infrastructure/Persistence/Repositories/MercadoFeriadoRepository.cs @@ -0,0 +1,56 @@ +using Dapper; +using Mercados.Core.Entities; +using System.Data; + +namespace Mercados.Infrastructure.Persistence.Repositories +{ + public class MercadoFeriadoRepository : IMercadoFeriadoRepository + { + private readonly IDbConnectionFactory _connectionFactory; + + public MercadoFeriadoRepository(IDbConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task> ObtenerPorMercadoYAnioAsync(string codigoMercado, int anio) + { + using IDbConnection connection = _connectionFactory.CreateConnection(); + const string sql = @" + SELECT * FROM MercadosFeriados + WHERE CodigoMercado = @CodigoMercado AND YEAR(Fecha) = @Anio;"; + return await connection.QueryAsync(sql, new { CodigoMercado = codigoMercado, Anio = anio }); + } + + public async Task ReemplazarFeriadosPorMercadoAsync(string codigoMercado, IEnumerable nuevosFeriados) + { + using IDbConnection connection = _connectionFactory.CreateConnection(); + connection.Open(); + using var transaction = connection.BeginTransaction(); + + try + { + // Borramos todos los feriados del año en curso para ese mercado + var anio = nuevosFeriados.FirstOrDefault()?.Fecha.Year; + if (anio.HasValue) + { + const string deleteSql = "DELETE FROM MercadosFeriados WHERE CodigoMercado = @CodigoMercado AND YEAR(Fecha) = @Anio;"; + await connection.ExecuteAsync(deleteSql, new { CodigoMercado = codigoMercado, Anio = anio.Value }, transaction); + } + + // Insertamos los nuevos + const string insertSql = @" + INSERT INTO MercadosFeriados (CodigoMercado, Fecha, Nombre) + VALUES (@CodigoMercado, @Fecha, @Nombre);"; + await connection.ExecuteAsync(insertSql, nuevosFeriados, transaction); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Services/FinnhubHolidayService.cs b/src/Mercados.Infrastructure/Services/FinnhubHolidayService.cs new file mode 100644 index 0000000..7463755 --- /dev/null +++ b/src/Mercados.Infrastructure/Services/FinnhubHolidayService.cs @@ -0,0 +1,50 @@ +using Mercados.Core.Entities; +using Mercados.Infrastructure.Persistence.Repositories; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace Mercados.Infrastructure.Services +{ + public class FinnhubHolidayService : IHolidayService + { + private readonly IMercadoFeriadoRepository _feriadoRepository; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + public FinnhubHolidayService( + IMercadoFeriadoRepository feriadoRepository, + IMemoryCache cache, + ILogger logger) + { + _feriadoRepository = feriadoRepository; + _cache = cache; + _logger = logger; + } + + public async Task IsMarketHolidayAsync(string marketCode, DateTime date) + { + var dateOnly = DateOnly.FromDateTime(date); + var cacheKey = $"holidays_{marketCode}_{date.Year}"; + + if (!_cache.TryGetValue(cacheKey, out HashSet? holidays)) + { + _logger.LogInformation("Caché de feriados no encontrada para {MarketCode}. Obteniendo desde la base de datos.", marketCode); + + try + { + // Llama a NUESTRA base de datos, no a la API externa. + var feriadosDesdeDb = await _feriadoRepository.ObtenerPorMercadoYAnioAsync(marketCode, date.Year); + holidays = feriadosDesdeDb.Select(h => DateOnly.FromDateTime(h.Fecha)).ToHashSet(); + _cache.Set(cacheKey, holidays, TimeSpan.FromHours(24)); + } + catch (Exception ex) + { + _logger.LogError(ex, "No se pudo obtener la lista de feriados para {MarketCode} desde la DB.", marketCode); + return false; // Asumimos que no es feriado si la DB falla + } + } + + return holidays?.Contains(dateOnly) ?? false; + } + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Services/IHolidayService.cs b/src/Mercados.Infrastructure/Services/IHolidayService.cs new file mode 100644 index 0000000..1c36075 --- /dev/null +++ b/src/Mercados.Infrastructure/Services/IHolidayService.cs @@ -0,0 +1,16 @@ +namespace Mercados.Infrastructure.Services +{ + /// + /// Define un servicio para consultar si una fecha es feriado para un mercado. + /// + public interface IHolidayService + { + /// + /// Comprueba si la fecha dada es un feriado bursátil para el mercado especificado. + /// + /// El código del mercado (ej. "BA" para Buenos Aires, "US" para EEUU). + /// La fecha a comprobar. + /// True si es feriado, false si no lo es. + Task IsMarketHolidayAsync(string marketCode, DateTime date); + } +} \ No newline at end of file diff --git a/src/Mercados.Worker/DataFetchingService.cs b/src/Mercados.Worker/DataFetchingService.cs index a8b4d60..daee879 100644 --- a/src/Mercados.Worker/DataFetchingService.cs +++ b/src/Mercados.Worker/DataFetchingService.cs @@ -14,22 +14,23 @@ namespace Mercados.Worker private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; private readonly TimeZoneInfo _argentinaTimeZone; - - // Almacenamos las expresiones Cron parseadas para no tener que hacerlo en cada ciclo. + + // Expresiones Cron private readonly CronExpression _agroSchedule; private readonly CronExpression _bcrSchedule; private readonly CronExpression _bolsasSchedule; + private readonly CronExpression _holidaysSchedule; - // Almacenamos la próxima ejecución calculada para cada tarea. + // Próximas ejecuciones private DateTime? _nextAgroRun; private DateTime? _nextBcrRun; private DateTime? _nextBolsasRun; + private DateTime? _nextHolidaysRun; - // Diccionario para rastrear la hora de la última alerta ENVIADA por cada tarea. private readonly Dictionary _lastAlertSent = new(); - // Definimos el período de "silencio" para las alertas (ej. 4 horas). private readonly TimeSpan _alertSilencePeriod = TimeSpan.FromHours(4); + // Eliminamos IHolidayService del constructor public DataFetchingService( ILogger logger, IServiceProvider serviceProvider, @@ -55,6 +56,7 @@ namespace Mercados.Worker _agroSchedule = CronExpression.Parse(configuration["Schedules:MercadoAgroganadero"]!); _bcrSchedule = CronExpression.Parse(configuration["Schedules:BCR"]!); _bolsasSchedule = CronExpression.Parse(configuration["Schedules:Bolsas"]!); + _holidaysSchedule = CronExpression.Parse(configuration["Schedules:Holidays"]!); } /// @@ -64,44 +66,86 @@ namespace Mercados.Worker { _logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now); - // Ejecutamos una vez al inicio para tener datos frescos inmediatamente. - //await RunAllFetchersAsync(stoppingToken); + // La ejecución inicial sigue comentada + // await RunAllFetchersAsync(stoppingToken); // Calculamos las primeras ejecuciones programadas al arrancar. var utcNow = DateTime.UtcNow; _nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); _nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); _nextBolsasRun = _bolsasSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); + _nextHolidaysRun = _holidaysSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); - // Usamos un PeriodicTimer que "despierta" cada 30 segundos para revisar si hay tareas pendientes. - // Un intervalo más corto aumenta la precisión del disparo de las tareas. + // Usamos un PeriodicTimer que "despierta" cada 30 segundos. using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30)); while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) { utcNow = DateTime.UtcNow; + var nowInArgentina = TimeZoneInfo.ConvertTimeFromUtc(utcNow, _argentinaTimeZone); - // Comprobamos si ha llegado el momento de la próxima ejecución para cada tarea. + // Tarea de actualización de Feriados (semanal) + if (_nextHolidaysRun.HasValue && utcNow >= _nextHolidaysRun.Value) + { + _logger.LogInformation("Ejecutando tarea semanal de actualización de feriados."); + await RunFetcherByNameAsync("Holidays", stoppingToken); + _nextHolidaysRun = _holidaysSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); + } + + // Tarea de Mercado Agroganadero (diaria) if (_nextAgroRun.HasValue && utcNow >= _nextAgroRun.Value) { - await RunFetcherByNameAsync("MercadoAgroganadero", stoppingToken); - // Inmediatamente después de ejecutar, calculamos la SIGUIENTE ocurrencia. + // Comprueba si NO es feriado en Argentina para ejecutar + if (!await IsMarketHolidayAsync("BA", nowInArgentina)) + { + await RunFetcherByNameAsync("MercadoAgroganadero", stoppingToken); + } + else { _logger.LogInformation("Ejecución de MercadoAgroganadero omitida por ser feriado."); } + + // Recalcula la próxima ejecución sin importar si corrió o fue feriado _nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); } + // Tarea de Granos BCR (diaria) if (_nextBcrRun.HasValue && utcNow >= _nextBcrRun.Value) { - await RunFetcherByNameAsync("BCR", stoppingToken); + if (!await IsMarketHolidayAsync("BA", nowInArgentina)) + { + await RunFetcherByNameAsync("BCR", stoppingToken); + } + else { _logger.LogInformation("Ejecución de BCR omitida por ser feriado."); } + _nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); } + // Tarea de Bolsas (recurrente) if (_nextBolsasRun.HasValue && utcNow >= _nextBolsasRun.Value) { - _logger.LogInformation("Ventana de ejecución para Bolsas. Iniciando en paralelo..."); - await Task.WhenAll( - RunFetcherByNameAsync("YahooFinance", stoppingToken), - RunFetcherByNameAsync("Finnhub", stoppingToken) - ); + _logger.LogInformation("Ventana de ejecución para Bolsas detectada."); + + var bolsaTasks = new List(); + + // Comprueba el mercado local (Argentina) + if (!await IsMarketHolidayAsync("BA", nowInArgentina)) + { + bolsaTasks.Add(RunFetcherByNameAsync("YahooFinance", stoppingToken)); + } + else { _logger.LogInformation("Ejecución de YahooFinance (Mercado Local) omitida por ser feriado."); } + + // Comprueba el mercado de EEUU + if (!await IsMarketHolidayAsync("US", nowInArgentina)) + { + bolsaTasks.Add(RunFetcherByNameAsync("Finnhub", stoppingToken)); + } + else { _logger.LogInformation("Ejecución de Finnhub (Mercado EEUU) omitida por ser feriado."); } + + // Si hay alguna tarea para ejecutar, las lanza en paralelo + if (bolsaTasks.Any()) + { + _logger.LogInformation("Iniciando {Count} fetcher(s) de bolsa en paralelo...", bolsaTasks.Count); + await Task.WhenAll(bolsaTasks); + } + _nextBolsasRun = _bolsasSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); } } @@ -179,5 +223,14 @@ namespace Mercados.Worker } #endregion + + // Creamos una única función para comprobar feriados que obtiene el servicio + // desde un scope. + private async Task IsMarketHolidayAsync(string marketCode, DateTime date) + { + using var scope = _serviceProvider.CreateScope(); + var holidayService = scope.ServiceProvider.GetRequiredService(); + return await holidayService.IsMarketHolidayAsync(marketCode, date); + } } } \ No newline at end of file diff --git a/src/Mercados.Worker/Program.cs b/src/Mercados.Worker/Program.cs index bf0d5eb..596ba36 100644 --- a/src/Mercados.Worker/Program.cs +++ b/src/Mercados.Worker/Program.cs @@ -31,6 +31,13 @@ IHost host = Host.CreateDefaultBuilder(args) services.AddHttpClient("BcrDataFetcher").AddPolicyHandler(GetRetryPolicy()); services.AddHttpClient("FinnhubDataFetcher").AddPolicyHandler(GetRetryPolicy()); + // Servicio de caché en memoria de .NET + services.AddMemoryCache(); + // Registramos nuestro nuevo servicio de feriados + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddHostedService(); }) .Build(); diff --git a/src/Mercados.Worker/appsettings.json b/src/Mercados.Worker/appsettings.json index d2132b6..01630f9 100644 --- a/src/Mercados.Worker/appsettings.json +++ b/src/Mercados.Worker/appsettings.json @@ -12,7 +12,8 @@ "Schedules": { "MercadoAgroganadero": "0 11 * * 1-5", "BCR": "30 11 * * 1-5", - "Bolsas": "10 11-17 * * 1-5" + "Bolsas": "10 11-17 * * 1-5", + "Holidays": "0 2 * * 1" }, "ApiKeys": { "Finnhub": "",