Compare commits

19 Commits
docs ... main

Author SHA1 Message Date
363f71282f Fix Bootstrap y Cache 2025-09-10 15:21:29 -03:00
fb872f0889 Recolocado 2025-09-10 14:41:37 -03:00
fbe07b7ea2 Git .env 2025-09-10 14:38:18 -03:00
2a59edf050 Try Cache 2025-09-10 14:30:45 -03:00
7c5d66665e Fix: Maíz Name 2025-07-24 14:53:02 -03:00
697f093ef1 Fix: Variacion Format 2025-07-24 13:53:27 -03:00
5ebf4a4320 Fix: Gitignore JSON 2025-07-24 11:02:28 -03:00
67a2f3f449 Actualizar README.md 2025-07-24 13:35:26 +00:00
efd0dfb574 Fix: RawData Number Format 2025-07-24 10:17:33 -03:00
c10924bc35 Fix: TimeZone Fetcher. 2025-07-21 10:20:27 -03:00
24a6fc3849 Fixed Bug: Remove Logs 2025-07-18 18:48:44 -03:00
134c9399e9 Change Log Level to Debug
_logger.LogDebug Added: Agro - Feching
2025-07-18 18:18:30 -03:00
9dbf8dbe7e Fix: AgroFetcher Data Diff 2025-07-18 16:19:59 -03:00
c778816efd Actualizar README.md 2025-07-17 18:36:02 +00:00
681809387e Actualizar README.md 2025-07-17 18:35:41 +00:00
aadd0b218b Fix: Lost Enpoint 2025-07-17 13:58:26 -03:00
f087799191 Fix: Controller Endpoint "agroganadero" 2025-07-17 13:43:20 -03:00
e9540fa155 feat(AgroFetcher): Implement incremental updates for cattle market data
- Modified the schedule to fetch data multiple times a day (11, 15, 18, 21h).
- Refactored MercadoAgroFetcher to compare new data against the last saved records for the current day.
- Added a repository method to replace the daily batch atomically, ensuring data integrity.
- Database writes are now skipped if no changes are detected, improving efficiency."
2025-07-17 13:38:47 -03:00
23f0d02fe3 Feat: RawData Table Format 2025-07-17 11:52:00 -03:00
56 changed files with 807 additions and 1153 deletions

34
.env Normal file
View File

@@ -0,0 +1,34 @@
# =======================================================
# == Fichero de Entorno para la Aplicación de Mercados ==
# =======================================================
# Este fichero debe estar en /opt/mercados-app/.env en el servidor.
# Contiene todos los secretos y configuraciones específicas del entorno.
# --- Conexión a la Base de Datos ---
# Cadena de conexión para SQL Server.
# IMPORTANTE: El 'Server' debe ser el nombre del servicio de la base de datos en la red Docker compartida.
# Según tu arquitectura, es 'db-sqlserver'.
ConnectionStrings__DefaultConnection="Server=db-sqlserver;Database=MercadosDb;User ID=mercadosuser;Password=@mercados1351@;TrustServerCertificate=True;Encrypt=False;"
# --- Horarios de Ejecución del Worker (formato Cron) ---
Schedules__MercadoAgroganadero="0 10,12,15,18 * * 1-5"
Schedules__BCR="30 11 * * 1-5"
Schedules__Bolsas="10 11-17 * * 1-5"
# --- Claves de APIs Externas ---
ApiKeys__Finnhub="d1jsl99r01ql1h397eh0d1jsl99r01ql1h397ehg"
ApiKeys__Bcr__Key="D1782A51-A5FD-EF11-9445-00155D09E201"
ApiKeys__Bcr__Secret="da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3"
# --- Configuración de Email para Alertas (SMTP) ---
SmtpSettings__Host="192.168.5.201"
SmtpSettings__Port="587"
SmtpSettings__User="alertas@eldia.com"
SmtpSettings__Pass="@Alertas713550@"
SmtpSettings__SenderName="Servicio de Mercados"
SmtpSettings__Recipient="tecnica@eldia.com"
# --- Configuración del Entorno de ASP.NET Core ---
# Esto asegura que la aplicación se ejecute en modo Producción,
# desactivando páginas de error detalladas, etc.
ASPNETCORE_ENVIRONMENT="Production"

8
.gitignore vendored
View File

@@ -180,8 +180,6 @@ DocProject/Help/Html2
DocProject/Help/html DocProject/Help/html
# DocFx # DocFx
[Dd]ocs/ [Dd]ocs/
docfx.build.json
docfx.metadata.json
# Click-Once directory # Click-Once directory
publish/ publish/
@@ -416,4 +414,8 @@ FodyWeavers.xsd
.history/ .history/
# Built Visual Studio Code Extensions # Built Visual Studio Code Extensions
*.vsix *.vsix
.env
.env
.env

150
README.md
View File

@@ -1,3 +1,149 @@
# Mercados-Web ---
API y Worker para la recolección y exposición de datos de mercados financieros. # Proyecto de Widgets de Mercados Financieros
Este repositorio contiene la solución completa para la recolección, almacenamiento y visualización de datos de mercados financieros. La arquitectura está diseñada para ser robusta, escalable y fácil de mantener, separando las responsabilidades en un backend de .NET, un worker de fondo, y un frontend de React basado en widgets.
---
## 🚀 Arquitectura General
El sistema se compone de los siguientes elementos principales:
1. **Backend (`Mercados.Api`):** Una API web de .NET 9 que expone los datos almacenados en la base de datos a través de endpoints RESTful.
2. **Worker Service (`Mercados.Worker`):** Un servicio de fondo de .NET 9 responsable de obtener los datos de fuentes externas (`Finnhub`, `Bolsa de Comercio de Rosario`, `Yahoo Finance` y scraping) de forma programada, procesarlos y guardarlos en la base de datos.
3. **Frontend (`frontend/`):** Una aplicación de React (construida con Vite + TypeScript) que proporciona un conjunto de "widgets" modulares y portátiles para visualizar los datos.
4. **Base de Datos (`Mercados.Database`):** Utiliza SQL Server para la persistencia de los datos. El esquema es gestionado a través de migraciones con **FluentMigrator**.
5. **Documentación(En Proceso) (`docs/`):** Un portal de documentación unificado generado con **DocFX**, **TypeDoc** y **Storybook**.
---
## 🛠️ Configuración del Entorno de Desarrollo
Para ejecutar este proyecto en un entorno local, necesitarás tener instalado:
- **.NET 9 SDK**
- **Node.js** (v20 o superior) y npm
- **SQL Server** (Developer o Express Edition)
- **Docker y Docker Compose** (Opcional, para simular el entorno de producción)
- **DocFX Command Line Tool** (Instalar con: `dotnet tool install -g docfx`)
### Pasos de Configuración Inicial:
1. **Clonar el Repositorio:**
```bash
git clone [URL_DEL_REPOSITORIO_GITEA]
cd [NOMBRE_DE_LA_CARPETA_DONDE_SE_CLONÓ]
```
2. **Configurar Secretos del Backend:**
Este proyecto utiliza "User Secrets" para manejar información sensible en desarrollo. Configúralos desde la raíz del repositorio:
```bash
# Habilitar secretos para la API y el Worker
dotnet user-secrets init --project src/Mercados.Api
dotnet user-secrets init --project src/Mercados.Worker
# Establecer los valores (reemplaza con tus datos)
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=...;Database=..." --project src/Mercados.Api
dotnet user-secrets set "ApiKeys:Finnhub" "tu_api_key" --project src/Mercados.Api
# ...añadir el resto de las claves para ApiKeys y SmtpSettings
# Repite el proceso para el Worker
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "..." --project src/Mercados.Worker
# ...
```
3. **Instalar Dependencias del Frontend:**
```bash
cd frontend
npm install
cd ..
```
4. **Crear y Migrar la Base de Datos:**
Simplemente ejecuta la API una vez. La primera vez que se inicie, FluentMigrator creará la base de datos `MercadosDb` y aplicará todas las migraciones necesarias.
```bash
dotnet run --project src/Mercados.Api
```
Puedes detenerla con `Ctrl+C` una vez que veas el mensaje "Application started".
---
## 🏃 Cómo Ejecutar el Proyecto
Para trabajar en el proyecto, necesitarás ejecutar los 3 servicios principales en terminales separadas.
1. **Iniciar el Backend API:**
```bash
dotnet run --project src/Mercados.Api
```
*La API estará disponible (por defecto) en `http://localhost:5045`.*
2. **Iniciar el Worker Service:**
```bash
dotnet run --project src/Mercados.Worker
```
*El worker comenzará a obtener datos según los horarios definidos en `appsettings.json`.*
3. **Iniciar el Frontend (Servidor de Desarrollo):**
```bash
cd frontend
npm run dev
```
*La aplicación de React estará disponible en `http://localhost:5173`.*
---
## 📚 Documentación
Este proyecto incluye un portal de documentación unificado que cubre tanto el backend como el frontend.
### Para Generar la Documentación:
1. **Compilar el Backend:**
```bash
# Desde la raíz
dotnet build
```
2. **Generar la documentación TypeScript:**
```bash
# Desde la carpeta /frontend
npm run doc:ts
```
3. **Construir el sitio DocFX:**
```bash
# Desde la raíz, usando el archivo de configuración en /docs
docfx docs/docfx.json
```
4. **Visualizar la documentación:**
```bash
# Desde la raíz
docfx serve docs/_site
```
*Abre tu navegador en `http://localhost:8080`.*
### Para ver la Librería de Componentes Visuales (Storybook):
```bash
# Desde la carpeta /frontend
npm run storybook
```
*Abre tu navegador en `http://localhost:6006`.*
---
## 🐳 Despliegue en Producción (Docker)
El despliegue está diseñado para ser realizado con Docker y Docker Compose.
1. **Construir las imágenes de Docker:**
```bash
# Desde la raíz del proyecto
docker compose build
```
2. **Lanzar el stack de servicios:**
Asegúrate de tener el archivo `.env` configurado en el servidor de producción antes de ejecutar.
```bash
docker compose up -d
```
El `docker-compose.yml` está configurado para conectarse a una red externa `shared-net` para la base de datos y para ser gestionado por un proxy inverso externo como Nginx Proxy Manager.

BIN
Widgets-ELDIA-1.1.pdf Normal file

Binary file not shown.

22
docfx.build.json Normal file
View File

@@ -0,0 +1,22 @@
{
"build": {
"content": [
{
"files": [
"docs/api/**.yml",
"docs/toc.yml",
"docs/index.md"
]
}
],
"resource": [],
"output": "docs/_site",
"template": [
"default",
"modern"
],
"globalMetadata": {
"_appTitle": "Documentación Proyecto Mercados"
}
}
}

19
docfx.metadata.json Normal file
View File

@@ -0,0 +1,19 @@
{
"metadata": [
{
"src": [
{
"files": [
"src/Mercados.Api/Mercados.Api.csproj",
"src/Mercados.Core/Mercados.Core.csproj",
"src/Mercados.Database/Mercados.Database.csproj",
"src/Mercados.Infrastructure/Mercados.Infrastructure.csproj",
"src/Mercados.Worker/Mercados.Worker.csproj"
],
"src": "."
}
],
"dest": "docs/api"
}
]
}

View File

@@ -2,7 +2,7 @@ services:
# Servicio del Backend API # Servicio del Backend API
mercados-api: mercados-api:
build: build:
context: ./Mercados-Web # Asumiendo que clonaste el repo en esta carpeta context: ./Mercados-Web
dockerfile: src/Mercados.Api/Dockerfile dockerfile: src/Mercados.Api/Dockerfile
container_name: mercados-api container_name: mercados-api
restart: always restart: always
@@ -60,4 +60,4 @@ networks:
driver: bridge driver: bridge
shared-net: shared-net:
external: true external: true

View File

@@ -1,23 +1,31 @@
server { server {
listen 80; listen 80;
server_name localhost; server_name localhost;
# Directorio raíz donde están los archivos de la app
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Configuración para servir los archivos estáticos y manejar el enrutamiento de la SPA # --- BLOQUE PARA BOOTSTRAP.JS (MEJORADO) ---
location / { # Se aplica EXCLUSIVAMENTE a la petición de /bootstrap.js
# Intenta servir el archivo solicitado directamente ($uri), location = /bootstrap.js {
# luego como un directorio ($uri/), # Aseguramos que Nginx genere la huella digital ETag
# y si no encuentra nada, devuelve el index.html etag on;
# Esto es crucial para que el enrutamiento de React funcione.
try_files $uri $uri/ /index.html; # Instrucciones explícitas de no cachear
expires -1;
add_header Cache-Control "no-cache, must-revalidate, private";
try_files $uri =404;
} }
# Opcional: optimizaciones para archivos estáticos # Bloque para otros activos con hash (con caché agresiva)
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|woff|woff2)$ { location ~* \.(?:js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y; expires 1y;
add_header Cache-Control "public"; add_header Cache-Control "public";
try_files $uri =404;
}
# Bloque para el enrutamiento de la aplicación React
location / {
try_files $uri $uri/ /index.html;
} }
} }

View File

@@ -1,5 +1,4 @@
// frontend/public/bootstrap.js // frontend/public/bootstrap.js
(function() { (function() {
// El dominio donde se alojan los widgets // El dominio donde se alojan los widgets
const WIDGETS_HOST = 'https://widgets.eldia.com'; const WIDGETS_HOST = 'https://widgets.eldia.com';
@@ -27,17 +26,17 @@
// Función principal // Función principal
async function initWidgets() { async function initWidgets() {
try { try {
// 1. Obtener el manifest.json para saber los nombres de archivo actuales // 1. Obtener el manifest.json
const response = await fetch(`${WIDGETS_HOST}/manifest.json`); const response = await fetch(`${WIDGETS_HOST}/manifest.json`);
if (!response.ok) { if (!response.ok) {
throw new Error('No se pudo cargar el manifest de los widgets.'); throw new Error('No se pudo cargar el manifest de los widgets de mercados.');
} }
const manifest = await response.json(); const manifest = await response.json();
// 2. Encontrar el punto de entrada principal (nuestro main.tsx) // 2. Encontrar el punto de entrada principal
const entryKey = Object.keys(manifest).find(key => manifest[key].isEntry); const entryKey = Object.keys(manifest).find(key => manifest[key].isEntry);
if (!entryKey) { if (!entryKey) {
throw new Error('No se encontró el punto de entrada en el manifest.'); throw new Error('No se encontró el punto de entrada en el manifest de mercados.');
} }
const entry = manifest[entryKey]; const entry = manifest[entryKey];
@@ -51,12 +50,26 @@
}); });
} }
// 4. Cargar el JS principal y esperar a que esté listo // 4. Cargar el JS principal
await loadScript(jsUrl); await loadScript(jsUrl);
// 5. Una vez cargado, llamar a la función de renderizado // 5. Una vez cargado, llamar a la función de renderizado para CADA WIDGET
if (window.MercadosWidgets && typeof window.MercadosWidgets.render === 'function') { if (window.MercadosWidgets && typeof window.MercadosWidgets.render === 'function') {
window.MercadosWidgets.render(); console.log('Bootstrap Mercados: La función render existe. Renderizando widgets...');
// Buscamos los contenedores específicos para los widgets de mercados
const widgetContainers = document.querySelectorAll('[data-mercado-widget]');
if (widgetContainers.length === 0) {
console.warn('Bootstrap Mercados: No se encontraron contenedores de widget en la página.');
}
widgetContainers.forEach(container => {
// Pasamos el contenedor y su dataset (props) a la función de renderizado
window.MercadosWidgets.render(container, container.dataset);
});
} else {
console.error('Bootstrap Mercados: La función window.MercadosWidgets.render no fue encontrada.');
} }
} catch (error) { } catch (error) {
@@ -64,7 +77,11 @@
} }
} }
// Iniciar todo el proceso // Esperamos a que el DOM esté listo para ejecutar
initWidgets(); if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initWidgets);
} else {
initWidgets();
}
})(); })();

View File

@@ -27,7 +27,7 @@ const Variacion = ({ value }: { value: number }) => {
return ( return (
<Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> <Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}>
<Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> <Icon sx={{ fontSize: '1rem', mr: 0.5 }} />
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{value.toFixed(2)}%</Typography> <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{formatCurrency(value)}%</Typography>
</Box> </Box>
); );
}; };

View File

@@ -27,7 +27,7 @@ const Variacion = ({ value }: { value: number }) => {
return ( return (
<Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> <Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}>
<Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> <Icon sx={{ fontSize: '1rem', mr: 0.5 }} />
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{value.toFixed(2)}%</Typography> <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{formatCurrency(value)}%</Typography>
</Box> </Box>
); );
}; };

View File

@@ -1,33 +1,29 @@
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material'; import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, Typography } from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ContentCopyIcon from '@mui/icons-material/ContentCopy';
// Importaciones de nuestro proyecto // Importaciones de nuestro proyecto
import { useApiData } from '../../hooks/useApiData'; import { useApiData } from '../../hooks/useApiData';
import { useIsHoliday } from '../../hooks/useIsHoliday'; import { useIsHoliday } from '../../hooks/useIsHoliday';
import type { CotizacionGanado } from '../../models/mercadoModels'; import type { CotizacionGanado } from '../../models/mercadoModels';
import { formatInteger, formatCurrency, formatFullDateTime } from '../../utils/formatters'; import { formatInteger, formatFullDateTime } from '../../utils/formatters';
import { copyToClipboard } from '../../utils/clipboardUtils'; import { copyToClipboard } from '../../utils/clipboardUtils';
import { HolidayAlert } from '../common/HolidayAlert'; import { HolidayAlert } from '../common/HolidayAlert';
/** /**
* Función para convertir los datos de la tabla a un formato CSV para el portapapeles. * Función para convertir los datos a formato TSV (Tab-Separated Values)
* con el formato específico solicitado por redacción.
*/ */
const toCSV = (headers: string[], data: CotizacionGanado[]) => { const toTSV = (data: CotizacionGanado[]) => {
const headerRow = headers.join(';'); const dataRows = data.map(row => {
const dataRows = data.map(row => // Unimos Categoría y Especificaciones en una sola columna para el copiado
[ const categoriaCompleta = `${row.categoria}/${row.especificaciones}`;
row.categoria, const cabezas = formatInteger(row.cabezas);
row.especificaciones, const importeTotal = formatInteger(row.importeTotal);
formatCurrency(row.maximo),
formatCurrency(row.minimo), return [categoriaCompleta, cabezas, importeTotal].join('\t');
formatCurrency(row.mediano), });
formatInteger(row.cabezas),
formatInteger(row.kilosTotales), return dataRows.join('\n');
formatInteger(row.importeTotal),
formatFullDateTime(row.fechaRegistro)
].join(';')
);
return [headerRow, ...dataRows].join('\n');
}; };
/** /**
@@ -35,24 +31,21 @@ const toCSV = (headers: string[], data: CotizacionGanado[]) => {
* diseñado para la página de redacción. * diseñado para la página de redacción.
*/ */
export const RawAgroTable = () => { export const RawAgroTable = () => {
// Hooks para obtener los datos y el estado de feriado.
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGanado[]>('/mercados/agroganadero'); const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGanado[]>('/mercados/agroganadero');
const isHoliday = useIsHoliday('BA'); const isHoliday = useIsHoliday('BA');
const handleCopy = () => { const handleCopy = () => {
if (!data) return; if (!data) return;
const headers = ["Categoría", "Especificaciones", "Máximo", "Mínimo", "Mediano", "Cabezas", "Kg Total", "Importe Total", "Fecha de Registro"]; const tsvData = toTSV(data);
const csvData = toCSV(headers, data);
copyToClipboard(csvData) copyToClipboard(tsvData)
.then(() => alert('¡Tabla copiada al portapapeles!')) .then(() => alert('Datos del Mercado Agroganadero copiados al portapapeles!'))
.catch(err => { .catch(err => {
console.error('Error al copiar:', err); console.error('Error al copiar:', err);
alert('Error: No se pudo copiar la tabla.'); alert('Error: No se pudo copiar la tabla.');
}); });
}; };
// Estado de carga unificado.
const isLoading = dataLoading || isHoliday === null; const isLoading = dataLoading || isHoliday === null;
if (isLoading) return <CircularProgress />; if (isLoading) return <CircularProgress />;
@@ -67,43 +60,37 @@ export const RawAgroTable = () => {
return ( return (
<Box> <Box>
{/* Si es feriado, mostramos una alerta informativa encima de la tabla. */}
{isHoliday && ( {isHoliday && (
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<HolidayAlert /> <HolidayAlert />
</Box> </Box>
)} )}
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
Copiar como CSV <Button startIcon={<ContentCopyIcon />} onClick={handleCopy}>
</Button> Copiar Datos para Redacción
</Button>
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
Última actualización: {formatFullDateTime(data[0].fechaRegistro)}
</Typography>
</Box>
{/* La tabla ahora muestra solo las columnas requeridas para facilitar la visualización */}
<TableContainer component={Paper}> <TableContainer component={Paper}>
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>Categoría</TableCell> <TableCell>Categoría / Especificaciones</TableCell>
<TableCell>Especificaciones</TableCell>
<TableCell align="right">Máximo</TableCell>
<TableCell align="right">Mínimo</TableCell>
<TableCell align="right">Mediano</TableCell>
<TableCell align="right">Cabezas</TableCell> <TableCell align="right">Cabezas</TableCell>
<TableCell align="right">Kg Total</TableCell>
<TableCell align="right">Importe Total</TableCell> <TableCell align="right">Importe Total</TableCell>
<TableCell>Última Act.</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{data.map(row => ( {data.map(row => (
<TableRow key={row.id}> <TableRow key={row.id}>
<TableCell>{row.categoria}</TableCell> <TableCell>{row.categoria} / {row.especificaciones}</TableCell>
<TableCell>{row.especificaciones}</TableCell>
<TableCell align="right">${formatCurrency(row.maximo)}</TableCell>
<TableCell align="right">${formatCurrency(row.minimo)}</TableCell>
<TableCell align="right">${formatCurrency(row.mediano)}</TableCell>
<TableCell align="right">{formatInteger(row.cabezas)}</TableCell> <TableCell align="right">{formatInteger(row.cabezas)}</TableCell>
<TableCell align="right">{formatInteger(row.kilosTotales)}</TableCell>
<TableCell align="right">${formatInteger(row.importeTotal)}</TableCell> <TableCell align="right">${formatInteger(row.importeTotal)}</TableCell>
<TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@@ -1,105 +1,124 @@
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material'; import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, Typography, Divider } from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ContentCopyIcon from '@mui/icons-material/ContentCopy';
// Importaciones de nuestro proyecto
import { useApiData } from '../../hooks/useApiData'; import { useApiData } from '../../hooks/useApiData';
import { useIsHoliday } from '../../hooks/useIsHoliday';
import type { CotizacionBolsa } from '../../models/mercadoModels'; import type { CotizacionBolsa } from '../../models/mercadoModels';
import { formatCurrency, formatFullDateTime } from '../../utils/formatters'; import { formatCurrency, formatFullDateTime } from '../../utils/formatters';
import { copyToClipboard } from '../../utils/clipboardUtils'; import { copyToClipboard } from '../../utils/clipboardUtils';
import { HolidayAlert } from '../common/HolidayAlert'; import { TICKERS_PRIORITARIOS_LOCAL } from '../../config/priorityTickers';
/** /**
* Función para convertir los datos de la tabla a formato CSV. * Función para convertir los datos prioritarios a formato TSV (Tab-Separated Values).
*/ */
const toCSV = (headers: string[], data: CotizacionBolsa[]) => { const toTSV = (data: CotizacionBolsa[]) => {
const headerRow = headers.join(';'); const dataRows = data.map(row => {
const dataRows = data.map(row => // Formateamos el nombre para que quede como "GGAL.BA (GRUPO FINANCIERO GALICIA)"
[ const nombreCompleto = `${row.ticker} (${row.nombreEmpresa || ''})`;
row.ticker, const precio = `$${formatCurrency(row.precioActual)}`
row.nombreEmpresa, const cambio = `${formatCurrency(row.porcentajeCambio)}%` //`${row.porcentajeCambio.toFixed(2)}%`
formatCurrency(row.precioActual),
formatCurrency(row.cierreAnterior), // Unimos los campos con un carácter de tabulación '\t'
`${row.porcentajeCambio.toFixed(2)}%`, return [nombreCompleto, precio, cambio].join('\t');
formatFullDateTime(row.fechaRegistro) });
].join(';')
); // Unimos todas las filas con un salto de línea
return [headerRow, ...dataRows].join('\n'); return dataRows.join('\n');
}; };
/** /**
* Componente de tabla de datos crudos para la Bolsa Local (MERVAL y acciones), * Componente de tabla de datos crudos para la Bolsa Local, adaptado para redacción.
* diseñado para la página de redacción.
*/ */
export const RawBolsaLocalTable = () => { export const RawBolsaLocalTable = () => {
// Hooks para obtener los datos y el estado de feriado. const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local');
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local');
const isHoliday = useIsHoliday('BA'); // Separamos los datos en prioritarios y el resto
const priorityData = data?.filter(d => TICKERS_PRIORITARIOS_LOCAL.includes(d.ticker))
.sort((a, b) => TICKERS_PRIORITARIOS_LOCAL.indexOf(a.ticker) - TICKERS_PRIORITARIOS_LOCAL.indexOf(b.ticker)); // Mantenemos el orden
const otherData = data?.filter(d => !TICKERS_PRIORITARIOS_LOCAL.includes(d.ticker));
const handleCopy = () => { const handleCopy = () => {
if (!data) return; if (!priorityData) return;
const headers = ["Ticker", "Nombre", "Último Precio", "Cierre Anterior", "Variación %", "Fecha de Registro"]; const tsvData = toTSV(priorityData);
const csvData = toCSV(headers, data);
copyToClipboard(tsvData)
copyToClipboard(csvData) .then(() => alert('Datos prioritarios copiados al portapapeles.'))
.then(() => alert('¡Tabla copiada al portapapeles!'))
.catch(err => { .catch(err => {
console.error('Error al copiar:', err); console.error('Error al copiar:', err);
alert('Error: No se pudo copiar la tabla.'); alert('Error: No se pudo copiar la tabla.');
}); });
}; };
// Estado de carga unificado. if (loading) return <CircularProgress />;
const isLoading = dataLoading || isHoliday === null; if (error) return <Alert severity="error">{error}</Alert>;
if (!data || data.length === 0) return <Alert severity="info">No hay datos disponibles para el mercado local.</Alert>;
if (isLoading) return <CircularProgress />;
if (dataError) return <Alert severity="error">{dataError}</Alert>;
if (!data || data.length === 0) {
if (isHoliday) {
return <HolidayAlert />;
}
return <Alert severity="info">No hay datos disponibles para el mercado local.</Alert>;
}
return ( return (
<Box> <Box>
{/* Si es feriado, mostramos una alerta informativa encima de la tabla. */} <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
{isHoliday && ( <Button startIcon={<ContentCopyIcon />} onClick={handleCopy}>
<Box sx={{ mb: 2 }}> Copiar Datos Principales
<HolidayAlert /> </Button>
</Box> <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
)} Última actualización: {formatFullDateTime(data[0].fechaRegistro)}
</Typography>
</Box>
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> {/* Tabla de Datos Prioritarios */}
Copiar como CSV
</Button>
<TableContainer component={Paper}> <TableContainer component={Paper}>
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>Ticker</TableCell> <TableCell>Símbolo (Nombre)</TableCell>
<TableCell>Nombre</TableCell> <TableCell align="right">Precio Actual</TableCell>
<TableCell align="right">Último Precio</TableCell> <TableCell align="right">% Cambio</TableCell>
<TableCell align="right">Cierre Anterior</TableCell>
<TableCell align="right">Variación %</TableCell>
<TableCell>Última Act.</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{data.map(row => ( {priorityData?.map(row => (
<TableRow key={row.id}> <TableRow key={row.id}>
<TableCell>{row.ticker}</TableCell> <TableCell>
<TableCell>{row.nombreEmpresa}</TableCell> <Typography component="span" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography>
<Typography component="span" sx={{ ml: 1, color: 'text.secondary' }}>({row.nombreEmpresa})</Typography>
</TableCell>
<TableCell align="right">${formatCurrency(row.precioActual)}</TableCell> <TableCell align="right">${formatCurrency(row.precioActual)}</TableCell>
<TableCell align="right">${formatCurrency(row.cierreAnterior)}</TableCell> <TableCell align="right">{formatCurrency(row.porcentajeCambio)}%</TableCell>
<TableCell align="right">{row.porcentajeCambio.toFixed(2)}%</TableCell>
<TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
{/* Sección para Otros Tickers (solo para consulta) */}
{otherData && otherData.length > 0 && (
<Box mt={4}>
<Divider sx={{ mb: 2 }}>
<Typography variant="overline">Otros Tickers (Solo Consulta)</Typography>
</Divider>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Símbolo (Nombre)</TableCell>
<TableCell align="right">Precio Actual</TableCell>
<TableCell align="right">% Cambio</TableCell>
</TableRow>
</TableHead>
<TableBody>
{otherData.map(row => (
<TableRow key={row.id}>
<TableCell>
<Typography component="span" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography>
<Typography component="span" sx={{ ml: 1, color: 'text.secondary' }}>({row.nombreEmpresa})</Typography>
</TableCell>
<TableCell align="right">${formatCurrency(row.precioActual)}</TableCell>
<TableCell align="right">{formatCurrency(row.porcentajeCambio)}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
)}
</Box> </Box>
); );
}; };

View File

@@ -1,4 +1,4 @@
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material'; import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, Typography, Divider } from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ContentCopyIcon from '@mui/icons-material/ContentCopy';
// Importaciones de nuestro proyecto // Importaciones de nuestro proyecto
@@ -8,98 +8,128 @@ import type { CotizacionBolsa } from '../../models/mercadoModels';
import { formatCurrency, formatFullDateTime } from '../../utils/formatters'; import { formatCurrency, formatFullDateTime } from '../../utils/formatters';
import { copyToClipboard } from '../../utils/clipboardUtils'; import { copyToClipboard } from '../../utils/clipboardUtils';
import { HolidayAlert } from '../common/HolidayAlert'; import { HolidayAlert } from '../common/HolidayAlert';
import { TICKERS_PRIORITARIOS_USA } from '../../config/priorityTickers';
/** /**
* Función para convertir los datos de la tabla a formato CSV. * Función para convertir los datos prioritarios a formato TSV (Tab-Separated Values).
*/ */
const toCSV = (headers: string[], data: CotizacionBolsa[]) => { const toTSV = (data: CotizacionBolsa[]) => {
const headerRow = headers.join(';'); const dataRows = data.map(row => {
const dataRows = data.map(row => // Formateamos los datos según los requisitos de redacción
[ const nombreCompleto = `${row.ticker} (${row.nombreEmpresa || ''})`;
row.ticker, const precio = `$${formatCurrency(row.precioActual)}`;
row.nombreEmpresa, const cambio = `${formatCurrency(row.porcentajeCambio)}%`;
formatCurrency(row.precioActual, 'USD'),
formatCurrency(row.cierreAnterior, 'USD'), return [nombreCompleto, precio, cambio].join('\t');
`${row.porcentajeCambio.toFixed(2)}%`, });
formatFullDateTime(row.fechaRegistro) return dataRows.join('\n');
].join(';')
);
return [headerRow, ...dataRows].join('\n');
}; };
/** /**
* Componente de tabla de datos crudos para la Bolsa de EEUU y ADRs, * Componente de tabla de datos crudos para la Bolsa de EEUU y ADRs,
* diseñado para la página de redacción. * adaptado para las necesidades de redacción.
*/ */
export const RawBolsaUsaTable = () => { export const RawBolsaUsaTable = () => {
// Hooks para obtener los datos y el estado de feriado para el mercado de EEUU.
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu'); const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu');
const isHoliday = useIsHoliday('US'); const isHoliday = useIsHoliday('US');
// Separamos los datos en prioritarios y el resto, manteniendo el orden de la lista
const priorityData = data?.filter(d => TICKERS_PRIORITARIOS_USA.includes(d.ticker))
.sort((a, b) => TICKERS_PRIORITARIOS_USA.indexOf(a.ticker) - TICKERS_PRIORITARIOS_USA.indexOf(b.ticker));
const otherData = data?.filter(d => !TICKERS_PRIORITARIOS_USA.includes(d.ticker));
const handleCopy = () => { const handleCopy = () => {
if (!data) return; if (!priorityData) return;
const headers = ["Ticker", "Nombre", "Último Precio (USD)", "Cierre Anterior (USD)", "Variación %", "Fecha de Registro"]; const tsvData = toTSV(priorityData);
const csvData = toCSV(headers, data);
copyToClipboard(tsvData)
copyToClipboard(csvData) .then(() => alert('Datos prioritarios copiados al portapapeles!'))
.then(() => alert('¡Tabla copiada al portapapeles!'))
.catch(err => { .catch(err => {
console.error('Error al copiar:', err); console.error('Error al copiar:', err);
alert('Error: No se pudo copiar la tabla.'); alert('Error: No se pudo copiar la tabla.');
}); });
}; };
// Estado de carga unificado.
const isLoading = dataLoading || isHoliday === null; const isLoading = dataLoading || isHoliday === null;
if (isLoading) return <CircularProgress />; if (isLoading) return <CircularProgress />;
if (dataError) return <Alert severity="error">{dataError}</Alert>; if (dataError) return <Alert severity="error">{dataError}</Alert>;
if (!data || data.length === 0) { if (!data || data.length === 0) {
if (isHoliday) { if (isHoliday) { return <HolidayAlert />; }
return <HolidayAlert />; return <Alert severity="info">No hay datos disponibles para el mercado de EEUU.</Alert>;
}
return <Alert severity="info">No hay datos disponibles para el mercado de EEUU (el fetcher puede estar desactivado).</Alert>;
} }
return ( return (
<Box> <Box>
{/* Si es feriado, mostramos una alerta informativa encima de la tabla. */} {isHoliday && <Box sx={{ mb: 2 }}><HolidayAlert /></Box>}
{isHoliday && (
<Box sx={{ mb: 2 }}>
<HolidayAlert />
</Box>
)}
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
Copiar como CSV <Button startIcon={<ContentCopyIcon />} onClick={handleCopy}>
</Button> Copiar Datos Principales
</Button>
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
Última actualización: {formatFullDateTime(data[0].fechaRegistro)}
</Typography>
</Box>
{/* Tabla de Datos Prioritarios */}
<TableContainer component={Paper}> <TableContainer component={Paper}>
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>Ticker</TableCell> <TableCell>Símbolo (Nombre)</TableCell>
<TableCell>Nombre</TableCell> <TableCell align="right">Precio Actual</TableCell>
<TableCell align="right">Último Precio</TableCell> <TableCell align="right">% Cambio</TableCell>
<TableCell align="right">Cierre Anterior</TableCell>
<TableCell align="right">Variación %</TableCell>
<TableCell>Última Act.</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{data.map(row => ( {priorityData?.map(row => (
<TableRow key={row.id}> <TableRow key={row.id}>
<TableCell>{row.ticker}</TableCell> <TableCell>
<TableCell>{row.nombreEmpresa}</TableCell> <Typography component="span" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography>
<TableCell align="right">{formatCurrency(row.precioActual, 'USD')}</TableCell> <Typography component="span" sx={{ ml: 1, color: 'text.secondary' }}>({row.nombreEmpresa})</Typography>
<TableCell align="right">{formatCurrency(row.cierreAnterior, 'USD')}</TableCell> </TableCell>
<TableCell align="right">{row.porcentajeCambio.toFixed(2)}%</TableCell> <TableCell align="right">${formatCurrency(row.precioActual)}</TableCell>
<TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell> <TableCell align="right">{formatCurrency(row.porcentajeCambio)}%</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
{/* Sección para Otros Tickers (solo para consulta) */}
{otherData && otherData.length > 0 && (
<Box mt={4}>
<Divider sx={{ mb: 2 }}>
<Typography variant="overline">Otros Tickers (Solo Consulta)</Typography>
</Divider>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Símbolo (Nombre)</TableCell>
<TableCell align="right">Precio Actual</TableCell>
<TableCell align="right">% Cambio</TableCell>
</TableRow>
</TableHead>
<TableBody>
{otherData.map(row => (
<TableRow key={row.id}>
<TableCell>
<Typography component="span" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography>
<Typography component="span" sx={{ ml: 1, color: 'text.secondary' }}>({row.nombreEmpresa})</Typography>
</TableCell>
<TableCell align="right">${formatCurrency(row.precioActual)}</TableCell>
<TableCell align="right">{formatCurrency(row.porcentajeCambio)}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
)}
</Box> </Box>
); );
}; };

View File

@@ -1,29 +1,32 @@
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material'; import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, Typography } from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ContentCopyIcon from '@mui/icons-material/ContentCopy';
// Importaciones de nuestro proyecto // Importaciones de nuestro proyecto
import { useApiData } from '../../hooks/useApiData'; import { useApiData } from '../../hooks/useApiData';
import { useIsHoliday } from '../../hooks/useIsHoliday'; import { useIsHoliday } from '../../hooks/useIsHoliday';
import type { CotizacionGrano } from '../../models/mercadoModels'; import type { CotizacionGrano } from '../../models/mercadoModels';
import { formatInteger, formatDateOnly, formatFullDateTime } from '../../utils/formatters'; import { formatInteger, formatFullDateTime } from '../../utils/formatters';
import { copyToClipboard } from '../../utils/clipboardUtils'; import { copyToClipboard } from '../../utils/clipboardUtils';
import { HolidayAlert } from '../common/HolidayAlert'; import { HolidayAlert } from '../common/HolidayAlert';
/** /**
* Función para convertir los datos de la tabla a formato CSV. * Función para convertir los datos a formato TSV (Tab-Separated Values)
* con el formato específico solicitado por redacción.
*/ */
const toCSV = (headers: string[], data: CotizacionGrano[]) => { const toTSV = (data: CotizacionGrano[]) => {
const headerRow = headers.join(';'); const dataRows = data.map(row => {
const dataRows = data.map(row => // Formateamos la variación para que muestre "=" si es cero.
[ const variacion = row.variacionPrecio === 0
row.nombre, ? '= 0'
formatInteger(row.precio), : formatInteger(row.variacionPrecio);
formatInteger(row.variacionPrecio),
formatDateOnly(row.fechaOperacion), const precio = formatInteger(row.precio);
formatFullDateTime(row.fechaRegistro)
].join(';') // Unimos los campos con un carácter de tabulación '\t'
); return [row.nombre, precio, variacion].join('\t');
return [headerRow, ...dataRows].join('\n'); });
return dataRows.join('\n');
}; };
/** /**
@@ -31,24 +34,21 @@ const toCSV = (headers: string[], data: CotizacionGrano[]) => {
* diseñado para la página de redacción. * diseñado para la página de redacción.
*/ */
export const RawGranosTable = () => { export const RawGranosTable = () => {
// Hooks para obtener los datos y el estado de feriado para el mercado argentino.
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGrano[]>('/mercados/granos'); const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGrano[]>('/mercados/granos');
const isHoliday = useIsHoliday('BA'); const isHoliday = useIsHoliday('BA');
const handleCopy = () => { const handleCopy = () => {
if (!data) return; if (!data) return;
const headers = ["Grano", "Precio ($/Tn)", "Variación", "Fecha Op.", "Fecha de Registro"]; const tsvData = toTSV(data);
const csvData = toCSV(headers, data);
copyToClipboard(csvData) copyToClipboard(tsvData)
.then(() => alert('¡Tabla copiada al portapapeles!')) .then(() => alert('Datos de Granos copiados al portapapeles!'))
.catch(err => { .catch(err => {
console.error('Error al copiar:', err); console.error('Error al copiar:', err);
alert('Error: No se pudo copiar la tabla.'); alert('Error: No se pudo copiar la tabla.');
}); });
}; };
// Estado de carga unificado.
const isLoading = dataLoading || isHoliday === null; const isLoading = dataLoading || isHoliday === null;
if (isLoading) return <CircularProgress />; if (isLoading) return <CircularProgress />;
@@ -63,16 +63,21 @@ export const RawGranosTable = () => {
return ( return (
<Box> <Box>
{/* Si es feriado, mostramos una alerta informativa encima de la tabla. */}
{isHoliday && ( {isHoliday && (
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<HolidayAlert /> <HolidayAlert />
</Box> </Box>
)} )}
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
Copiar como CSV <Button startIcon={<ContentCopyIcon />} onClick={handleCopy}>
</Button> Copiar Datos para Redacción
</Button>
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
Última actualización: {formatFullDateTime(data[0].fechaRegistro)}
</Typography>
</Box>
<TableContainer component={Paper}> <TableContainer component={Paper}>
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
@@ -80,8 +85,6 @@ export const RawGranosTable = () => {
<TableCell>Grano</TableCell> <TableCell>Grano</TableCell>
<TableCell align="right">Precio ($/Tn)</TableCell> <TableCell align="right">Precio ($/Tn)</TableCell>
<TableCell align="right">Variación</TableCell> <TableCell align="right">Variación</TableCell>
<TableCell>Fecha Op.</TableCell>
<TableCell>Última Act.</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@@ -89,9 +92,7 @@ export const RawGranosTable = () => {
<TableRow key={row.id}> <TableRow key={row.id}>
<TableCell>{row.nombre}</TableCell> <TableCell>{row.nombre}</TableCell>
<TableCell align="right">${formatInteger(row.precio)}</TableCell> <TableCell align="right">${formatInteger(row.precio)}</TableCell>
<TableCell align="right">{formatInteger(row.variacionPrecio)}</TableCell> <TableCell align="right">{row.variacionPrecio === 0 ? '= 0' : formatInteger(row.variacionPrecio)}</TableCell>
<TableCell>{formatDateOnly(row.fechaOperacion)}</TableCell>
<TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@@ -0,0 +1,11 @@
export const TICKERS_PRIORITARIOS_LOCAL = [
'^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'
];
// Dejaremos las otras listas aquí para los siguientes componentes
export const TICKERS_PRIORITARIOS_USA = [
'AAPL', 'AMD', 'AMZN', 'BRK-B', 'KO', 'MSFT', 'NVDA',
'GLD', 'XLF', 'XLI', 'XLE', 'XLK', 'MELI'
];

View File

@@ -5,9 +5,6 @@ using Microsoft.AspNetCore.Mvc;
namespace Mercados.Api.Controllers namespace Mercados.Api.Controllers
{ {
/// <summary>
/// Controlador principal para exponer los datos de los mercados financieros.
/// </summary>
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
public class MercadosController : ControllerBase public class MercadosController : ControllerBase
@@ -18,14 +15,7 @@ namespace Mercados.Api.Controllers
private readonly IHolidayService _holidayService; private readonly IHolidayService _holidayService;
private readonly ILogger<MercadosController> _logger; private readonly ILogger<MercadosController> _logger;
/// <summary> // Inyectamos TODOS los repositorios que necesita el controlador.
/// Inicializa una nueva instancia del controlador MercadosController.
/// </summary>
/// <param name="bolsaRepo">Repositorio para datos de la bolsa.</param>
/// <param name="granoRepo">Repositorio para datos de granos.</param>
/// <param name="ganadoRepo">Repositorio para datos de ganado.</param>
/// <param name="holidayService">Servicio para consultar feriados.</param>
/// <param name="logger">Servicio de logging.</param>
public MercadosController( public MercadosController(
ICotizacionBolsaRepository bolsaRepo, ICotizacionBolsaRepository bolsaRepo,
ICotizacionGranoRepository granoRepo, ICotizacionGranoRepository granoRepo,
@@ -40,10 +30,7 @@ namespace Mercados.Api.Controllers
_logger = logger; _logger = logger;
} }
/// <summary> // --- Endpoint para Agroganadero ---
/// Obtiene el último parte completo del mercado agroganadero.
/// </summary>
/// <returns>Una colección de objetos CotizacionGanado.</returns>
[HttpGet("agroganadero")] [HttpGet("agroganadero")]
[ProducesResponseType(typeof(IEnumerable<CotizacionGanado>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IEnumerable<CotizacionGanado>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -51,7 +38,7 @@ namespace Mercados.Api.Controllers
{ {
try try
{ {
var data = await _ganadoRepo.ObtenerUltimaTandaAsync(); var data = await _ganadoRepo.ObtenerUltimaTandaDisponibleAsync();
return Ok(data); return Ok(data);
} }
catch (Exception ex) catch (Exception ex)
@@ -61,10 +48,7 @@ namespace Mercados.Api.Controllers
} }
} }
/// <summary> // --- Endpoint para Granos ---
/// Obtiene las últimas cotizaciones para los principales granos.
/// </summary>
/// <returns>Una colección de objetos CotizacionGrano.</returns>
[HttpGet("granos")] [HttpGet("granos")]
[ProducesResponseType(typeof(IEnumerable<CotizacionGrano>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IEnumerable<CotizacionGrano>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -83,10 +67,6 @@ namespace Mercados.Api.Controllers
} }
// --- Endpoints de Bolsa --- // --- Endpoints de Bolsa ---
/// <summary>
/// Obtiene las últimas cotizaciones para el mercado de bolsa de EEUU.
/// </summary>
/// <returns>Una colección de objetos CotizacionBolsa.</returns>
[HttpGet("bolsa/eeuu")] [HttpGet("bolsa/eeuu")]
[ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -104,10 +84,6 @@ namespace Mercados.Api.Controllers
} }
} }
/// <summary>
/// Obtiene las últimas cotizaciones para el mercado de bolsa local.
/// </summary>
/// <returns>Una colección de objetos CotizacionBolsa.</returns>
[HttpGet("bolsa/local")] [HttpGet("bolsa/local")]
[ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -125,37 +101,23 @@ namespace Mercados.Api.Controllers
} }
} }
/// <summary> [HttpGet("bolsa/history/{ticker}")]
/// Obtiene el historial de cotizaciones para un ticker específico en un mercado determinado. [ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)]
/// </summary> [ProducesResponseType(StatusCodes.Status500InternalServerError)]
/// <param name="ticker">El identificador del ticker.</param> public async Task<IActionResult> GetBolsaHistory(string ticker, [FromQuery] string mercado = "Local", [FromQuery] int dias = 30)
/// <param name="mercado">El nombre del mercado (por defecto "Local").</param> {
/// <param name="dias">Cantidad de días de historial a recuperar (por defecto 30).</param> try
/// <returns>Una colección de objetos CotizacionBolsa.</returns> {
[HttpGet("bolsa/history/{ticker}")] var data = await _bolsaRepo.ObtenerHistorialPorTickerAsync(ticker, mercado, dias);
[ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)] return Ok(data);
[ProducesResponseType(StatusCodes.Status500InternalServerError)] }
public async Task<IActionResult> GetBolsaHistory(string ticker, [FromQuery] string mercado = "Local", [FromQuery] int dias = 30) catch (Exception ex)
{ {
try _logger.LogError(ex, "Error al obtener historial para el ticker {Ticker}.", ticker);
{ return StatusCode(500, "Ocurrió un error interno en el servidor.");
var data = await _bolsaRepo.ObtenerHistorialPorTickerAsync(ticker, mercado, dias); }
return Ok(data); }
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al obtener historial para el ticker {Ticker}.", ticker);
return StatusCode(500, "Ocurrió un error interno en el servidor.");
}
}
/// <summary>
/// Obtiene el historial de cotizaciones para una categoría y especificaciones de ganado en un rango de días.
/// </summary>
/// <param name="categoria">La categoría de ganado.</param>
/// <param name="especificaciones">Las especificaciones del ganado.</param>
/// <param name="dias">Cantidad de días de historial a recuperar (por defecto 30).</param>
/// <returns>Una colección de objetos CotizacionGanado.</returns>
[HttpGet("agroganadero/history")] [HttpGet("agroganadero/history")]
[ProducesResponseType(typeof(IEnumerable<CotizacionGanado>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IEnumerable<CotizacionGanado>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -173,12 +135,6 @@ namespace Mercados.Api.Controllers
} }
} }
/// <summary>
/// Obtiene el historial de cotizaciones para un grano específico en un rango de días.
/// </summary>
/// <param name="nombre">El nombre del grano.</param>
/// <param name="dias">Cantidad de días de historial a recuperar (por defecto 30).</param>
/// <returns>Una colección de objetos CotizacionGrano.</returns>
[HttpGet("granos/history/{nombre}")] [HttpGet("granos/history/{nombre}")]
[ProducesResponseType(typeof(IEnumerable<CotizacionGrano>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IEnumerable<CotizacionGrano>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -196,11 +152,6 @@ namespace Mercados.Api.Controllers
} }
} }
/// <summary>
/// Verifica si la fecha actual es feriado para el mercado especificado.
/// </summary>
/// <param name="mercado">El nombre del mercado a consultar.</param>
/// <returns>True si es feriado, false en caso contrario.</returns>
[HttpGet("es-feriado/{mercado}")] [HttpGet("es-feriado/{mercado}")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)] [ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]

View File

@@ -5,7 +5,6 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>28c6a673-1f1e-4140-aa75-a0d894d1fbc4</UserSecretsId> <UserSecretsId>28c6a673-1f1e-4140-aa75-a0d894d1fbc4</UserSecretsId>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -9,32 +9,19 @@ namespace Mercados.Api.Utils
/// </summary> /// </summary>
public class UtcDateTimeConverter : JsonConverter<DateTime> public class UtcDateTimeConverter : JsonConverter<DateTime>
{ {
/// <summary>
/// Lee un valor DateTime desde el lector JSON y lo convierte a UTC.
/// </summary>
/// <param name="reader">El lector JSON.</param>
/// <param name="typeToConvert">El tipo a convertir.</param>
/// <param name="options">Las opciones de serialización JSON.</param>
/// <returns>El valor DateTime en UTC.</returns>
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{ {
// Al leer un string de fecha, nos aseguramos de que se interprete como UTC // Al leer un string de fecha, nos aseguramos de que se interprete como UTC
return reader.GetDateTime().ToUniversalTime(); return reader.GetDateTime().ToUniversalTime();
} }
/// <summary> public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
/// Escribe un valor DateTime en formato UTC como una cadena en el escritor JSON. {
/// </summary> // Antes de escribir el string, especificamos que el 'Kind' es Utc.
/// <param name="writer">El escritor JSON.</param> // Si ya es Utc, no hace nada. Si es Local o Unspecified, lo trata como si fuera Utc.
/// <param name="value">El valor DateTime a escribir.</param> // Esto es seguro porque sabemos que todas nuestras fechas en la BD son UTC.
/// <param name="options">Las opciones de serialización JSON.</param> var utcValue = DateTime.SpecifyKind(value, DateTimeKind.Utc);
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) writer.WriteStringValue(utcValue);
{ }
// Antes de escribir el string, especificamos que el 'Kind' es Utc.
// Si ya es Utc, no hace nada. Si es Local o Unspecified, lo trata como si fuera Utc.
// Esto es seguro porque sabemos que todas nuestras fechas en la BD son UTC.
var utcValue = DateTime.SpecifyKind(value, DateTimeKind.Utc);
writer.WriteStringValue(utcValue);
}
} }
} }

View File

@@ -1,53 +1,15 @@
namespace Mercados.Core.Entities namespace Mercados.Core.Entities
{ {
/// <summary> public class CotizacionBolsa
/// Representa una única captura de cotización para un activo de la bolsa de valores. {
/// </summary> public long Id { get; set; }
public class CotizacionBolsa public string Ticker { get; set; } = string.Empty; // "AAPL", "GGAL.BA", etc.
{ public string? NombreEmpresa { get; set; }
/// <summary> public string Mercado { get; set; } = string.Empty; // "EEUU" o "Local"
/// Identificador único del registro en la base de datos. public decimal PrecioActual { get; set; }
/// </summary> public decimal Apertura { get; set; }
public long Id { get; set; } public decimal CierreAnterior { get; set; }
public decimal PorcentajeCambio { get; set; }
/// <summary> public DateTime FechaRegistro { get; set; }
/// El símbolo o identificador del activo en el mercado (ej. "AAPL", "GGAL.BA"). }
/// </summary>
public string Ticker { get; set; } = string.Empty;
/// <summary>
/// El nombre completo de la empresa o del activo.
/// </summary>
public string? NombreEmpresa { get; set; }
/// <summary>
/// El mercado al que pertenece el activo (ej. "EEUU", "Local").
/// </summary>
public string Mercado { get; set; } = string.Empty;
/// <summary>
/// El último precio registrado para el activo.
/// </summary>
public decimal PrecioActual { get; set; }
/// <summary>
/// El precio del activo al inicio de la jornada de mercado.
/// </summary>
public decimal Apertura { get; set; }
/// <summary>
/// El precio de cierre del activo en la jornada anterior.
/// </summary>
public decimal CierreAnterior { get; set; }
/// <summary>
/// El cambio porcentual del precio actual con respecto al cierre anterior.
/// </summary>
public decimal PorcentajeCambio { get; set; }
/// <summary>
/// La fecha y hora (en UTC) en que se registró esta cotización en la base de datos.
/// </summary>
public DateTime FechaRegistro { get; set; }
}
} }

View File

@@ -1,68 +1,18 @@
namespace Mercados.Core.Entities namespace Mercados.Core.Entities
{ {
/// <summary> public class CotizacionGanado
/// Representa una cotización para una categoría de ganado en el Mercado Agroganadero. {
/// </summary> public long Id { get; set; }
public class CotizacionGanado public string Categoria { get; set; } = string.Empty;
{ public string Especificaciones { get; set; } = string.Empty;
/// <summary> public decimal Maximo { get; set; }
/// Identificador único del registro en la base de datos. public decimal Minimo { get; set; }
/// </summary> public decimal Promedio { get; set; }
public long Id { get; set; } public decimal Mediano { get; set; }
public int Cabezas { get; set; }
/// <summary> public int KilosTotales { get; set; }
/// La categoría principal del ganado (ej. "NOVILLOS", "VACAS"). public int KilosPorCabeza { get; set; }
/// </summary> public decimal ImporteTotal { get; set; }
public string Categoria { get; set; } = string.Empty; public DateTime FechaRegistro { get; set; }
}
/// <summary>
/// Detalles adicionales sobre la categoría, como raza o peso.
/// </summary>
public string Especificaciones { get; set; } = string.Empty;
/// <summary>
/// El precio máximo alcanzado para esta categoría en la jornada.
/// </summary>
public decimal Maximo { get; set; }
/// <summary>
/// El precio mínimo alcanzado para esta categoría en la jornada.
/// </summary>
public decimal Minimo { get; set; }
/// <summary>
/// El precio promedio ponderado para la categoría.
/// </summary>
public decimal Promedio { get; set; }
/// <summary>
/// El precio mediano (valor central) registrado para la categoría.
/// </summary>
public decimal Mediano { get; set; }
/// <summary>
/// El número total de cabezas de ganado comercializadas en esta categoría.
/// </summary>
public int Cabezas { get; set; }
/// <summary>
/// El peso total en kilogramos de todo el ganado comercializado.
/// </summary>
public int KilosTotales { get; set; }
/// <summary>
/// El peso promedio por cabeza de ganado.
/// </summary>
public int KilosPorCabeza { get; set; }
/// <summary>
/// El importe total monetario de las transacciones para esta categoría.
/// </summary>
public decimal ImporteTotal { get; set; }
/// <summary>
/// La fecha y hora (en UTC) en que se registró esta cotización en la base de datos.
/// </summary>
public DateTime FechaRegistro { get; set; }
}
} }

View File

@@ -1,38 +1,12 @@
namespace Mercados.Core.Entities namespace Mercados.Core.Entities
{ {
/// <summary> public class CotizacionGrano
/// Representa una cotización para un tipo de grano específico. {
/// </summary> public long Id { get; set; }
public class CotizacionGrano public string Nombre { get; set; } = string.Empty; // "Soja", "Trigo", etc.
{ public decimal Precio { get; set; }
/// <summary> public decimal VariacionPrecio { get; set; }
/// Identificador único del registro en la base de datos. public DateTime FechaOperacion { get; set; }
/// </summary> public DateTime FechaRegistro { get; set; }
public long Id { get; set; } }
/// <summary>
/// El nombre del grano (ej. "Soja", "Trigo", "Maíz").
/// </summary>
public string Nombre { get; set; } = string.Empty;
/// <summary>
/// El precio de cotización, generalmente por tonelada.
/// </summary>
public decimal Precio { get; set; }
/// <summary>
/// La variación del precio con respecto a la cotización anterior.
/// </summary>
public decimal VariacionPrecio { get; set; }
/// <summary>
/// La fecha en que se concertó la operación de la cotización.
/// </summary>
public DateTime FechaOperacion { get; set; }
/// <summary>
/// La fecha y hora (en UTC) en que se registró esta cotización en la base de datos.
/// </summary>
public DateTime FechaRegistro { get; set; }
}
} }

View File

@@ -1,31 +1,10 @@
namespace Mercados.Core.Entities namespace Mercados.Core.Entities
{ {
/// <summary> public class FuenteDato
/// Representa una fuente de datos externa desde la cual se obtiene información. {
/// Esta entidad se utiliza para auditar y monitorear la salud de los Data Fetchers. public long Id { get; set; }
/// </summary> public string Nombre { get; set; } = string.Empty; // "BCR", "Finnhub", "YahooFinance", "MercadoAgroganadero"
public class FuenteDato public DateTime UltimaEjecucionExitosa { get; set; }
{ public string? Url { get; set; }
/// <summary> }
/// Identificador único del registro en la base de datos.
/// </summary>
public long Id { get; set; }
/// <summary>
/// El nombre único que identifica a la fuente de datos (ej. "BCR", "Finnhub", "YahooFinance", "MercadoAgroganadero").
/// Este nombre coincide con la propiedad SourceName de la interfaz IDataFetcher.
/// </summary>
public string Nombre { get; set; } = string.Empty;
/// <summary>
/// La fecha y hora (en UTC) de la última vez que el Data Fetcher correspondiente
/// se ejecutó y completó su tarea exitosamente.
/// </summary>
public DateTime UltimaEjecucionExitosa { get; set; }
/// <summary>
/// La URL base o principal de la fuente de datos, para referencia.
/// </summary>
public string? Url { get; set; }
}
} }

View File

@@ -1,28 +1,10 @@
namespace Mercados.Core.Entities namespace Mercados.Core.Entities
{ {
/// <summary>
/// Representa un único día feriado para un mercado bursátil específico.
/// </summary>
public class MercadoFeriado public class MercadoFeriado
{ {
/// <summary>
/// Identificador único del registro en la base de datos.
/// </summary>
public long Id { get; set; } public long Id { get; set; }
public string CodigoMercado { get; set; } = string.Empty; // "US" o "BA"
/// <summary>
/// El código del mercado al que pertenece el feriado (ej. "US", "BA").
/// </summary>
public string CodigoMercado { get; set; } = string.Empty;
/// <summary>
/// La fecha exacta del feriado (la hora no es relevante).
/// </summary>
public DateTime Fecha { get; set; } public DateTime Fecha { get; set; }
public string? Nombre { get; set; } // Nombre del feriado, si la API lo provee
/// <summary>
/// El nombre o la descripción del feriado (si está disponible).
/// </summary>
public string? Nombre { get; set; }
} }
} }

View File

@@ -4,7 +4,6 @@
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@@ -4,7 +4,6 @@
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -2,10 +2,8 @@ using FluentMigrator;
namespace Mercados.Database.Migrations namespace Mercados.Database.Migrations
{ {
/// <summary> // El número es la versión única de esta migración.
/// Migración inicial que crea las tablas necesarias para almacenar // Usar un timestamp es una práctica común y segura.
/// las cotizaciones de ganado, granos, bolsa y fuentes de datos.
/// </summary>
[Migration(20250701113000)] [Migration(20250701113000)]
public class CreateInitialTables : Migration public class CreateInitialTables : Migration
{ {

View File

@@ -2,25 +2,15 @@ using FluentMigrator;
namespace Mercados.Database.Migrations namespace Mercados.Database.Migrations
{ {
/// <summary>
/// Migración que añade la columna 'NombreEmpresa' a la tabla 'CotizacionesBolsa'
/// para almacenar el nombre descriptivo de la acción.
/// </summary>
[Migration(20250702133000)] [Migration(20250702133000)]
public class AddNameToStocks : Migration public class AddNameToStocks : Migration
{ {
/// <summary>
/// Aplica la migración, añadiendo la columna 'NombreEmpresa'.
/// </summary>
public override void Up() public override void Up()
{ {
Alter.Table("CotizacionesBolsa") Alter.Table("CotizacionesBolsa")
.AddColumn("NombreEmpresa").AsString(255).Nullable(); .AddColumn("NombreEmpresa").AsString(255).Nullable();
} }
/// <summary>
/// Revierte la migración, eliminando la columna 'NombreEmpresa'.
/// </summary>
public override void Down() public override void Down()
{ {
Delete.Column("NombreEmpresa").FromTable("CotizacionesBolsa"); Delete.Column("NombreEmpresa").FromTable("CotizacionesBolsa");

View File

@@ -2,18 +2,11 @@ using FluentMigrator;
namespace Mercados.Database.Migrations namespace Mercados.Database.Migrations
{ {
/// <summary>
/// Migración para crear la tabla 'MercadosFeriados', que almacenará los días no laborables
/// para diferentes mercados bursátiles.
/// </summary>
[Migration(20250714150000)] [Migration(20250714150000)]
public class CreateMercadoFeriadoTable : Migration public class CreateMercadoFeriadoTable : Migration
{ {
private const string TableName = "MercadosFeriados"; private const string TableName = "MercadosFeriados";
/// <summary>
/// Define la estructura de la tabla 'MercadosFeriados' y crea un índice único.
/// </summary>
public override void Up() public override void Up()
{ {
Create.Table(TableName) Create.Table(TableName)
@@ -30,9 +23,6 @@ namespace Mercados.Database.Migrations
.WithOptions().Unique(); .WithOptions().Unique();
} }
/// <summary>
/// Revierte la migración eliminando la tabla 'MercadosFeriados'.
/// </summary>
public override void Down() public override void Down()
{ {
Delete.Table(TableName); Delete.Table(TableName);

View File

@@ -8,83 +8,37 @@ using System.Text.Json.Serialization;
namespace Mercados.Infrastructure.DataFetchers namespace Mercados.Infrastructure.DataFetchers
{ {
/// <summary>
/// Implementación de <see cref="IDataFetcher"/> para obtener datos de cotizaciones de granos
/// desde la API de la Bolsa de Comercio de Rosario (BCR).
/// </summary>
public class BcrDataFetcher : IDataFetcher public class BcrDataFetcher : IDataFetcher
{ {
#region Clases DTO para la respuesta de la API de BCR #region Clases DTO para la respuesta de la API de BCR
/// <summary>
/// DTO para la respuesta del endpoint de autenticación de BCR.
/// </summary>
private class BcrTokenResponse private class BcrTokenResponse
{ {
/// <summary>
/// Contenedor de datos del token.
/// </summary>
[JsonPropertyName("data")] [JsonPropertyName("data")]
public TokenData? Data { get; set; } public TokenData? Data { get; set; }
} }
/// <summary>
/// Contiene el token de autenticación.
/// </summary>
private class TokenData private class TokenData
{ {
/// <summary>
/// El token JWT para autenticar las solicitudes.
/// </summary>
[JsonPropertyName("token")] [JsonPropertyName("token")]
public string? Token { get; set; } public string? Token { get; set; }
} }
/// <summary>
/// DTO para la respuesta del endpoint de precios de BCR.
/// </summary>
private class BcrPreciosResponse private class BcrPreciosResponse
{ {
/// <summary>
/// Lista de precios de granos.
/// </summary>
[JsonPropertyName("data")] [JsonPropertyName("data")]
public List<BcrPrecioItem>? Data { get; set; } public List<BcrPrecioItem>? Data { get; set; }
} }
/// <summary>
/// Representa un ítem individual de precio en la respuesta de la API de BCR.
/// </summary>
private class BcrPrecioItem private class BcrPrecioItem
{ {
/// <summary>
/// El precio de cotización del grano.
/// </summary>
[JsonPropertyName("precio_Cotizacion")] [JsonPropertyName("precio_Cotizacion")]
public decimal PrecioCotizacion { get; set; } public decimal PrecioCotizacion { get; set; }
/// <summary>
/// La variación del precio respecto a la cotización anterior.
/// </summary>
[JsonPropertyName("variacion_Precio_Cotizacion")] [JsonPropertyName("variacion_Precio_Cotizacion")]
public decimal VariacionPrecioCotizacion { get; set; } public decimal VariacionPrecioCotizacion { get; set; }
/// <summary>
/// La fecha en que se realizó la operación.
/// </summary>
[JsonPropertyName("fecha_Operacion_Pizarra")] [JsonPropertyName("fecha_Operacion_Pizarra")]
public DateTime FechaOperacionPizarra { get; set; } public DateTime FechaOperacionPizarra { get; set; }
} }
#endregion #endregion
/// <inheritdoc />
public string SourceName => "BCR"; public string SourceName => "BCR";
/// <summary>
/// URL base de la API de BCR.
/// </summary>
private const string BaseUrl = "https://api.bcr.com.ar/gix/v1.0"; private const string BaseUrl = "https://api.bcr.com.ar/gix/v1.0";
/// <summary>
/// Mapeo de nombres de granos a sus IDs correspondientes en la API de BCR.
/// </summary>
private readonly Dictionary<string, int> _grainIds = new() private readonly Dictionary<string, int> _grainIds = new()
{ {
{ "Trigo", 1 }, { "Maiz", 2 }, { "Sorgo", 3 }, { "Girasol", 20 }, { "Soja", 21 } { "Trigo", 1 }, { "Maiz", 2 }, { "Sorgo", 3 }, { "Girasol", 20 }, { "Soja", 21 }
@@ -96,14 +50,6 @@ namespace Mercados.Infrastructure.DataFetchers
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ILogger<BcrDataFetcher> _logger; private readonly ILogger<BcrDataFetcher> _logger;
/// <summary>
/// Inicializa una nueva instancia de la clase <see cref="BcrDataFetcher"/>.
/// </summary>
/// <param name="httpClientFactory">Fábrica para crear instancias de HttpClient.</param>
/// <param name="cotizacionRepository">Repositorio para guardar las cotizaciones de granos.</param>
/// <param name="fuenteDatoRepository">Repositorio para gestionar la información de la fuente de datos.</param>
/// <param name="configuration">Configuración de la aplicación para acceder a las claves de API.</param>
/// <param name="logger">Logger para registrar información y errores.</param>
public BcrDataFetcher( public BcrDataFetcher(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
ICotizacionGranoRepository cotizacionRepository, ICotizacionGranoRepository cotizacionRepository,
@@ -118,7 +64,19 @@ namespace Mercados.Infrastructure.DataFetchers
_logger = logger; _logger = logger;
} }
/// <inheritdoc /> /// <summary>
/// Formatea el nombre del grano para corregir acentos u otros detalles.
/// </summary>
private string FormatearNombreGrano(string nombreOriginal)
{
if (nombreOriginal.Equals("Maiz", StringComparison.OrdinalIgnoreCase))
{
// Devuelve la versión con el caso de la primera letra original, pero con acento.
return char.IsUpper(nombreOriginal[0]) ? "Maíz" : "maíz";
}
return nombreOriginal; // Devuelve el original si no hay ninguna regla
}
public async Task<(bool Success, string Message)> FetchDataAsync() public async Task<(bool Success, string Message)> FetchDataAsync()
{ {
_logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName);
@@ -151,7 +109,7 @@ namespace Mercados.Infrastructure.DataFetchers
{ {
cotizaciones.Add(new CotizacionGrano cotizaciones.Add(new CotizacionGrano
{ {
Nombre = grain.Key, Nombre = FormatearNombreGrano(grain.Key),
Precio = latestRecord.PrecioCotizacion, Precio = latestRecord.PrecioCotizacion,
VariacionPrecio = latestRecord.VariacionPrecioCotizacion, VariacionPrecio = latestRecord.VariacionPrecioCotizacion,
FechaOperacion = latestRecord.FechaOperacionPizarra, FechaOperacion = latestRecord.FechaOperacionPizarra,
@@ -179,11 +137,6 @@ namespace Mercados.Infrastructure.DataFetchers
} }
} }
/// <summary>
/// Obtiene un token de autenticación de la API de BCR.
/// </summary>
/// <param name="client">El cliente HTTP a utilizar para la solicitud.</param>
/// <returns>El token de autenticación como una cadena de texto, o null si la operación falla.</returns>
private async Task<string?> GetAuthTokenAsync(HttpClient client) private async Task<string?> GetAuthTokenAsync(HttpClient client)
{ {
var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/Login"); var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/Login");
@@ -197,9 +150,6 @@ namespace Mercados.Infrastructure.DataFetchers
return tokenResponse?.Data?.Token; return tokenResponse?.Data?.Token;
} }
/// <summary>
/// Actualiza la información de la fuente de datos en la base de datos, registrando la última ejecución exitosa.
/// </summary>
private async Task UpdateSourceInfoAsync() private async Task UpdateSourceInfoAsync()
{ {
var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName);

View File

@@ -7,18 +7,8 @@ using System.Net.Http;
namespace Mercados.Infrastructure.DataFetchers namespace Mercados.Infrastructure.DataFetchers
{ {
/// <summary>
/// Implementación de <see cref="IDataFetcher"/> para obtener datos de cotizaciones de bolsa
/// desde la API de Finnhub.
/// </summary>
/// <remarks>
/// Utiliza la librería ThreeFourteen.Finnhub.Client para interactuar con la API.
/// </remarks>
public class FinnhubDataFetcher : IDataFetcher public class FinnhubDataFetcher : IDataFetcher
{ {
/// <summary>
/// Nombre de la fuente de datos utilizada por este fetcher.
/// </summary>
public string SourceName => "Finnhub"; public string SourceName => "Finnhub";
private readonly List<string> _tickers = new() { private readonly List<string> _tickers = new() {
// Tecnológicas y ETFs // Tecnológicas y ETFs
@@ -34,17 +24,6 @@ namespace Mercados.Infrastructure.DataFetchers
private readonly IFuenteDatoRepository _fuenteDatoRepository; private readonly IFuenteDatoRepository _fuenteDatoRepository;
private readonly ILogger<FinnhubDataFetcher> _logger; private readonly ILogger<FinnhubDataFetcher> _logger;
/// <summary>
/// Inicializa una nueva instancia de la clase <see cref="FinnhubDataFetcher"/>.
/// </summary>
/// <param name="configuration">Configuración de la aplicación para acceder a la clave de API de Finnhub.</param>
/// <param name="httpClientFactory">Fábrica para crear instancias de HttpClient.</param>
/// <param name="cotizacionRepository">Repositorio para guardar las cotizaciones de bolsa obtenidas.</param>
/// <param name="fuenteDatoRepository">Repositorio para gestionar la información de la fuente de datos (Finnhub).</param>
/// <param name="logger">Logger para registrar información y errores durante la ejecución.</param>
/// <exception cref="InvalidOperationException">
/// Se lanza si la clave de API de Finnhub no está configurada en la aplicación.
/// </exception>
public FinnhubDataFetcher( public FinnhubDataFetcher(
IConfiguration configuration, IConfiguration configuration,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
@@ -63,12 +42,6 @@ namespace Mercados.Infrastructure.DataFetchers
_logger = logger; _logger = logger;
} }
/// <summary>
/// Obtiene los datos de cotizaciones de bolsa desde la API de Finnhub para los tickers configurados
/// y los guarda en la base de datos.
/// </summary>
/// <returns>Una tupla que indica si la operación fue exitosa y un mensaje descriptivo del resultado.</returns>
/// <inheritdoc />
public async Task<(bool Success, string Message)> FetchDataAsync() public async Task<(bool Success, string Message)> FetchDataAsync()
{ {
_logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName);
@@ -115,9 +88,6 @@ namespace Mercados.Infrastructure.DataFetchers
return (true, $"Proceso completado. Se guardaron {cotizaciones.Count} registros."); return (true, $"Proceso completado. Se guardaron {cotizaciones.Count} registros.");
} }
/// <summary>
/// Actualiza la información de la fuente de datos (Finnhub) en la base de datos.
/// </summary>
private async Task UpdateSourceInfoAsync() private async Task UpdateSourceInfoAsync()
{ {
var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName);

View File

@@ -7,51 +7,24 @@ using System.Text.Json.Serialization;
namespace Mercados.Infrastructure.DataFetchers namespace Mercados.Infrastructure.DataFetchers
{ {
/// <summary> // Añadimos las clases DTO necesarias para deserializar la respuesta de Finnhub
/// DTO para deserializar la respuesta de la API de Finnhub al obtener feriados de mercado.
/// </summary>
public class MarketHolidayResponse public class MarketHolidayResponse
{ {
/// <summary>
/// Lista de feriados del mercado.
/// </summary>
[JsonPropertyName("data")] [JsonPropertyName("data")]
public List<MarketHoliday>? Data { get; set; } public List<MarketHoliday>? Data { get; set; }
} }
/// <summary>
/// Representa un feriado de mercado individual en la respuesta de la API de Finnhub.
/// </summary>
public class MarketHoliday public class MarketHoliday
{ {
/// <summary>
/// Fecha del feriado en formato de cadena (YYYY-MM-DD).
/// </summary>
[JsonPropertyName("at")] [JsonPropertyName("at")]
public string? At { get; set; } public string? At { get; set; }
/// <summary>
/// Fecha del feriado como <see cref="DateOnly"/>.
/// </summary>
[JsonIgnore] [JsonIgnore]
public DateOnly Date => DateOnly.FromDateTime(DateTime.Parse(At!)); public DateOnly Date => DateOnly.FromDateTime(DateTime.Parse(At!));
} }
/// <summary>
/// Implementación de <see cref="IDataFetcher"/> para obtener datos de feriados de mercado
/// desde la API de Finnhub.
/// </summary>
public class HolidayDataFetcher : IDataFetcher public class HolidayDataFetcher : IDataFetcher
{ {
/// <inheritdoc />
public string SourceName => "Holidays"; public string SourceName => "Holidays";
/// <summary>
/// Códigos de mercado para los cuales se obtendrán los feriados.
/// </summary>
/// <remarks>
/// "US" para Estados Unidos, "BA" para Argentina (Bolsa de Comercio de Buenos Aires).
/// </remarks>
private readonly string[] _marketCodes = { "US", "BA" }; private readonly string[] _marketCodes = { "US", "BA" };
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
@@ -59,16 +32,6 @@ namespace Mercados.Infrastructure.DataFetchers
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ILogger<HolidayDataFetcher> _logger; private readonly ILogger<HolidayDataFetcher> _logger;
/// <summary>
/// Inicializa una nueva instancia de la clase <see cref="HolidayDataFetcher"/>.
/// </summary>
/// <param name="httpClientFactory">Fábrica para crear instancias de HttpClient.</param>
/// <param name="feriadoRepository">Repositorio para gestionar los feriados de mercado en la base de datos.</param>
/// <param name="configuration">Configuración de la aplicación para acceder a la clave de API de Finnhub.</param>
/// <param name="logger">Logger para registrar información y errores durante la ejecución.</param>
/// <exception cref="InvalidOperationException">
/// Se lanza si la clave de API de Finnhub no está configurada en la aplicación.
/// </exception>
public HolidayDataFetcher( public HolidayDataFetcher(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IMercadoFeriadoRepository feriadoRepository, IMercadoFeriadoRepository feriadoRepository,
@@ -81,24 +44,14 @@ namespace Mercados.Infrastructure.DataFetchers
_logger = logger; _logger = logger;
} }
/// <summary>
/// Obtiene los datos de feriados de mercado desde la API de Finnhub y los guarda en la base de datos.
/// </summary>
/// <returns>Una tupla que indica si la operación fue exitosa y un mensaje descriptivo del resultado.</returns>
/// <inheritdoc />
public async Task<(bool Success, string Message)> FetchDataAsync() public async Task<(bool Success, string Message)> FetchDataAsync()
{ {
_logger.LogInformation("Iniciando actualización de feriados."); _logger.LogInformation("Iniciando actualización de feriados.");
// Verificamos que la API Key de Finnhub esté configurada
var apiKey = _configuration["ApiKeys:Finnhub"]; var apiKey = _configuration["ApiKeys:Finnhub"];
if (string.IsNullOrEmpty(apiKey)) if (string.IsNullOrEmpty(apiKey)) return (false, "API Key de Finnhub no configurada.");
{
return (false, "API Key de Finnhub no configurada.");
}
var client = _httpClientFactory.CreateClient("FinnhubDataFetcher"); var client = _httpClientFactory.CreateClient("FinnhubDataFetcher");
// Iteramos sobre cada código de mercado configurado
foreach (var marketCode in _marketCodes) foreach (var marketCode in _marketCodes)
{ {
try try
@@ -106,24 +59,18 @@ namespace Mercados.Infrastructure.DataFetchers
var apiUrl = $"https://finnhub.io/api/v1/stock/market-holiday?exchange={marketCode}&token={apiKey}"; var apiUrl = $"https://finnhub.io/api/v1/stock/market-holiday?exchange={marketCode}&token={apiKey}";
// Ahora la deserialización funcionará porque la clase existe // Ahora la deserialización funcionará porque la clase existe
var response = await client.GetFromJsonAsync<MarketHolidayResponse>(apiUrl); var response = await client.GetFromJsonAsync<MarketHolidayResponse>(apiUrl);
// Si obtuvimos datos en la respuesta
if (response?.Data != null) if (response?.Data != null)
{ {
// Convertimos los datos de la API al formato de nuestra entidad MercadoFeriado
var nuevosFeriados = response.Data.Select(h => new MercadoFeriado var nuevosFeriados = response.Data.Select(h => new MercadoFeriado
{ {
CodigoMercado = marketCode, CodigoMercado = marketCode,
Fecha = h.Date.ToDateTime(TimeOnly.MinValue), Fecha = h.Date.ToDateTime(TimeOnly.MinValue),
Nombre = "Feriado Bursátil" Nombre = "Feriado Bursátil"
}).ToList(); }).ToList();
// Guardamos los feriados en la base de datos, reemplazando los existentes para ese mercado
await _feriadoRepository.ReemplazarFeriadosPorMercadoAsync(marketCode, nuevosFeriados); await _feriadoRepository.ReemplazarFeriadosPorMercadoAsync(marketCode, nuevosFeriados);
_logger.LogInformation( _logger.LogInformation("Feriados para {MarketCode} actualizados exitosamente: {Count} registros.", marketCode, nuevosFeriados.Count);
"Feriados para {MarketCode} actualizados exitosamente: {Count} registros.",
marketCode,
nuevosFeriados.Count);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -131,7 +78,6 @@ namespace Mercados.Infrastructure.DataFetchers
_logger.LogError(ex, "Falló la obtención de feriados para {MarketCode}.", marketCode); _logger.LogError(ex, "Falló la obtención de feriados para {MarketCode}.", marketCode);
} }
} }
// Retornamos éxito si el proceso completo se ejecutó sin errores irrecuperables
return (true, "Actualización de feriados completada."); return (true, "Actualización de feriados completada.");
} }
} }

View File

@@ -6,39 +6,16 @@ using System.Globalization;
namespace Mercados.Infrastructure.DataFetchers namespace Mercados.Infrastructure.DataFetchers
{ {
/// <summary>
/// Implementación de <see cref="IDataFetcher"/> para obtener datos de cotizaciones de ganado
/// desde el sitio web de Mercado Agro Ganadero.
/// </summary>
/// <remarks>
/// Utiliza AngleSharp para el parsing del HTML.
/// </remarks>
public class MercadoAgroFetcher : IDataFetcher public class MercadoAgroFetcher : IDataFetcher
{ {
/// <inheritdoc />
public string SourceName => "MercadoAgroganadero"; public string SourceName => "MercadoAgroganadero";
/// <summary>
/// URL del sitio web de Mercado Agro Ganadero donde se encuentran las cotizaciones.
/// </summary>
private const string DataUrl = "https://www.mercadoagroganadero.com.ar/dll/hacienda6.dll/haciinfo000225"; private const string DataUrl = "https://www.mercadoagroganadero.com.ar/dll/hacienda6.dll/haciinfo000225";
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly ICotizacionGanadoRepository _cotizacionRepository; private readonly ICotizacionGanadoRepository _cotizacionRepository;
private readonly IFuenteDatoRepository _fuenteDatoRepository; private readonly IFuenteDatoRepository _fuenteDatoRepository;
private readonly ILogger<MercadoAgroFetcher> _logger; private readonly ILogger<MercadoAgroFetcher> _logger;
private readonly TimeZoneInfo _argentinaTimeZone;
/// <summary>
/// Inicializa una nueva instancia de la clase <see cref="MercadoAgroFetcher"/>.
/// </summary>
/// <param name="httpClientFactory">Fábrica para crear instancias de HttpClient, configuradas con políticas de reintento.</param>
/// <param name="cotizacionRepository">Repositorio para guardar las cotizaciones de ganado obtenidas.</param>
/// <param name="fuenteDatoRepository">Repositorio para gestionar la información de la fuente de datos (Mercado Agro Ganadero).</param>
/// <param name="logger">Logger para registrar información y errores durante la ejecución.</param>
/// <remarks>
/// El constructor requiere una <see cref="IHttpClientFactory"/> que debe tener configurado un cliente HTTP
/// con el nombre "MercadoAgroFetcher", y este cliente debe tener aplicada una política de reintentos (ej. con Polly).
/// </remarks>
public MercadoAgroFetcher( public MercadoAgroFetcher(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
ICotizacionGanadoRepository cotizacionRepository, ICotizacionGanadoRepository cotizacionRepository,
@@ -49,76 +26,100 @@ namespace Mercados.Infrastructure.DataFetchers
_cotizacionRepository = cotizacionRepository; _cotizacionRepository = cotizacionRepository;
_fuenteDatoRepository = fuenteDatoRepository; _fuenteDatoRepository = fuenteDatoRepository;
_logger = logger; _logger = logger;
try
{
_argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires");
}
catch (TimeZoneNotFoundException)
{
_argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time");
}
} }
/// <summary>
/// Obtiene los datos de cotizaciones de ganado desde el sitio web de Mercado Agro Ganadero,
/// los parsea y los guarda en la base de datos.
/// </summary>
/// <returns>Una tupla que indica si la operación fue exitosa y un mensaje descriptivo del resultado.</returns>
/// <inheritdoc />
public async Task<(bool Success, string Message)> FetchDataAsync() public async Task<(bool Success, string Message)> FetchDataAsync()
{ {
_logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName);
try try
{ {
var htmlContent = await GetHtmlContentAsync(); var htmlContent = await GetHtmlContentAsync();
if (string.IsNullOrEmpty(htmlContent)) if (string.IsNullOrEmpty(htmlContent)) return (false, "No se pudo obtener el contenido HTML.");
var cotizacionesNuevas = ParseHtmlToEntities(htmlContent);
if (!cotizacionesNuevas.Any())
{ {
return (false, "No se pudo obtener el contenido HTML."); return (true, "Conexión exitosa, pero no se encontraron nuevos datos de ganado.");
} }
var cotizaciones = ParseHtmlToEntities(htmlContent); var ahoraEnArgentina = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _argentinaTimeZone);
if (!cotizaciones.Any()) var hoy = DateOnly.FromDateTime(ahoraEnArgentina);
var cotizacionesViejas = await _cotizacionRepository.ObtenerTandaPorFechaAsync(hoy);
// Si el número de registros es diferente, sabemos que hay cambios.
if (cotizacionesViejas.Count() != cotizacionesNuevas.Count)
{ {
// Esto NO es un error crítico, es un estado informativo. _logger.LogInformation("El número de registros de {SourceName} ha cambiado. Actualizando...", SourceName);
_logger.LogInformation("La conexión con {SourceName} fue exitosa, pero no se encontraron datos de cotizaciones para procesar.", SourceName); }
return (true, "Conexión exitosa, pero no se encontraron nuevos datos."); else
{
// Si el número de registros es el mismo, comparamos el contenido.
// Convertimos los nuevos a un diccionario para una búsqueda rápida por clave.
var nuevasDict = cotizacionesNuevas.ToDictionary(c => $"{c.Categoria}|{c.Especificaciones}");
bool hayCambios = false;
foreach (var cotizacionVieja in cotizacionesViejas)
{
var clave = $"{cotizacionVieja.Categoria}|{cotizacionVieja.Especificaciones}";
// Buscamos si el registro viejo existe en los nuevos y comparamos los valores.
if (!nuevasDict.TryGetValue(clave, out var cotizacionNueva) ||
cotizacionVieja.Maximo != cotizacionNueva.Maximo ||
cotizacionVieja.Minimo != cotizacionNueva.Minimo ||
cotizacionVieja.Cabezas != cotizacionNueva.Cabezas ||
cotizacionVieja.KilosTotales != cotizacionNueva.KilosTotales)
{
hayCambios = true;
_logger.LogInformation("Se detectó un cambio en la categoría: {Categoria}", clave);
break; // Encontramos un cambio, no necesitamos seguir buscando.
}
}
if (!hayCambios)
{
_logger.LogInformation("No se encontraron cambios en los datos de {SourceName}. No se requiere actualización.", SourceName);
return (true, "Datos sin cambios.");
}
} }
await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); _logger.LogInformation("Se detectaron cambios en los datos de {SourceName}. Reemplazando la tanda del día...", SourceName);
await _cotizacionRepository.ReemplazarTandaDelDiaAsync(hoy, cotizacionesNuevas);
await UpdateSourceInfoAsync(); await UpdateSourceInfoAsync();
_logger.LogInformation("Fetch para {SourceName} completado exitosamente. Se guardaron {Count} registros.", SourceName, cotizaciones.Count); _logger.LogInformation("Fetch para {SourceName} completado. Se actualizaron {Count} registros.", SourceName, cotizacionesNuevas.Count);
return (true, $"Proceso completado. Se guardaron {cotizaciones.Count} registros."); return (true, $"Proceso completado. Se actualizaron {cotizacionesNuevas.Count} registros.");
} }
catch (Exception ex) catch (Exception ex)
{ {
// Un catch aquí sí es un error real (ej. 404, timeout, etc.)
_logger.LogError(ex, "Ocurrió un error durante el fetch para {SourceName}.", SourceName); _logger.LogError(ex, "Ocurrió un error durante el fetch para {SourceName}.", SourceName);
return (false, $"Error: {ex.Message}"); return (false, $"Error: {ex.Message}");
} }
} }
/// <summary>
/// Obtiene el contenido HTML de la página de cotizaciones.
/// </summary>
/// <returns>El contenido HTML como una cadena.</returns>
/// <exception cref="HttpRequestException">
/// Se lanza si la solicitud HTTP no es exitosa.
/// </exception>
private async Task<string> GetHtmlContentAsync() private async Task<string> GetHtmlContentAsync()
{ {
// Pedimos el cliente HTTP con el nombre específico que tiene la política de Polly // Pedimos el cliente HTTP con el nombre específico que tiene la política de Polly
var client = _httpClientFactory.CreateClient("MercadoAgroFetcher"); var client = _httpClientFactory.CreateClient("MercadoAgroFetcher");
// Es importante simular un navegador para evitar bloqueos. // Es importante simular un navegador para evitar bloqueos.
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"); client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36");
var response = await client.GetAsync(DataUrl); var response = await client.GetAsync(DataUrl);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
// El sitio usa una codificación específica, hay que decodificarla correctamente. // El sitio usa una codificación específica, hay que decodificarla correctamente.
var stream = await response.Content.ReadAsStreamAsync(); var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream, System.Text.Encoding.GetEncoding("windows-1252")); using var reader = new StreamReader(stream, System.Text.Encoding.GetEncoding("windows-1252"));
return await reader.ReadToEndAsync(); return await reader.ReadToEndAsync();
} }
/// <summary>
/// Parsea el contenido HTML para extraer las cotizaciones de ganado.
/// </summary>
/// <param name="html">El HTML a parsear.</param>
/// <returns>Una lista de entidades <see cref="CotizacionGanado"/>.</returns>
private List<CotizacionGanado> ParseHtmlToEntities(string html) private List<CotizacionGanado> ParseHtmlToEntities(string html)
{ {
var config = Configuration.Default; var config = Configuration.Default;
@@ -149,7 +150,7 @@ namespace Mercados.Infrastructure.DataFetchers
Categoria = celdas[1], Categoria = celdas[1],
Especificaciones = $"{celdas[2]} - {celdas[3]}", Especificaciones = $"{celdas[2]} - {celdas[3]}",
Maximo = ParseDecimal(celdas[4]), Maximo = ParseDecimal(celdas[4]),
Minimo = ParseDecimal(celdas[5]), Minimo = ParseDecimal(celdas[5]),
Promedio = ParseDecimal(celdas[6]), Promedio = ParseDecimal(celdas[6]),
Mediano = ParseDecimal(celdas[7]), Mediano = ParseDecimal(celdas[7]),
Cabezas = ParseInt(celdas[8]), Cabezas = ParseInt(celdas[8]),
@@ -162,24 +163,21 @@ namespace Mercados.Infrastructure.DataFetchers
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "No se pudo parsear una fila de la tabla. Contenido: {RowContent}", _logger.LogWarning(ex, "No se pudo parsear una fila de la tabla. Contenido: {RowContent}", string.Join(" | ", celdas));
string.Join(" | ", celdas));
} }
} }
return cotizaciones; return cotizaciones;
} }
/// <summary>
/// Actualiza la información de la fuente de datos (Mercado Agro Ganadero) en la base de datos.
/// </summary>
private async Task UpdateSourceInfoAsync() private async Task UpdateSourceInfoAsync()
{ {
var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName);
if (fuente == null) if (fuente == null)
{ {
await _fuenteDatoRepository.CrearAsync(new FuenteDato await _fuenteDatoRepository.CrearAsync(new FuenteDato
{ {
Nombre = SourceName, Nombre = SourceName,
Url = DataUrl, Url = DataUrl,
UltimaEjecucionExitosa = DateTime.UtcNow UltimaEjecucionExitosa = DateTime.UtcNow
}); });
@@ -193,33 +191,17 @@ namespace Mercados.Infrastructure.DataFetchers
} }
// --- Funciones de Ayuda para Parseo --- // --- Funciones de Ayuda para Parseo ---
/// <summary>
/// <see cref="CultureInfo"/> para el parseo de números en formato "es-AR".
/// </summary>
private readonly CultureInfo _cultureInfo = new CultureInfo("es-AR"); private readonly CultureInfo _cultureInfo = new CultureInfo("es-AR");
/// <summary>
/// Parsea una cadena a decimal, considerando el formato numérico de Argentina.
/// </summary>
/// <param name="value">La cadena a parsear.</param>
/// <returns>El valor decimal parseado.</returns>
private decimal ParseDecimal(string value) private decimal ParseDecimal(string value)
{ {
// El sitio usa '.' como separador de miles y ',' como decimal. // El sitio usa '.' como separador de miles y ',' como decimal.
// Primero quitamos el de miles, luego reemplazamos la coma decimal por un punto. // Primero quitamos el de miles, luego reemplazamos la coma decimal por un punto.
var cleanValue = value.Replace("$", "").Replace(".", "").Trim(); var cleanValue = value.Replace("$", "").Replace(".", "").Trim();
return decimal.Parse(cleanValue, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, _cultureInfo); return decimal.Parse(cleanValue, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, _cultureInfo);
} }
/// <summary>
/// Parsea una cadena a entero, quitando separadores de miles.
/// </summary>
/// <param name="value">La cadena a parsear.</param>
/// <returns>El valor entero parseado.</returns>
private int ParseInt(string value) private int ParseInt(string value)
{ {
return int.Parse(value.Replace(".", ""), CultureInfo.InvariantCulture); return int.Parse(value.Replace(".", ""), CultureInfo.InvariantCulture);
} }
} }
} }

View File

@@ -1,19 +1,13 @@
namespace Mercados.Infrastructure.DataFetchers namespace Mercados.Infrastructure.DataFetchers
{ {
/// <summary>
/// Clase estática que proporciona un mapeo entre los tickers de acciones y sus nombres descriptivos.
/// </summary>
public static class TickerNameMapping public static class TickerNameMapping
{ {
/// <summary>
/// Diccionario privado que almacena los tickers como claves y los nombres de las empresas como valores.
/// La comparación de claves no distingue entre mayúsculas y minúsculas.
/// </summary>
private static readonly Dictionary<string, string> Names = new(StringComparer.OrdinalIgnoreCase) private static readonly Dictionary<string, string> Names = new(StringComparer.OrdinalIgnoreCase)
{ {
// USA // USA
{ "SPY", "S&P 500 ETF" }, { "SPY", "S&P 500 ETF" }, // Cambiado de GSPC a SPY para Finnhub
{ "AAPL", "Apple Inc." }, { "AAPL", "Apple Inc." },
{ "MSFT", "Microsoft Corp." },
{ "AMZN", "Amazon.com, Inc." }, { "AMZN", "Amazon.com, Inc." },
{ "NVDA", "NVIDIA Corp." }, { "NVDA", "NVIDIA Corp." },
{ "AMD", "Advanced Micro Devices" }, { "AMD", "Advanced Micro Devices" },
@@ -25,7 +19,6 @@ namespace Mercados.Infrastructure.DataFetchers
{ "XLE", "Energy Select Sector SPDR" }, { "XLE", "Energy Select Sector SPDR" },
{ "XLK", "Technology Select Sector SPDR" }, { "XLK", "Technology Select Sector SPDR" },
{ "MELI", "MercadoLibre, Inc." }, { "MELI", "MercadoLibre, Inc." },
{ "MSFT", "Microsoft Corp." },
{ "GLOB", "Globant" }, { "GLOB", "Globant" },
// ADRs Argentinos que cotizan en EEUU // ADRs Argentinos que cotizan en EEUU
@@ -60,15 +53,9 @@ namespace Mercados.Infrastructure.DataFetchers
{ "MELI.BA", "MercadoLibre (CEDEAR)" }, // Aclaramos que es el CEDEAR { "MELI.BA", "MercadoLibre (CEDEAR)" }, // Aclaramos que es el CEDEAR
}; };
/// <summary>
/// Obtiene el nombre descriptivo asociado a un ticker.
/// </summary>
/// <param name="ticker">El ticker de la acción (ej. "AAPL").</param>
/// <returns>El nombre completo de la empresa si se encuentra en el mapeo; de lo contrario, null.</returns>
public static string? GetName(string ticker) public static string? GetName(string ticker)
{ {
// Devuelve el nombre si existe, o null si no se encuentra la clave. return Names.GetValueOrDefault(ticker);
return Names.TryGetValue(ticker, out var name) ? name : $"Ticker no reconocido: {ticker}";
} }
} }
} }

View File

@@ -5,24 +5,9 @@ using YahooFinanceApi;
namespace Mercados.Infrastructure.DataFetchers namespace Mercados.Infrastructure.DataFetchers
{ {
/// <summary>
/// Implementación de <see cref="IDataFetcher"/> para obtener datos de cotizaciones de bolsa
/// desde la API de Yahoo Finance.
/// </summary>
/// <remarks>
/// Utiliza la librería YahooFinanceApi para interactuar con la API.
/// </remarks>
public class YahooFinanceDataFetcher : IDataFetcher public class YahooFinanceDataFetcher : IDataFetcher
{ {
/// <inheritdoc />
public string SourceName => "YahooFinance"; public string SourceName => "YahooFinance";
/// <summary>
/// Lista de tickers a obtener de Yahoo Finance.
/// </summary>
/// <remarks>
/// Incluye el índice S&amp;P 500, acciones del Merval argentino y algunos CEDEARs.
/// </remarks>
private readonly List<string> _tickers = new() { private readonly List<string> _tickers = new() {
"^GSPC", // Índice S&P 500 "^GSPC", // Índice S&P 500
"^MERV", "GGAL.BA", "YPFD.BA", "PAMP.BA", "BMA.BA", "COME.BA", "^MERV", "GGAL.BA", "YPFD.BA", "PAMP.BA", "BMA.BA", "COME.BA",
@@ -30,36 +15,20 @@ namespace Mercados.Infrastructure.DataFetchers
"CEPU.BA", "LOMA.BA", "VALO.BA", "MELI.BA" "CEPU.BA", "LOMA.BA", "VALO.BA", "MELI.BA"
}; };
/// <summary>
/// Diccionario para almacenar el mapeo de tickers con su información de mercado (Local o EEUU).
/// </summary>
private readonly Dictionary<string, string> _tickerMarketMapping = new Dictionary<string, string>();
private readonly ICotizacionBolsaRepository _cotizacionRepository; private readonly ICotizacionBolsaRepository _cotizacionRepository;
private readonly IFuenteDatoRepository _fuenteDatoRepository; private readonly IFuenteDatoRepository _fuenteDatoRepository;
private readonly ILogger<YahooFinanceDataFetcher> _logger; private readonly ILogger<YahooFinanceDataFetcher> _logger;
/// <summary> public YahooFinanceDataFetcher(
/// Inicializa una nueva instancia de la clase <see cref="YahooFinanceDataFetcher"/>. ICotizacionBolsaRepository cotizacionRepository,
/// </summary> IFuenteDatoRepository fuenteDatoRepository,
/// <param name="cotizacionRepository">Repositorio para guardar las cotizaciones de bolsa obtenidas.</param> ILogger<YahooFinanceDataFetcher> logger)
/// <param name="fuenteDatoRepository">Repositorio para gestionar la información de la fuente de datos (Yahoo Finance).</param> {
/// <param name="logger">Logger para registrar información y errores durante la ejecución.</param> _cotizacionRepository = cotizacionRepository;
public YahooFinanceDataFetcher( _fuenteDatoRepository = fuenteDatoRepository;
ICotizacionBolsaRepository cotizacionRepository, _logger = logger;
IFuenteDatoRepository fuenteDatoRepository, }
ILogger<YahooFinanceDataFetcher> logger)
{
_cotizacionRepository = cotizacionRepository;
_fuenteDatoRepository = fuenteDatoRepository;
_logger = logger;
}
/// <summary>
/// Obtiene los datos de cotizaciones de bolsa desde la API de Yahoo Finance para los tickers configurados
/// y los guarda en la base de datos.
/// </summary>
/// <returns>Una tupla que indica si la operación fue exitosa y un mensaje descriptivo del resultado.</returns>
public async Task<(bool Success, string Message)> FetchDataAsync() public async Task<(bool Success, string Message)> FetchDataAsync()
{ {
_logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName);
@@ -72,7 +41,7 @@ namespace Mercados.Infrastructure.DataFetchers
{ {
if (sec.RegularMarketPrice == 0 || sec.RegularMarketPreviousClose == 0) continue; if (sec.RegularMarketPrice == 0 || sec.RegularMarketPreviousClose == 0) continue;
string mercado = DetermineMarket(sec.Symbol); string mercado = sec.Symbol.EndsWith(".BA") || sec.Symbol == "^MERV" ? "Local" : "EEUU";
cotizaciones.Add(new CotizacionBolsa cotizaciones.Add(new CotizacionBolsa
{ {
@@ -106,27 +75,6 @@ namespace Mercados.Infrastructure.DataFetchers
} }
} }
/// <summary>
/// Determina el mercado (Local o EEUU) para un ticker específico.
/// </summary>
/// <param name="symbol">El ticker de la acción.</param>
/// <returns>El mercado al que pertenece el ticker.</returns>
private string DetermineMarket(string symbol)
{
if (_tickerMarketMapping.TryGetValue(symbol, out string? market))
{
return market;
}
// Si no existe en el mapping, determinamos y lo agregamos.
market = symbol.EndsWith(".BA") || symbol == "^MERV" ? "Local" : "EEUU";
_tickerMarketMapping[symbol] = market;
return market;
}
/// <summary>
/// Actualiza la información de la fuente de datos (Yahoo Finance) en la base de datos.
/// </summary>
private async Task UpdateSourceInfoAsync() private async Task UpdateSourceInfoAsync()
{ {
var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName);

View File

@@ -21,7 +21,6 @@
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@@ -2,15 +2,8 @@ using System.Data;
namespace Mercados.Infrastructure.Persistence namespace Mercados.Infrastructure.Persistence
{ {
/// <summary> public interface IDbConnectionFactory
/// Define una interfaz para una fábrica de conexiones a la base de datos. {
/// </summary> IDbConnection CreateConnection();
public interface IDbConnectionFactory }
{
/// <summary>
/// Crea y abre una nueva conexión a la base de datos.
/// </summary>
/// <returns>Un objeto <see cref="IDbConnection"/> representando la conexión abierta.</returns>
IDbConnection CreateConnection();
}
} }

View File

@@ -4,34 +4,26 @@ using System.Data;
namespace Mercados.Infrastructure.Persistence.Repositories namespace Mercados.Infrastructure.Persistence.Repositories
{ {
/// <inheritdoc cref="ICotizacionBolsaRepository"/>
public class CotizacionBolsaRepository : ICotizacionBolsaRepository public class CotizacionBolsaRepository : ICotizacionBolsaRepository
{ {
private readonly IDbConnectionFactory _connectionFactory; private readonly IDbConnectionFactory _connectionFactory;
/// <summary>
/// Inicializa una nueva instancia de la clase <see cref="CotizacionBolsaRepository"/>.
/// </summary>
/// <param name="connectionFactory">Fábrica para crear conexiones a la base de datos.</param>
public CotizacionBolsaRepository(IDbConnectionFactory connectionFactory) public CotizacionBolsaRepository(IDbConnectionFactory connectionFactory)
{ {
_connectionFactory = connectionFactory; _connectionFactory = connectionFactory;
} }
/// <inheritdoc />
public async Task GuardarMuchosAsync(IEnumerable<CotizacionBolsa> cotizaciones) public async Task GuardarMuchosAsync(IEnumerable<CotizacionBolsa> cotizaciones)
{ {
using IDbConnection connection = _connectionFactory.CreateConnection(); using IDbConnection connection = _connectionFactory.CreateConnection();
const string sql = @"INSERT INTO const string sql = @"
CotizacionesBolsa (Ticker, NombreEmpresa, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro) INSERT INTO CotizacionesBolsa (Ticker, NombreEmpresa, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro)
VALUES VALUES (@Ticker, @NombreEmpresa, @Mercado, @PrecioActual, @Apertura, @CierreAnterior, @PorcentajeCambio, @FechaRegistro);";
(@Ticker, @NombreEmpresa, @Mercado, @PrecioActual, @Apertura, @CierreAnterior, @PorcentajeCambio, @FechaRegistro);";
await connection.ExecuteAsync(sql, cotizaciones); await connection.ExecuteAsync(sql, cotizaciones);
} }
/// <inheritdoc />
public async Task<IEnumerable<CotizacionBolsa>> ObtenerUltimasPorMercadoAsync(string mercado) public async Task<IEnumerable<CotizacionBolsa>> ObtenerUltimasPorMercadoAsync(string mercado)
{ {
using IDbConnection connection = _connectionFactory.CreateConnection(); using IDbConnection connection = _connectionFactory.CreateConnection();
@@ -56,7 +48,6 @@ namespace Mercados.Infrastructure.Persistence.Repositories
return await connection.QueryAsync<CotizacionBolsa>(sql, new { Mercado = mercado }); return await connection.QueryAsync<CotizacionBolsa>(sql, new { Mercado = mercado });
} }
/// <inheritdoc />
public async Task<IEnumerable<CotizacionBolsa>> ObtenerHistorialPorTickerAsync(string ticker, string mercado, int dias) public async Task<IEnumerable<CotizacionBolsa>> ObtenerHistorialPorTickerAsync(string ticker, string mercado, int dias)
{ {
using IDbConnection connection = _connectionFactory.CreateConnection(); using IDbConnection connection = _connectionFactory.CreateConnection();

View File

@@ -4,29 +4,75 @@ using System.Data;
namespace Mercados.Infrastructure.Persistence.Repositories namespace Mercados.Infrastructure.Persistence.Repositories
{ {
/// <inheritdoc cref="ICotizacionGanadoRepository"/>
public class CotizacionGanadoRepository : ICotizacionGanadoRepository public class CotizacionGanadoRepository : ICotizacionGanadoRepository
{ {
private readonly IDbConnectionFactory _connectionFactory; private readonly IDbConnectionFactory _connectionFactory;
/// <summary>
/// Inicializa una nueva instancia de la clase <see cref="CotizacionGanadoRepository"/>.
/// </summary>
/// <param name="connectionFactory">Fábrica para crear conexiones a la base de datos.</param>
public CotizacionGanadoRepository(IDbConnectionFactory connectionFactory) public CotizacionGanadoRepository(IDbConnectionFactory connectionFactory)
{ {
_connectionFactory = connectionFactory; _connectionFactory = connectionFactory;
} }
/// <inheritdoc /> public async Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaDisponibleAsync()
{
using IDbConnection connection = _connectionFactory.CreateConnection();
// Esta consulta busca la fecha más reciente que tiene registros
// y luego devuelve todos los registros de esa fecha. Es la lógica original y correcta.
const string sql = @"
SELECT * FROM CotizacionesGanado
WHERE CONVERT(date, FechaRegistro) = (
SELECT TOP 1 CONVERT(date, FechaRegistro)
FROM CotizacionesGanado
ORDER BY FechaRegistro DESC
);";
return await connection.QueryAsync<CotizacionGanado>(sql);
}
// Este método lo sigue necesitando el Fetcher para comparar los datos del día.
public async Task<IEnumerable<CotizacionGanado>> ObtenerTandaPorFechaAsync(DateOnly fecha)
{
using IDbConnection connection = _connectionFactory.CreateConnection();
const string sql = @"
SELECT * FROM CotizacionesGanado
WHERE CONVERT(date, FechaRegistro) = @Fecha;";
return await connection.QueryAsync<CotizacionGanado>(sql, new { Fecha = fecha.ToString("yyyy-MM-dd") });
}
// Nuevo método para hacer un "reemplazo" inteligente
public async Task ReemplazarTandaDelDiaAsync(DateOnly fecha, IEnumerable<CotizacionGanado> nuevasCotizaciones)
{
using IDbConnection connection = _connectionFactory.CreateConnection();
connection.Open();
using var transaction = connection.BeginTransaction();
try
{
// Borramos solo los registros del día que vamos a actualizar
const string deleteSql = "DELETE FROM CotizacionesGanado WHERE CONVERT(date, FechaRegistro) = @Fecha;";
await connection.ExecuteAsync(deleteSql, new { Fecha = fecha.ToString("yyyy-MM-dd") }, transaction);
// Insertamos la nueva tanda completa
const string insertSql = @"
INSERT INTO CotizacionesGanado (Categoria, Especificaciones, Maximo, Minimo, Promedio, Mediano, Cabezas, KilosTotales, KilosPorCabeza, ImporteTotal, FechaRegistro)
VALUES (@Categoria, @Especificaciones, @Maximo, @Minimo, @Promedio, @Mediano, @Cabezas, @KilosTotales, @KilosPorCabeza, @ImporteTotal, @FechaRegistro);";
await connection.ExecuteAsync(insertSql, nuevasCotizaciones, transaction);
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
public async Task GuardarMuchosAsync(IEnumerable<CotizacionGanado> cotizaciones) public async Task GuardarMuchosAsync(IEnumerable<CotizacionGanado> cotizaciones)
{ {
using IDbConnection connection = _connectionFactory.CreateConnection(); using IDbConnection connection = _connectionFactory.CreateConnection();
// Dapper puede insertar una colección de objetos de una sola vez, ¡muy eficiente! // Dapper puede insertar una colección de objetos de una sola vez, ¡muy eficiente!
const string sql = @" const string sql = @"
INSERT INTO INSERT INTO CotizacionesGanado (
CotizacionesGanado (
Categoria, Especificaciones, Maximo, Minimo, Promedio, Mediano, Categoria, Especificaciones, Maximo, Minimo, Promedio, Mediano,
Cabezas, KilosTotales, KilosPorCabeza, ImporteTotal, FechaRegistro Cabezas, KilosTotales, KilosPorCabeza, ImporteTotal, FechaRegistro
) )
@@ -38,22 +84,6 @@ namespace Mercados.Infrastructure.Persistence.Repositories
await connection.ExecuteAsync(sql, cotizaciones); await connection.ExecuteAsync(sql, cotizaciones);
} }
/// <inheritdoc />
public async Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaAsync()
{
using IDbConnection connection = _connectionFactory.CreateConnection();
// Primero, obtenemos la fecha de registro más reciente.
// Luego, seleccionamos todos los registros que tengan esa fecha.
const string sql = @"
SELECT *
FROM CotizacionesGanado
WHERE FechaRegistro = (SELECT MAX(FechaRegistro) FROM CotizacionesGanado);";
return await connection.QueryAsync<CotizacionGanado>(sql);
}
/// <inheritdoc />
public async Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias) public async Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias)
{ {
using IDbConnection connection = _connectionFactory.CreateConnection(); using IDbConnection connection = _connectionFactory.CreateConnection();
@@ -70,10 +100,11 @@ namespace Mercados.Infrastructure.Persistence.Repositories
ORDER BY ORDER BY
FechaRegistro ASC;"; FechaRegistro ASC;";
return await connection.QueryAsync<CotizacionGanado>(sql, new { return await connection.QueryAsync<CotizacionGanado>(sql, new
Categoria = categoria, {
Especificaciones = especificaciones, Categoria = categoria,
Dias = dias Especificaciones = especificaciones,
Dias = dias
}); });
} }
} }

View File

@@ -4,34 +4,25 @@ using System.Data;
namespace Mercados.Infrastructure.Persistence.Repositories namespace Mercados.Infrastructure.Persistence.Repositories
{ {
/// <inheritdoc cref="ICotizacionGranoRepository"/>
public class CotizacionGranoRepository : ICotizacionGranoRepository public class CotizacionGranoRepository : ICotizacionGranoRepository
{ {
private readonly IDbConnectionFactory _connectionFactory; private readonly IDbConnectionFactory _connectionFactory;
/// <summary>
/// Inicializa una nueva instancia de la clase <see cref="CotizacionGranoRepository"/>.
/// </summary>
/// <param name="connectionFactory">Fábrica para crear conexiones a la base de datos.</param>
public CotizacionGranoRepository(IDbConnectionFactory connectionFactory) public CotizacionGranoRepository(IDbConnectionFactory connectionFactory)
{ {
_connectionFactory = connectionFactory; _connectionFactory = connectionFactory;
} }
/// <inheritdoc />
public async Task GuardarMuchosAsync(IEnumerable<CotizacionGrano> cotizaciones) public async Task GuardarMuchosAsync(IEnumerable<CotizacionGrano> cotizaciones)
{ {
using IDbConnection connection = _connectionFactory.CreateConnection(); using IDbConnection connection = _connectionFactory.CreateConnection();
const string sql = @"INSERT INTO const string sql = @"
CotizacionesGranos (Nombre, Precio, VariacionPrecio, FechaOperacion, FechaRegistro) INSERT INTO CotizacionesGranos (Nombre, Precio, VariacionPrecio, FechaOperacion, FechaRegistro)
VALUES VALUES (@Nombre, @Precio, @VariacionPrecio, @FechaOperacion, @FechaRegistro);";
(@Nombre, @Precio, @VariacionPrecio, @FechaOperacion, @FechaRegistro);";
await connection.ExecuteAsync(sql, cotizaciones); await connection.ExecuteAsync(sql, cotizaciones);
} }
/// <inheritdoc />
public async Task<IEnumerable<CotizacionGrano>> ObtenerUltimasAsync() public async Task<IEnumerable<CotizacionGrano>> ObtenerUltimasAsync()
{ {
using IDbConnection connection = _connectionFactory.CreateConnection(); using IDbConnection connection = _connectionFactory.CreateConnection();
@@ -54,7 +45,6 @@ namespace Mercados.Infrastructure.Persistence.Repositories
return await connection.QueryAsync<CotizacionGrano>(sql); return await connection.QueryAsync<CotizacionGrano>(sql);
} }
/// <inheritdoc />
public async Task<IEnumerable<CotizacionGrano>> ObtenerHistorialAsync(string nombre, int dias) public async Task<IEnumerable<CotizacionGrano>> ObtenerHistorialAsync(string nombre, int dias)
{ {
using IDbConnection connection = _connectionFactory.CreateConnection(); using IDbConnection connection = _connectionFactory.CreateConnection();

View File

@@ -4,42 +4,37 @@ using System.Data;
namespace Mercados.Infrastructure.Persistence.Repositories namespace Mercados.Infrastructure.Persistence.Repositories
{ {
/// <inheritdoc cref="IFuenteDatoRepository"/>
public class FuenteDatoRepository : IFuenteDatoRepository public class FuenteDatoRepository : IFuenteDatoRepository
{ {
private readonly IDbConnectionFactory _connectionFactory; private readonly IDbConnectionFactory _connectionFactory;
/// <summary>
/// Inicializa una nueva instancia de la clase <see cref="FuenteDatoRepository"/>.
/// </summary>
/// <param name="connectionFactory">Fábrica para crear conexiones a la base de datos.</param>
public FuenteDatoRepository(IDbConnectionFactory connectionFactory) public FuenteDatoRepository(IDbConnectionFactory connectionFactory)
{ {
_connectionFactory = connectionFactory; _connectionFactory = connectionFactory;
} }
/// <inheritdoc />
public async Task<FuenteDato?> ObtenerPorNombreAsync(string nombre) public async Task<FuenteDato?> ObtenerPorNombreAsync(string nombre)
{ {
using IDbConnection connection = _connectionFactory.CreateConnection(); using IDbConnection connection = _connectionFactory.CreateConnection();
const string sql = "SELECT * FROM FuentesDatos WHERE Nombre = @Nombre;"; const string sql = "SELECT * FROM FuentesDatos WHERE Nombre = @Nombre;";
return await connection.QuerySingleOrDefaultAsync<FuenteDato>(sql, new { Nombre = nombre }); return await connection.QuerySingleOrDefaultAsync<FuenteDato>(sql, new { Nombre = nombre });
} }
/// <inheritdoc />
public async Task CrearAsync(FuenteDato fuenteDato) public async Task CrearAsync(FuenteDato fuenteDato)
{ {
using IDbConnection connection = _connectionFactory.CreateConnection(); using IDbConnection connection = _connectionFactory.CreateConnection();
const string sql = @"INSERT INTO FuentesDatos (Nombre, UltimaEjecucionExitosa, Url) const string sql = @"
INSERT INTO FuentesDatos (Nombre, UltimaEjecucionExitosa, Url)
VALUES (@Nombre, @UltimaEjecucionExitosa, @Url);"; VALUES (@Nombre, @UltimaEjecucionExitosa, @Url);";
await connection.ExecuteAsync(sql, fuenteDato); await connection.ExecuteAsync(sql, fuenteDato);
} }
/// <inheritdoc />
public async Task ActualizarAsync(FuenteDato fuenteDato) public async Task ActualizarAsync(FuenteDato fuenteDato)
{ {
using IDbConnection connection = _connectionFactory.CreateConnection(); using IDbConnection connection = _connectionFactory.CreateConnection();
const string sql = @"UPDATE FuentesDatos const string sql = @"
SET UltimaEjecucionExitosa = @UltimaEjecucionExitosa, Url = @Url UPDATE FuentesDatos
SET UltimaEjecucionExitosa = @UltimaEjecucionExitosa, Url = @Url
WHERE Id = @Id;"; WHERE Id = @Id;";
await connection.ExecuteAsync(sql, fuenteDato); await connection.ExecuteAsync(sql, fuenteDato);
} }

View File

@@ -1,9 +1,6 @@
namespace Mercados.Infrastructure.Persistence.Repositories namespace Mercados.Infrastructure.Persistence.Repositories
{ {
/// <summary> // Esta interfaz no es estrictamente necesaria ahora, pero es útil para futuras abstracciones.
/// Interfaz base marcadora para todos los repositorios.
/// No define miembros, pero sirve para la abstracción y la inyección de dependencias.
/// </summary>
public interface IBaseRepository public interface IBaseRepository
{ {
} }

View File

@@ -2,31 +2,10 @@ using Mercados.Core.Entities;
namespace Mercados.Infrastructure.Persistence.Repositories namespace Mercados.Infrastructure.Persistence.Repositories
{ {
/// <summary>
/// Define el contrato para el repositorio que gestiona las cotizaciones de la bolsa.
/// </summary>
public interface ICotizacionBolsaRepository : IBaseRepository public interface ICotizacionBolsaRepository : IBaseRepository
{ {
/// <summary>
/// Guarda una colección de cotizaciones de bolsa en la base de datos de forma masiva.
/// </summary>
/// <param name="cotizaciones">La colección de entidades CotizacionBolsa a guardar.</param>
Task GuardarMuchosAsync(IEnumerable<CotizacionBolsa> cotizaciones); Task GuardarMuchosAsync(IEnumerable<CotizacionBolsa> cotizaciones);
/// <summary>
/// Obtiene la última cotización registrada para cada ticker de un mercado específico.
/// </summary>
/// <param name="mercado">El código del mercado a consultar (ej. "US", "Local").</param>
/// <returns>Una colección con la última cotización de cada activo de ese mercado.</returns>
Task<IEnumerable<CotizacionBolsa>> ObtenerUltimasPorMercadoAsync(string mercado); Task<IEnumerable<CotizacionBolsa>> ObtenerUltimasPorMercadoAsync(string mercado);
/// <summary>
/// Obtiene el historial de cotizaciones para un ticker específico durante un período determinado.
/// </summary>
/// <param name="ticker">El símbolo del activo (ej. "AAPL", "^MERV").</param>
/// <param name="mercado">El mercado al que pertenece el ticker.</param>
/// <param name="dias">El número de días hacia atrás desde hoy para obtener el historial.</param>
/// <returns>Una colección de cotizaciones ordenadas por fecha de forma ascendente.</returns>
Task<IEnumerable<CotizacionBolsa>> ObtenerHistorialPorTickerAsync(string ticker, string mercado, int dias); Task<IEnumerable<CotizacionBolsa>> ObtenerHistorialPorTickerAsync(string ticker, string mercado, int dias);
} }
} }

View File

@@ -2,30 +2,11 @@ using Mercados.Core.Entities;
namespace Mercados.Infrastructure.Persistence.Repositories namespace Mercados.Infrastructure.Persistence.Repositories
{ {
/// <summary>
/// Define el contrato para el repositorio que gestiona las cotizaciones del mercado de ganado.
/// </summary>
public interface ICotizacionGanadoRepository : IBaseRepository public interface ICotizacionGanadoRepository : IBaseRepository
{ {
/// <summary> Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaDisponibleAsync();
/// Guarda una colección de cotizaciones de ganado en la base de datos. Task ReemplazarTandaDelDiaAsync(DateOnly fecha, IEnumerable<CotizacionGanado> nuevasCotizaciones);
/// </summary>
/// <param name="cotizaciones">La colección de entidades CotizacionGanado a guardar.</param>
Task GuardarMuchosAsync(IEnumerable<CotizacionGanado> cotizaciones);
/// <summary>
/// Obtiene el último parte completo de cotizaciones del mercado de ganado.
/// </summary>
/// <returns>Una colección de todas las cotizaciones de la última tanda registrada.</returns>
Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaAsync();
/// <summary>
/// Obtiene el historial de cotizaciones para una categoría y especificación de ganado.
/// </summary>
/// <param name="categoria">La categoría principal del ganado (ej. "NOVILLOS").</param>
/// <param name="especificaciones">La especificación detallada del ganado.</param>
/// <param name="dias">El número de días de historial a recuperar.</param>
/// <returns>Una colección de cotizaciones históricas para esa categoría.</returns>
Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias); Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias);
Task<IEnumerable<CotizacionGanado>> ObtenerTandaPorFechaAsync(DateOnly fecha);
} }
} }

View File

@@ -2,29 +2,10 @@ using Mercados.Core.Entities;
namespace Mercados.Infrastructure.Persistence.Repositories namespace Mercados.Infrastructure.Persistence.Repositories
{ {
/// <summary>
/// Define el contrato para el repositorio que gestiona las cotizaciones del mercado de granos.
/// </summary>
public interface ICotizacionGranoRepository : IBaseRepository public interface ICotizacionGranoRepository : IBaseRepository
{ {
/// <summary>
/// Guarda una colección de cotizaciones de granos en la base de datos.
/// </summary>
/// <param name="cotizaciones">La colección de entidades CotizacionGrano a guardar.</param>
Task GuardarMuchosAsync(IEnumerable<CotizacionGrano> cotizaciones); Task GuardarMuchosAsync(IEnumerable<CotizacionGrano> cotizaciones);
/// <summary>
/// Obtiene las últimas cotizaciones disponibles para los granos.
/// </summary>
/// <returns>Una colección de las últimas cotizaciones de granos registradas.</returns>
Task<IEnumerable<CotizacionGrano>> ObtenerUltimasAsync(); Task<IEnumerable<CotizacionGrano>> ObtenerUltimasAsync();
/// <summary>
/// Obtiene el historial de cotizaciones para un grano específico.
/// </summary>
/// <param name="nombre">El nombre del grano (ej. "Soja").</param>
/// <param name="dias">El número de días de historial a recuperar.</param>
/// <returns>Una colección de cotizaciones históricas para el grano especificado.</returns>
Task<IEnumerable<CotizacionGrano>> ObtenerHistorialAsync(string nombre, int dias); Task<IEnumerable<CotizacionGrano>> ObtenerHistorialAsync(string nombre, int dias);
} }
} }

View File

@@ -2,28 +2,10 @@ using Mercados.Core.Entities;
namespace Mercados.Infrastructure.Persistence.Repositories namespace Mercados.Infrastructure.Persistence.Repositories
{ {
/// <summary>
/// Define el contrato para el repositorio que gestiona las fuentes de datos.
/// </summary>
public interface IFuenteDatoRepository : IBaseRepository public interface IFuenteDatoRepository : IBaseRepository
{ {
/// <summary>
/// Obtiene una entidad FuenteDato por su nombre único.
/// </summary>
/// <param name="nombre">El nombre de la fuente de datos a buscar.</param>
/// <returns>La entidad FuenteDato si se encuentra; de lo contrario, null.</returns>
Task<FuenteDato?> ObtenerPorNombreAsync(string nombre); Task<FuenteDato?> ObtenerPorNombreAsync(string nombre);
/// <summary>
/// Actualiza una entidad FuenteDato existente en la base de datos.
/// </summary>
/// <param name="fuenteDato">La entidad FuenteDato con los datos actualizados.</param>
Task ActualizarAsync(FuenteDato fuenteDato); Task ActualizarAsync(FuenteDato fuenteDato);
/// <summary>
/// Crea una nueva entidad FuenteDato en la base de datos.
/// </summary>
/// <param name="fuenteDato">La entidad FuenteDato a crear.</param>
Task CrearAsync(FuenteDato fuenteDato); Task CrearAsync(FuenteDato fuenteDato);
} }
} }

View File

@@ -2,23 +2,9 @@ using Mercados.Core.Entities;
namespace Mercados.Infrastructure.Persistence.Repositories namespace Mercados.Infrastructure.Persistence.Repositories
{ {
/// <summary>
/// Define el contrato para el repositorio que gestiona los feriados de los mercados.
/// </summary>
public interface IMercadoFeriadoRepository : IBaseRepository public interface IMercadoFeriadoRepository : IBaseRepository
{ {
/// <summary>
/// Obtiene todos los feriados para un mercado y año específicos.
/// </summary>
/// <param name="codigoMercado">El código del mercado para el cual se buscan los feriados.</param>
/// <param name="anio">El año para el cual se desean obtener los feriados.</param>
/// <returns>Una colección de entidades MercadoFeriado para el mercado y año especificados.</returns>
Task<IEnumerable<MercadoFeriado>> ObtenerPorMercadoYAnioAsync(string codigoMercado, int anio); Task<IEnumerable<MercadoFeriado>> ObtenerPorMercadoYAnioAsync(string codigoMercado, int anio);
/// <summary>
/// Reemplaza todos los feriados existentes para un mercado con una nueva lista.
/// </summary>
/// <param name="codigoMercado">El código del mercado cuyos feriados serán reemplazados.</param>
/// <param name="nuevosFeriados">La nueva colección de feriados que se guardará.</param>
Task ReemplazarFeriadosPorMercadoAsync(string codigoMercado, IEnumerable<MercadoFeriado> nuevosFeriados); Task ReemplazarFeriadosPorMercadoAsync(string codigoMercado, IEnumerable<MercadoFeriado> nuevosFeriados);
} }
} }

View File

@@ -4,35 +4,24 @@ using System.Data;
namespace Mercados.Infrastructure.Persistence.Repositories namespace Mercados.Infrastructure.Persistence.Repositories
{ {
/// <inheritdoc cref="IMercadoFeriadoRepository"/>
public class MercadoFeriadoRepository : IMercadoFeriadoRepository public class MercadoFeriadoRepository : IMercadoFeriadoRepository
{ {
private readonly IDbConnectionFactory _connectionFactory; private readonly IDbConnectionFactory _connectionFactory;
/// <summary>
/// Inicializa una nueva instancia de la clase <see cref="MercadoFeriadoRepository"/>.
/// </summary>
/// <param name="connectionFactory">Fábrica para crear conexiones a la base de datos.</param>
public MercadoFeriadoRepository(IDbConnectionFactory connectionFactory) public MercadoFeriadoRepository(IDbConnectionFactory connectionFactory)
{ {
_connectionFactory = connectionFactory; _connectionFactory = connectionFactory;
} }
/// <inheritdoc />
public async Task<IEnumerable<MercadoFeriado>> ObtenerPorMercadoYAnioAsync(string codigoMercado, int anio) public async Task<IEnumerable<MercadoFeriado>> ObtenerPorMercadoYAnioAsync(string codigoMercado, int anio)
{ {
using IDbConnection connection = _connectionFactory.CreateConnection(); using IDbConnection connection = _connectionFactory.CreateConnection();
const string sql = @"SELECT * const string sql = @"
FROM MercadosFeriados SELECT * FROM MercadosFeriados
WHERE CodigoMercado = @CodigoMercado AND YEAR(Fecha) = @Anio;"; WHERE CodigoMercado = @CodigoMercado AND YEAR(Fecha) = @Anio;";
return await connection.QueryAsync<MercadoFeriado>(sql, new return await connection.QueryAsync<MercadoFeriado>(sql, new { CodigoMercado = codigoMercado, Anio = anio });
{
CodigoMercado = codigoMercado,
Anio = anio
});
} }
/// <inheritdoc />
public async Task ReemplazarFeriadosPorMercadoAsync(string codigoMercado, IEnumerable<MercadoFeriado> nuevosFeriados) public async Task ReemplazarFeriadosPorMercadoAsync(string codigoMercado, IEnumerable<MercadoFeriado> nuevosFeriados)
{ {
using IDbConnection connection = _connectionFactory.CreateConnection(); using IDbConnection connection = _connectionFactory.CreateConnection();
@@ -41,31 +30,25 @@ namespace Mercados.Infrastructure.Persistence.Repositories
try try
{ {
// Obtenemos el año del primer feriado (asumimos que todos son del mismo año) // Borramos todos los feriados del año en curso para ese mercado
var anio = nuevosFeriados.FirstOrDefault()?.Fecha.Year; var anio = nuevosFeriados.FirstOrDefault()?.Fecha.Year;
if (!anio.HasValue) return; // Si no hay feriados, no hay nada que hacer if (anio.HasValue)
// 1. Borrar los feriados existentes para ese mercado
const string deleteSql = "DELETE FROM MercadosFeriados WHERE CodigoMercado = @CodigoMercado;";
await connection.ExecuteAsync(deleteSql, new { CodigoMercado = codigoMercado }, transaction);
// 2. Insertar los nuevos feriados
if (nuevosFeriados.Any())
{ {
const string insertSql = @"INSERT INTO MercadosFeriados (CodigoMercado, Fecha, Nombre) const string deleteSql = "DELETE FROM MercadosFeriados WHERE CodigoMercado = @CodigoMercado AND YEAR(Fecha) = @Anio;";
VALUES (@CodigoMercado, @Fecha, @Nombre);"; await connection.ExecuteAsync(deleteSql, new { CodigoMercado = codigoMercado, Anio = anio.Value }, transaction);
await connection.ExecuteAsync(insertSql, nuevosFeriados, transaction);
} }
// Si todo sale bien, confirmar la transacción // Insertamos los nuevos
const string insertSql = @"
INSERT INTO MercadosFeriados (CodigoMercado, Fecha, Nombre)
VALUES (@CodigoMercado, @Fecha, @Nombre);";
await connection.ExecuteAsync(insertSql, nuevosFeriados, transaction);
transaction.Commit(); transaction.Commit();
} }
catch catch
{ {
// Si hay algún error, deshacer la transacción para no dejar datos inconsistentes
transaction.Rollback(); transaction.Rollback();
// Relanzar la excepción para que el llamador sepa que algo falló
throw; throw;
} }
} }

View File

@@ -5,17 +5,10 @@ using System.Data;
namespace Mercados.Infrastructure namespace Mercados.Infrastructure
{ {
/// <summary>
/// Proporciona una fábrica para crear conexiones a la base de datos SQL.
/// </summary>
public class SqlConnectionFactory : IDbConnectionFactory public class SqlConnectionFactory : IDbConnectionFactory
{ {
private readonly string _connectionString; private readonly string _connectionString;
/// <summary>
/// Inicializa una nueva instancia de la clase <see cref="SqlConnectionFactory"/>.
/// </summary>
/// <param name="configuration">La configuración de la aplicación desde donde se obtiene la cadena de conexión.</param>
public SqlConnectionFactory(IConfiguration configuration) public SqlConnectionFactory(IConfiguration configuration)
{ {
// Variable de entorno 'DB_CONNECTION_STRING' si está disponible, // Variable de entorno 'DB_CONNECTION_STRING' si está disponible,
@@ -24,10 +17,9 @@ namespace Mercados.Infrastructure
?? throw new ArgumentNullException(nameof(configuration), "La cadena de conexión 'DefaultConnection' no fue encontrada."); ?? throw new ArgumentNullException(nameof(configuration), "La cadena de conexión 'DefaultConnection' no fue encontrada.");
} }
/// <inheritdoc /> public IDbConnection CreateConnection()
public IDbConnection CreateConnection()
{ {
return new SqlConnection(_connectionString); return new SqlConnection(_connectionString);
} }
} }
} }

View File

@@ -6,26 +6,17 @@ using MimeKit;
namespace Mercados.Infrastructure.Services namespace Mercados.Infrastructure.Services
{ {
/// <summary>
/// Servicio que gestiona el envío de notificaciones por correo electrónico.
/// </summary>
public class EmailNotificationService : INotificationService public class EmailNotificationService : INotificationService
{ {
private readonly ILogger<EmailNotificationService> _logger; private readonly ILogger<EmailNotificationService> _logger;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
/// <summary>
/// Inicializa una nueva instancia de la clase <see cref="EmailNotificationService"/>.
/// </summary>
/// <param name="logger">Logger para registrar información y errores.</param>
/// <param name="configuration">Configuración de la aplicación para obtener los ajustes SMTP.</param>
public EmailNotificationService(ILogger<EmailNotificationService> logger, IConfiguration configuration) public EmailNotificationService(ILogger<EmailNotificationService> logger, IConfiguration configuration)
{ {
_logger = logger; _logger = logger;
_configuration = configuration; _configuration = configuration;
} }
/// <inheritdoc />
public async Task SendFailureAlertAsync(string subject, string message, DateTime? eventTimeUtc = null) public async Task SendFailureAlertAsync(string subject, string message, DateTime? eventTimeUtc = null)
{ {
// Leemos la configuración de forma segura desde IConfiguration (que a su vez lee el .env) // Leemos la configuración de forma segura desde IConfiguration (que a su vez lee el .env)

View File

@@ -5,41 +5,26 @@ using Microsoft.Extensions.Logging;
namespace Mercados.Infrastructure.Services namespace Mercados.Infrastructure.Services
{ {
/// <summary> public class FinnhubHolidayService : IHolidayService
/// Servicio para consultar si una fecha es feriado de mercado utilizando la base de datos interna.
/// </summary>
public class FinnhubHolidayService : IHolidayService
{ {
private readonly IMercadoFeriadoRepository _feriadoRepository; private readonly IMercadoFeriadoRepository _feriadoRepository;
private readonly IMemoryCache _cache; private readonly IMemoryCache _cache;
private readonly ILogger<FinnhubHolidayService> _logger; private readonly ILogger<FinnhubHolidayService> _logger;
/// <summary> public FinnhubHolidayService(
/// Inicializa una nueva instancia de la clase <see cref="FinnhubHolidayService"/>. IMercadoFeriadoRepository feriadoRepository,
/// </summary> IMemoryCache cache,
/// <param name="feriadoRepository">Repositorio para acceder a los feriados de mercado.</param> ILogger<FinnhubHolidayService> logger)
/// <param name="cache">Caché en memoria para almacenar los feriados.</param> {
/// <param name="logger">Logger para registrar información y errores.</param> _feriadoRepository = feriadoRepository;
public FinnhubHolidayService( _cache = cache;
IMercadoFeriadoRepository feriadoRepository, _logger = logger;
IMemoryCache cache, }
ILogger<FinnhubHolidayService> logger)
{
_feriadoRepository = feriadoRepository;
_cache = cache;
_logger = logger;
}
/// <summary> public async Task<bool> IsMarketHolidayAsync(string marketCode, DateTime date)
/// Determina si una fecha específica es feriado de mercado para el código de mercado proporcionado. {
/// </summary> var dateOnly = DateOnly.FromDateTime(date);
/// <param name="marketCode">Código del mercado a consultar.</param> var cacheKey = $"holidays_{marketCode}_{date.Year}";
/// <param name="date">Fecha a verificar.</param>
/// <returns>True si la fecha es feriado de mercado; de lo contrario, false.</returns>
public async Task<bool> IsMarketHolidayAsync(string marketCode, DateTime date)
{
var dateOnly = DateOnly.FromDateTime(date);
var cacheKey = $"holidays_{marketCode}_{date.Year}";
if (!_cache.TryGetValue(cacheKey, out HashSet<DateOnly>? holidays)) if (!_cache.TryGetValue(cacheKey, out HashSet<DateOnly>? holidays))
{ {

View File

@@ -10,7 +10,6 @@ namespace Mercados.Infrastructure.Services
/// </summary> /// </summary>
/// <param name="subject">El título de la alerta.</param> /// <param name="subject">El título de la alerta.</param>
/// <param name="message">El mensaje detallado del error.</param> /// <param name="message">El mensaje detallado del error.</param>
/// <param name="eventTimeUtc">La fecha y hora UTC del evento (opcional).</param>
Task SendFailureAlertAsync(string subject, string message, DateTime? eventTimeUtc = null); Task SendFailureAlertAsync(string subject, string message, DateTime? eventTimeUtc = null);
} }
} }

View File

@@ -14,48 +14,23 @@ namespace Mercados.Worker
private readonly ILogger<DataFetchingService> _logger; private readonly ILogger<DataFetchingService> _logger;
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly TimeZoneInfo _argentinaTimeZone; private readonly TimeZoneInfo _argentinaTimeZone;
/// <summary> // Expresiones Cron
/// Expresión Cron para la tarea de Mercado Agroganadero.
/// </summary>
private readonly CronExpression _agroSchedule; private readonly CronExpression _agroSchedule;
/// <summary>
/// Expresión Cron para la tarea de la Bolsa de Comercio de Rosario (BCR).
/// </summary>
private readonly CronExpression _bcrSchedule; private readonly CronExpression _bcrSchedule;
/// <summary>
/// Expresión Cron para la tarea de las Bolsas (Finnhub y Yahoo Finance).
/// </summary>
private readonly CronExpression _bolsasSchedule; private readonly CronExpression _bolsasSchedule;
/// <summary>
/// Expresión Cron para la tarea de actualización de feriados.
/// </summary>
private readonly CronExpression _holidaysSchedule; private readonly CronExpression _holidaysSchedule;
/// <summary>Próxima hora de ejecución programada para la tarea de Mercado Agroganadero.</summary> // Próximas ejecuciones
private DateTime? _nextAgroRun; private DateTime? _nextAgroRun;
/// <summary>Próxima hora de ejecución programada para la tarea de BCR.</summary>
private DateTime? _nextBcrRun; private DateTime? _nextBcrRun;
/// <summary>Próxima hora de ejecución programada para la tarea de Bolsas.</summary>
private DateTime? _nextBolsasRun; private DateTime? _nextBolsasRun;
/// <summary>Próxima hora de ejecución programada para la tarea de Feriados.</summary>
private DateTime? _nextHolidaysRun; private DateTime? _nextHolidaysRun;
/// <summary>
/// Almacena la última vez que se envió una alerta para una tarea específica, para evitar spam.
/// </summary>
private readonly Dictionary<string, DateTime> _lastAlertSent = new(); private readonly Dictionary<string, DateTime> _lastAlertSent = new();
/// <summary>
/// Período de tiempo durante el cual no se enviarán alertas repetidas para la misma tarea.
/// </summary>
private readonly TimeSpan _alertSilencePeriod = TimeSpan.FromHours(4); private readonly TimeSpan _alertSilencePeriod = TimeSpan.FromHours(4);
/// <summary> // Eliminamos IHolidayService del constructor
/// Inicializa una nueva instancia de la clase <see cref="DataFetchingService"/>.
/// </summary>
/// <param name="logger">Logger para registrar información y eventos.</param>
/// <param name="serviceProvider">Proveedor de servicios para la inyección de dependencias con scope.</param>
/// <param name="configuration">Configuración de la aplicación para obtener los schedules de Cron.</param>
public DataFetchingService( public DataFetchingService(
ILogger<DataFetchingService> logger, ILogger<DataFetchingService> logger,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
@@ -85,10 +60,8 @@ namespace Mercados.Worker
} }
/// <summary> /// <summary>
/// Método principal del servicio que se ejecuta en segundo plano. Contiene el bucle /// Método principal del servicio. Se ejecuta una vez cuando el servicio arranca.
/// principal que verifica periódicamente si se debe ejecutar alguna tarea programada.
/// </summary> /// </summary>
/// <param name="stoppingToken">Token de cancelación para detener el servicio de forma segura.</param>
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
_logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now); _logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now);
@@ -181,9 +154,6 @@ namespace Mercados.Worker
/// <summary> /// <summary>
/// Ejecuta un fetcher específico por su nombre, gestionando el scope de DI y las notificaciones. /// Ejecuta un fetcher específico por su nombre, gestionando el scope de DI y las notificaciones.
/// </summary> /// </summary>
/// <param name="sourceName">El nombre del <see cref="IDataFetcher"/> a ejecutar.</param>
/// <param name="stoppingToken">Token de cancelación para detener la operación si el servicio se está parando.</param>
/// <remarks>Este método crea un nuevo scope de DI para resolver los servicios necesarios.</remarks>
private async Task RunFetcherByNameAsync(string sourceName, CancellationToken stoppingToken) private async Task RunFetcherByNameAsync(string sourceName, CancellationToken stoppingToken)
{ {
if (stoppingToken.IsCancellationRequested) return; if (stoppingToken.IsCancellationRequested) return;
@@ -223,8 +193,6 @@ namespace Mercados.Worker
/// <summary> /// <summary>
/// Ejecuta todos los fetchers en paralelo al iniciar el servicio. /// Ejecuta todos los fetchers en paralelo al iniciar el servicio.
/// </summary> /// </summary>
/// <param name="stoppingToken">Token de cancelación para detener la operación si el servicio se está parando.</param>
/// <remarks>Esta función se usa principalmente para una ejecución de prueba al arrancar.</remarks>
private async Task RunAllFetchersAsync(CancellationToken stoppingToken) private async Task RunAllFetchersAsync(CancellationToken stoppingToken)
{ {
_logger.LogInformation("Ejecutando todos los fetchers al iniciar en paralelo..."); _logger.LogInformation("Ejecutando todos los fetchers al iniciar en paralelo...");
@@ -243,8 +211,6 @@ namespace Mercados.Worker
/// <summary> /// <summary>
/// Determina si se debe enviar una alerta o si está en período de silencio. /// Determina si se debe enviar una alerta o si está en período de silencio.
/// </summary> /// </summary>
/// <param name="taskName">El nombre de la tarea que podría generar la alerta.</param>
/// <returns>True si se debe enviar la alerta; de lo contrario, false.</returns>
private bool ShouldSendAlert(string taskName) private bool ShouldSendAlert(string taskName)
{ {
if (!_lastAlertSent.ContainsKey(taskName)) if (!_lastAlertSent.ContainsKey(taskName))
@@ -258,13 +224,8 @@ namespace Mercados.Worker
#endregion #endregion
/// <summary> // Creamos una única función para comprobar feriados que obtiene el servicio
/// Comprueba si una fecha dada es feriado para un mercado específico. // desde un scope.
/// </summary>
/// <param name="marketCode">El código del mercado (ej. "US", "BA").</param>
/// <param name="date">La fecha a comprobar.</param>
/// <returns>True si es feriado, false si no lo es o si ocurre un error.</returns>
/// <remarks>Este método resuelve el <see cref="IHolidayService"/> desde un nuevo scope de DI para cada llamada.</remarks>
private async Task<bool> IsMarketHolidayAsync(string marketCode, DateTime date) private async Task<bool> IsMarketHolidayAsync(string marketCode, DateTime date)
{ {
using var scope = _serviceProvider.CreateScope(); using var scope = _serviceProvider.CreateScope();

View File

@@ -5,7 +5,6 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-Mercados.Worker-0e9a6e84-c8fb-400b-ba7b-193d9981a046</UserSecretsId> <UserSecretsId>dotnet-Mercados.Worker-0e9a6e84-c8fb-400b-ba7b-193d9981a046</UserSecretsId>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,7 +1,7 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Debug",
"Microsoft.Hosting.Lifetime": "Information" "Microsoft.Hosting.Lifetime": "Information"
} }
} }

View File

@@ -10,7 +10,7 @@
"DefaultConnection": "" "DefaultConnection": ""
}, },
"Schedules": { "Schedules": {
"MercadoAgroganadero": "0 11 * * 1-5", "MercadoAgroganadero": "0 11,15,18,21 * * 1-5",
"BCR": "30 11 * * 1-5", "BCR": "30 11 * * 1-5",
"Bolsas": "10 11-17 * * 1-5", "Bolsas": "10 11-17 * * 1-5",
"Holidays": "0 2 * * 1" "Holidays": "0 2 * * 1"