Compare commits
32 Commits
5e317ab304
...
docs
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c4839e36a | |||
| 88f245a80d | |||
| 8878ec632e | |||
| e3339fff99 | |||
| 55457afcac | |||
| 2da9e17067 | |||
| 191a49977a | |||
| e1e23f5315 | |||
| 640b7d1ece | |||
| cda2726960 | |||
| 2efc052755 | |||
| 30d03147c7 | |||
| 37bc4b0206 | |||
| e670ebaac7 | |||
| c9b3127f55 | |||
| 2cd57d0e60 | |||
| 3a50753c8a | |||
| d66765c646 | |||
| 761970a4de | |||
| c93ee2733b | |||
| c51be0433a | |||
| 9d4c19823c | |||
| 4f6e833a20 | |||
| 2a27207b41 | |||
| 2673539af1 | |||
| bb68cb9234 | |||
| 5286fa9617 | |||
| d411919288 | |||
| cd1cc283cd | |||
| 55b36b6042 | |||
| c63f53b69a | |||
| 32c99515dd |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -178,6 +178,10 @@ DocProject/Help/*.hhk
|
|||||||
DocProject/Help/*.hhp
|
DocProject/Help/*.hhp
|
||||||
DocProject/Help/Html2
|
DocProject/Help/Html2
|
||||||
DocProject/Help/html
|
DocProject/Help/html
|
||||||
|
# DocFx
|
||||||
|
[Dd]ocs/
|
||||||
|
docfx.build.json
|
||||||
|
docfx.metadata.json
|
||||||
|
|
||||||
# Click-Once directory
|
# Click-Once directory
|
||||||
publish/
|
publish/
|
||||||
@@ -413,4 +417,3 @@ FodyWeavers.xsd
|
|||||||
|
|
||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
*.vsix
|
*.vsix
|
||||||
|
|
||||||
|
|||||||
63
docker-compose.yml
Normal file
63
docker-compose.yml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
services:
|
||||||
|
# Servicio del Backend API
|
||||||
|
mercados-api:
|
||||||
|
build:
|
||||||
|
context: ./Mercados-Web # Asumiendo que clonaste el repo en esta carpeta
|
||||||
|
dockerfile: src/Mercados.Api/Dockerfile
|
||||||
|
container_name: mercados-api
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- ./.env # Lee las variables desde un archivo .env en la misma carpeta
|
||||||
|
networks:
|
||||||
|
- mercados-net
|
||||||
|
- shared-net # Se conecta a la red compartida para hablar con la DB
|
||||||
|
# NO se exponen puertos al host.
|
||||||
|
|
||||||
|
# Servicio del Worker
|
||||||
|
mercados-worker:
|
||||||
|
build:
|
||||||
|
context: ./Mercados-Web
|
||||||
|
dockerfile: src/Mercados.Worker/Dockerfile
|
||||||
|
container_name: mercados-worker
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
networks:
|
||||||
|
- shared-net # Solo necesita acceso a la DB.
|
||||||
|
# NO se exponen puertos al host.
|
||||||
|
|
||||||
|
# Servicio del Frontend (servido por Nginx)
|
||||||
|
mercados-frontend:
|
||||||
|
build:
|
||||||
|
context: ./Mercados-Web/frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: mercados-frontend
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- mercados-net
|
||||||
|
# NO se exponen puertos al host.
|
||||||
|
|
||||||
|
# --- NUEVO SERVICIO: Proxy Inverso Local ---
|
||||||
|
proxy:
|
||||||
|
image: nginx:1.25-alpine
|
||||||
|
container_name: mercados-proxy
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
# Mapeamos nuestro archivo de configuración al contenedor de Nginx
|
||||||
|
- ./proxy-local/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||||
|
ports:
|
||||||
|
# ESTE ES EL ÚNICO PUNTO DE ENTRADA DESDE EL EXTERIOR
|
||||||
|
# Expone el puerto 80 del contenedor al puerto 8500 del host Debian.
|
||||||
|
- "8500:80"
|
||||||
|
networks:
|
||||||
|
- mercados-net
|
||||||
|
depends_on:
|
||||||
|
- mercados-api
|
||||||
|
- mercados-frontend
|
||||||
|
|
||||||
|
networks:
|
||||||
|
mercados-net:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
shared-net:
|
||||||
|
external: true
|
||||||
19
frontend/Dockerfile
Normal file
19
frontend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# --- Etapa 1: Build ---
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- Etapa 2: Producción ---
|
||||||
|
FROM nginx:1.25-alpine
|
||||||
|
|
||||||
|
# Copiamos los archivos estáticos generados
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copiamos nuestra configuración personalizada de Nginx para el frontend
|
||||||
|
COPY frontend.nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
23
frontend/frontend.nginx.conf
Normal file
23
frontend/frontend.nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# Directorio raíz donde están los archivos de la app
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Configuración para servir los archivos estáticos y manejar el enrutamiento de la SPA
|
||||||
|
location / {
|
||||||
|
# Intenta servir el archivo solicitado directamente ($uri),
|
||||||
|
# luego como un directorio ($uri/),
|
||||||
|
# y si no encuentra nada, devuelve el index.html
|
||||||
|
# Esto es crucial para que el enrutamiento de React funcione.
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Opcional: optimizaciones para archivos estáticos
|
||||||
|
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|woff|woff2)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
}
|
||||||
70
frontend/public/bootstrap.js
vendored
Normal file
70
frontend/public/bootstrap.js
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// frontend/public/bootstrap.js
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
// El dominio donde se alojan los widgets
|
||||||
|
const WIDGETS_HOST = 'https://widgets.eldia.com';
|
||||||
|
|
||||||
|
// Función para cargar dinámicamente un script
|
||||||
|
function loadScript(src) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.type = 'module';
|
||||||
|
script.src = src;
|
||||||
|
script.onload = resolve;
|
||||||
|
script.onerror = reject;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para cargar dinámicamente una hoja de estilos
|
||||||
|
function loadCSS(href) {
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = href;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función principal
|
||||||
|
async function initWidgets() {
|
||||||
|
try {
|
||||||
|
// 1. Obtener el manifest.json para saber los nombres de archivo actuales
|
||||||
|
const response = await fetch(`${WIDGETS_HOST}/manifest.json`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('No se pudo cargar el manifest de los widgets.');
|
||||||
|
}
|
||||||
|
const manifest = await response.json();
|
||||||
|
|
||||||
|
// 2. Encontrar el punto de entrada principal (nuestro main.tsx)
|
||||||
|
const entryKey = Object.keys(manifest).find(key => manifest[key].isEntry);
|
||||||
|
if (!entryKey) {
|
||||||
|
throw new Error('No se encontró el punto de entrada en el manifest.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = manifest[entryKey];
|
||||||
|
const jsUrl = `${WIDGETS_HOST}/${entry.file}`;
|
||||||
|
|
||||||
|
// 3. Cargar el CSS si existe
|
||||||
|
if (entry.css && entry.css.length > 0) {
|
||||||
|
entry.css.forEach(cssFile => {
|
||||||
|
const cssUrl = `${WIDGETS_HOST}/${cssFile}`;
|
||||||
|
loadCSS(cssUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Cargar el JS principal y esperar a que esté listo
|
||||||
|
await loadScript(jsUrl);
|
||||||
|
|
||||||
|
// 5. Una vez cargado, llamar a la función de renderizado
|
||||||
|
if (window.MercadosWidgets && typeof window.MercadosWidgets.render === 'function') {
|
||||||
|
window.MercadosWidgets.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al inicializar los widgets de mercados:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iniciar todo el proceso
|
||||||
|
initWidgets();
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
// Durante el desarrollo, nuestra API corre en un puerto específico (ej. 5045).
|
// Eliminamos la baseURL de aquí para evitar cualquier confusión.
|
||||||
// En producción, esto debería apuntar a la URL real del servidor donde se despliegue la API.
|
|
||||||
const API_BASE_URL = 'http://192.168.10.78:5045/api';
|
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,15 +2,15 @@ import { Box, CircularProgress, Alert } from '@mui/material';
|
|||||||
import type { CotizacionGanado } from '../models/mercadoModels';
|
import type { CotizacionGanado } from '../models/mercadoModels';
|
||||||
import { useApiData } from '../hooks/useApiData';
|
import { useApiData } from '../hooks/useApiData';
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||||
|
import { formatFullDateTime } from '../utils/formatters';
|
||||||
|
|
||||||
interface AgroHistoricalChartWidgetProps {
|
interface AgroHistoricalChartWidgetProps {
|
||||||
categoria: string;
|
categoria: string;
|
||||||
especificaciones: string;
|
especificaciones: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatXAxis = (tickItem: string) => {
|
const formatTooltipLabel = (label: string) => {
|
||||||
const date = new Date(tickItem);
|
return formatFullDateTime(label);
|
||||||
return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AgroHistoricalChartWidget = ({ categoria, especificaciones }: AgroHistoricalChartWidgetProps) => {
|
export const AgroHistoricalChartWidget = ({ categoria, especificaciones }: AgroHistoricalChartWidgetProps) => {
|
||||||
@@ -29,13 +29,20 @@ export const AgroHistoricalChartWidget = ({ categoria, especificaciones }: AgroH
|
|||||||
return <Alert severity="info" sx={{ height: 300 }}>No hay suficientes datos históricos para graficar esta categoría.</Alert>;
|
return <Alert severity="info" sx={{ height: 300 }}>No hay suficientes datos históricos para graficar esta categoría.</Alert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatXAxis = (tickItem: string) => {
|
||||||
|
return new Date(tickItem).toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="fechaRegistro" tickFormatter={formatXAxis} />
|
<XAxis dataKey="fechaRegistro" tickFormatter={formatXAxis} />
|
||||||
<YAxis domain={['dataMin - 10', 'dataMax + 10']} tickFormatter={(tick) => `$${tick.toLocaleString('es-AR')}`} />
|
<YAxis domain={['dataMin - 10', 'dataMax + 10']} tickFormatter={(tick) => `$${tick.toLocaleString('es-AR')}`} />
|
||||||
<Tooltip formatter={(value: number) => [`$${value.toFixed(2)}`, 'Precio Promedio']} />
|
<Tooltip
|
||||||
|
formatter={(value: number) => [`$${value.toFixed(2)}`, 'Precio Promedio']}
|
||||||
|
labelFormatter={formatTooltipLabel}
|
||||||
|
/>
|
||||||
<Legend />
|
<Legend />
|
||||||
<Line type="monotone" dataKey="promedio" name="Precio Promedio" stroke="#028fbe" strokeWidth={2} dot={false} />
|
<Line type="monotone" dataKey="promedio" name="Precio Promedio" stroke="#028fbe" strokeWidth={2} dot={false} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer,
|
Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer,
|
||||||
TableHead, TableRow, Paper, Typography, Dialog, DialogTitle,
|
TableHead, TableRow, Paper, Typography, Dialog, DialogTitle,
|
||||||
@@ -8,12 +8,19 @@ import CloseIcon from '@mui/icons-material/Close';
|
|||||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||||
import RemoveIcon from '@mui/icons-material/Remove';
|
import RemoveIcon from '@mui/icons-material/Remove';
|
||||||
import { formatFullDateTime, formatCurrency } from '../utils/formatters';
|
|
||||||
import type { CotizacionBolsa } from '../models/mercadoModels';
|
|
||||||
import { useApiData } from '../hooks/useApiData';
|
|
||||||
import { HistoricalChartWidget } from './HistoricalChartWidget';
|
|
||||||
import { PiChartLineUpBold } from 'react-icons/pi';
|
import { PiChartLineUpBold } from 'react-icons/pi';
|
||||||
|
|
||||||
|
// Importaciones de nuestro proyecto
|
||||||
|
import type { CotizacionBolsa } from '../models/mercadoModels';
|
||||||
|
import { useApiData } from '../hooks/useApiData';
|
||||||
|
import { useIsHoliday } from '../hooks/useIsHoliday';
|
||||||
|
import { formatFullDateTime, formatCurrency } from '../utils/formatters';
|
||||||
|
import { HistoricalChartWidget } from './HistoricalChartWidget';
|
||||||
|
import { HolidayAlert } from './common/HolidayAlert';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub-componente para mostrar la variación porcentual con un icono y color apropiado.
|
||||||
|
*/
|
||||||
const Variacion = ({ value }: { value: number }) => {
|
const Variacion = ({ value }: { value: number }) => {
|
||||||
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
|
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
|
||||||
const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon;
|
const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon;
|
||||||
@@ -25,31 +32,60 @@ const Variacion = ({ value }: { value: number }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget autónomo para la tabla de acciones líderes locales (Panel Merval).
|
||||||
|
*/
|
||||||
export const BolsaLocalWidget = () => {
|
export const BolsaLocalWidget = () => {
|
||||||
const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local');
|
// Este widget obtiene todos los datos del mercado local y luego los filtra.
|
||||||
|
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local');
|
||||||
|
const isHoliday = useIsHoliday('BA');
|
||||||
|
|
||||||
const [selectedTicker, setSelectedTicker] = useState<string | null>(null);
|
const [selectedTicker, setSelectedTicker] = useState<string | null>(null);
|
||||||
|
const triggerButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
const handleRowClick = (ticker: string) => setSelectedTicker(ticker);
|
const handleOpenModal = (ticker: string, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
const handleCloseDialog = () => setSelectedTicker(null);
|
triggerButtonRef.current = event.currentTarget;
|
||||||
|
setSelectedTicker(ticker);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setSelectedTicker(null);
|
||||||
|
setTimeout(() => {
|
||||||
|
triggerButtonRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtramos para obtener solo las acciones, excluyendo el índice.
|
||||||
const panelPrincipal = data?.filter(d => d.ticker !== '^MERV') || [];
|
const panelPrincipal = data?.filter(d => d.ticker !== '^MERV') || [];
|
||||||
|
|
||||||
if (loading) {
|
const isLoading = dataLoading || isHoliday === null;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (dataError) {
|
||||||
return <Alert severity="error">{error}</Alert>;
|
return <Alert severity="error">{dataError}</Alert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
// Si después de filtrar no queda ninguna acción, mostramos el mensaje apropiado.
|
||||||
return <Alert severity="info">No hay datos disponibles para el mercado local.</Alert>;
|
if (panelPrincipal.length === 0) {
|
||||||
|
// Si sabemos que es feriado, la alerta de feriado es el mensaje más relevante.
|
||||||
|
if (isHoliday) {
|
||||||
|
return <HolidayAlert />;
|
||||||
|
}
|
||||||
|
return <Alert severity="info">No hay acciones líderes disponibles para mostrar.</Alert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Box>
|
||||||
|
{/* La alerta de feriado también se aplica a esta tabla. */}
|
||||||
|
{isHoliday && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<HolidayAlert />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{panelPrincipal.length > 0 && (
|
|
||||||
<TableContainer component={Paper}>
|
<TableContainer component={Paper}>
|
||||||
<Box sx={{ p: 1, m: 0 }}>
|
<Box sx={{ p: 1, m: 0 }}>
|
||||||
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
||||||
@@ -79,7 +115,7 @@ export const BolsaLocalWidget = () => {
|
|||||||
<IconButton
|
<IconButton
|
||||||
aria-label={`ver historial de ${row.ticker}`}
|
aria-label={`ver historial de ${row.ticker}`}
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => handleRowClick(row.ticker)}
|
onClick={(event) => handleOpenModal(row.ticker, event)}
|
||||||
sx={{
|
sx={{
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||||
transition: 'all 0.2s ease-in-out',
|
transition: 'all 0.2s ease-in-out',
|
||||||
@@ -94,26 +130,33 @@ export const BolsaLocalWidget = () => {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
)}
|
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={Boolean(selectedTicker)} onClose={handleCloseDialog} maxWidth="md" fullWidth
|
open={Boolean(selectedTicker)}
|
||||||
|
onClose={handleCloseDialog}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }}
|
sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label="close" onClick={handleCloseDialog}
|
aria-label="close"
|
||||||
|
onClick={handleCloseDialog}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute', top: -15, right: -15, color: (theme) => theme.palette.grey[500],
|
position: 'absolute', top: -15, right: -15,
|
||||||
backgroundColor: 'white', boxShadow: 3, '&:hover': { backgroundColor: 'grey.100' },
|
color: (theme) => theme.palette.grey[500],
|
||||||
|
backgroundColor: 'white', boxShadow: 3,
|
||||||
|
'&:hover': { backgroundColor: 'grey.100' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<DialogTitle sx={{ m: 0, p: 2 }}>Historial de 30 días para: {selectedTicker}</DialogTitle>
|
<DialogTitle sx={{ m: 0, p: 2 }}>
|
||||||
|
Historial de 30 días para: {selectedTicker}
|
||||||
|
</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
{selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="Local" dias={30} />}
|
{selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="Local" dias={30} />}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer,
|
Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer,
|
||||||
TableHead, TableRow, Paper, Typography, Dialog, DialogTitle,
|
TableHead, TableRow, Paper, Typography, Dialog, DialogTitle,
|
||||||
@@ -8,12 +8,19 @@ import CloseIcon from '@mui/icons-material/Close';
|
|||||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||||
import RemoveIcon from '@mui/icons-material/Remove';
|
import RemoveIcon from '@mui/icons-material/Remove';
|
||||||
import { formatFullDateTime, formatCurrency } from '../utils/formatters';
|
|
||||||
import type { CotizacionBolsa } from '../models/mercadoModels';
|
|
||||||
import { useApiData } from '../hooks/useApiData';
|
|
||||||
import { HistoricalChartWidget } from './HistoricalChartWidget';
|
|
||||||
import { PiChartLineUpBold } from 'react-icons/pi';
|
import { PiChartLineUpBold } from 'react-icons/pi';
|
||||||
|
|
||||||
|
// Importaciones de modelos, hooks y utilidades
|
||||||
|
import type { CotizacionBolsa } from '../models/mercadoModels';
|
||||||
|
import { useApiData } from '../hooks/useApiData';
|
||||||
|
import { useIsHoliday } from '../hooks/useIsHoliday';
|
||||||
|
import { formatFullDateTime, formatCurrency } from '../utils/formatters';
|
||||||
|
import { HistoricalChartWidget } from './HistoricalChartWidget';
|
||||||
|
import { HolidayAlert } from './common/HolidayAlert';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub-componente para mostrar la variación porcentual con un icono y color apropiado.
|
||||||
|
*/
|
||||||
const Variacion = ({ value }: { value: number }) => {
|
const Variacion = ({ value }: { value: number }) => {
|
||||||
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
|
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
|
||||||
const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon;
|
const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon;
|
||||||
@@ -25,31 +32,64 @@ const Variacion = ({ value }: { value: number }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget autónomo para la tabla de acciones de EEUU y ADRs Argentinos.
|
||||||
|
*/
|
||||||
export const BolsaUsaWidget = () => {
|
export const BolsaUsaWidget = () => {
|
||||||
const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu');
|
// 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 isHoliday = useIsHoliday('US'); // <-- Usamos el código de mercado 'US'
|
||||||
|
|
||||||
|
// Estado y referencia para manejar el modal del gráfico.
|
||||||
const [selectedTicker, setSelectedTicker] = useState<string | null>(null);
|
const [selectedTicker, setSelectedTicker] = useState<string | null>(null);
|
||||||
|
const triggerButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
const handleRowClick = (ticker: string) => setSelectedTicker(ticker);
|
const handleOpenModal = (ticker: string, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
const handleCloseDialog = () => setSelectedTicker(null);
|
triggerButtonRef.current = event.currentTarget;
|
||||||
|
setSelectedTicker(ticker);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setSelectedTicker(null);
|
||||||
|
// Devuelve el foco al botón que abrió el modal para mejorar la accesibilidad.
|
||||||
|
setTimeout(() => {
|
||||||
|
triggerButtonRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtramos para obtener solo las acciones, excluyendo el índice S&P 500.
|
||||||
const otherStocks = data?.filter(d => d.ticker !== '^GSPC') || [];
|
const otherStocks = data?.filter(d => d.ticker !== '^GSPC') || [];
|
||||||
|
|
||||||
if (loading) {
|
// Estado de carga unificado: el componente está "cargando" si los datos principales
|
||||||
|
// o la información del feriado todavía no han llegado.
|
||||||
|
const isLoading = dataLoading || isHoliday === null;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (dataError) {
|
||||||
return <Alert severity="error">{error}</Alert>;
|
return <Alert severity="error">{dataError}</Alert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
// Si después de filtrar no queda ninguna acción, mostramos el mensaje apropiado.
|
||||||
return <Alert severity="info">No hay datos disponibles para el mercado de EEUU.</Alert>;
|
if (otherStocks.length === 0) {
|
||||||
|
// Si sabemos que es feriado, la alerta de feriado es el mensaje más relevante.
|
||||||
|
if (isHoliday) {
|
||||||
|
return <HolidayAlert />;
|
||||||
|
}
|
||||||
|
return <Alert severity="info">No hay acciones de EEUU disponibles para mostrar.</Alert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Box>
|
||||||
{/* Renderizamos la tabla solo si hay otras acciones */}
|
{/* Si es feriado, mostramos la alerta informativa en la parte superior. */}
|
||||||
{otherStocks.length > 0 && (
|
{isHoliday && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<HolidayAlert />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<TableContainer component={Paper}>
|
<TableContainer component={Paper}>
|
||||||
<Box sx={{ p: 1, m: 0 }}>
|
<Box sx={{ p: 1, m: 0 }}>
|
||||||
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
||||||
@@ -61,10 +101,8 @@ export const BolsaUsaWidget = () => {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Símbolo</TableCell>
|
<TableCell>Símbolo</TableCell>
|
||||||
<TableCell align="right">Precio Actual</TableCell>
|
<TableCell align="right">Precio Actual</TableCell>
|
||||||
|
|
||||||
<TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>Apertura</TableCell>
|
<TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>Apertura</TableCell>
|
||||||
<TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>Cierre Anterior</TableCell>
|
<TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>Cierre Anterior</TableCell>
|
||||||
|
|
||||||
<TableCell align="center">% Cambio</TableCell>
|
<TableCell align="center">% Cambio</TableCell>
|
||||||
<TableCell align="center">Historial</TableCell>
|
<TableCell align="center">Historial</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -74,34 +112,55 @@ export const BolsaUsaWidget = () => {
|
|||||||
<TableRow key={row.ticker} hover>
|
<TableRow key={row.ticker} hover>
|
||||||
<TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell>
|
<TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell>
|
||||||
<TableCell align="right">{formatCurrency(row.precioActual, 'USD')}</TableCell>
|
<TableCell align="right">{formatCurrency(row.precioActual, 'USD')}</TableCell>
|
||||||
|
|
||||||
<TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>{formatCurrency(row.apertura, 'USD')}</TableCell>
|
<TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>{formatCurrency(row.apertura, 'USD')}</TableCell>
|
||||||
<TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>{formatCurrency(row.cierreAnterior, 'USD')}</TableCell>
|
<TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>{formatCurrency(row.cierreAnterior, 'USD')}</TableCell>
|
||||||
|
|
||||||
<TableCell align="center"><Variacion value={row.porcentajeCambio} /></TableCell>
|
<TableCell align="center"><Variacion value={row.porcentajeCambio} /></TableCell>
|
||||||
<TableCell align="center">
|
<TableCell align="center">
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label={`ver historial de ${row.ticker}`} size="small"
|
aria-label={`ver historial de ${row.ticker}`}
|
||||||
onClick={() => handleRowClick(row.ticker)}
|
size="small"
|
||||||
sx={{ boxShadow: '0 1px 3px rgba(0,0,0,0.1)', transition: 'all 0.2s ease-in-out', '&:hover': { transform: 'scale(1.1)', boxShadow: '0 2px 6px rgba(0,0,0,0.2)' } }}
|
onClick={(event) => handleOpenModal(row.ticker, event)}
|
||||||
><PiChartLineUpBold size="18" /></IconButton>
|
sx={{
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
'&:hover': { transform: 'scale(1.1)', boxShadow: '0 2px 6px rgba(0,0,0,0.2)' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PiChartLineUpBold size="18" />
|
||||||
|
</IconButton>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
)}
|
|
||||||
|
|
||||||
<Dialog open={Boolean(selectedTicker)} onClose={handleCloseDialog} maxWidth="md" fullWidth sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }}>
|
<Dialog
|
||||||
<IconButton aria-label="close" onClick={handleCloseDialog} sx={{ position: 'absolute', top: -15, right: -15, color: (theme) => theme.palette.grey[500], backgroundColor: 'white', boxShadow: 3, '&:hover': { backgroundColor: 'grey.100' }, }}>
|
open={Boolean(selectedTicker)}
|
||||||
|
onClose={handleCloseDialog}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
aria-label="close"
|
||||||
|
onClick={handleCloseDialog}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute', top: -15, right: -15,
|
||||||
|
color: (theme) => theme.palette.grey[500],
|
||||||
|
backgroundColor: 'white', boxShadow: 3,
|
||||||
|
'&:hover': { backgroundColor: 'grey.100' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<DialogTitle sx={{ m: 0, p: 2 }}>Historial de 30 días para: {selectedTicker}</DialogTitle>
|
<DialogTitle sx={{ m: 0, p: 2 }}>
|
||||||
|
Historial de 30 días para: {selectedTicker}
|
||||||
|
</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
{selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="EEUU" dias={30} />}
|
{selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="EEUU" dias={30} />}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -2,11 +2,16 @@ import { Box, CircularProgress, Alert } from '@mui/material';
|
|||||||
import type { CotizacionGrano } from '../models/mercadoModels';
|
import type { CotizacionGrano } from '../models/mercadoModels';
|
||||||
import { useApiData } from '../hooks/useApiData';
|
import { useApiData } from '../hooks/useApiData';
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||||
|
import { formatFullDateTime } from '../utils/formatters';
|
||||||
|
|
||||||
interface GrainsHistoricalChartWidgetProps {
|
interface GrainsHistoricalChartWidgetProps {
|
||||||
nombre: string;
|
nombre: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatTooltipLabel = (label: string) => {
|
||||||
|
return formatFullDateTime(label);
|
||||||
|
};
|
||||||
|
|
||||||
const formatXAxis = (tickItem: string) => {
|
const formatXAxis = (tickItem: string) => {
|
||||||
const date = new Date(tickItem);
|
const date = new Date(tickItem);
|
||||||
return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' });
|
return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' });
|
||||||
@@ -32,9 +37,13 @@ export const GrainsHistoricalChartWidget = ({ nombre }: GrainsHistoricalChartWid
|
|||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
{/* Para los granos, usamos la fecha de operación en el eje X, que es más relevante */}
|
||||||
<XAxis dataKey="fechaOperacion" tickFormatter={formatXAxis} />
|
<XAxis dataKey="fechaOperacion" tickFormatter={formatXAxis} />
|
||||||
<YAxis domain={['dataMin - 1000', 'dataMax + 1000']} tickFormatter={(tick) => `$${tick.toLocaleString('es-AR')}`} />
|
<YAxis domain={['dataMin - 1000', 'dataMax + 1000']} tickFormatter={(tick) => `$${tick.toLocaleString('es-AR')}`} />
|
||||||
<Tooltip formatter={(value: number) => [`$${value.toFixed(0)}`, 'Precio']} />
|
<Tooltip
|
||||||
|
formatter={(value: number) => [`$${value.toFixed(0)}`, 'Precio']}
|
||||||
|
labelFormatter={formatTooltipLabel}
|
||||||
|
/>
|
||||||
<Legend />
|
<Legend />
|
||||||
<Line type="monotone" dataKey="precio" name="Precio" stroke="#028fbe" strokeWidth={2} dot={false} />
|
<Line type="monotone" dataKey="precio" name="Precio" stroke="#028fbe" strokeWidth={2} dot={false} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
|
|||||||
@@ -8,13 +8,17 @@ import CloseIcon from '@mui/icons-material/Close';
|
|||||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||||
import RemoveIcon from '@mui/icons-material/Remove';
|
import RemoveIcon from '@mui/icons-material/Remove';
|
||||||
import { GiSunflower, GiWheat, GiCorn, GiGrain } from "react-icons/gi";
|
import { GiSunflower, GiWheat, GiCorn, GiGrainBundle } from "react-icons/gi";
|
||||||
import { TbGrain } from "react-icons/tb";
|
import { TbGrain } from "react-icons/tb";
|
||||||
|
|
||||||
import type { CotizacionGrano } from '../models/mercadoModels';
|
import type { CotizacionGrano } from '../models/mercadoModels';
|
||||||
import { useApiData } from '../hooks/useApiData';
|
import { useApiData } from '../hooks/useApiData';
|
||||||
import { formatInteger, formatDateOnly } from '../utils/formatters';
|
import { formatInteger, formatDateOnly } from '../utils/formatters';
|
||||||
import { GrainsHistoricalChartWidget } from './GrainsHistoricalChartWidget';
|
import { GrainsHistoricalChartWidget } from './GrainsHistoricalChartWidget';
|
||||||
|
import { LuBean } from 'react-icons/lu';
|
||||||
|
|
||||||
|
import { useIsHoliday } from '../hooks/useIsHoliday';
|
||||||
|
import { HolidayAlert } from './common/HolidayAlert';
|
||||||
|
|
||||||
const getGrainIcon = (nombre: string) => {
|
const getGrainIcon = (nombre: string) => {
|
||||||
switch (nombre.toLowerCase()) {
|
switch (nombre.toLowerCase()) {
|
||||||
@@ -22,7 +26,8 @@ const getGrainIcon = (nombre: string) => {
|
|||||||
case 'trigo': return <GiWheat size={28} color="#fbc02d" />;
|
case 'trigo': return <GiWheat size={28} color="#fbc02d" />;
|
||||||
case 'sorgo': return <TbGrain size={28} color="#fbc02d" />;
|
case 'sorgo': return <TbGrain size={28} color="#fbc02d" />;
|
||||||
case 'maiz': return <GiCorn size={28} color="#fbc02d" />;
|
case 'maiz': return <GiCorn size={28} color="#fbc02d" />;
|
||||||
default: return <GiGrain size={28} color="#fbc02d" />;
|
case 'soja': return <LuBean size={28} color="#fbc02d" />;
|
||||||
|
default: return <GiGrainBundle size={28} color="#fbc02d" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,7 +43,7 @@ const GranoCard = ({ grano, onChartClick }: { grano: CotizacionGrano, onChartCli
|
|||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
p: 2, display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
p: 2, display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
||||||
flex: '1 1 180px', minWidth: '180px', maxWidth: '220px', height: '160px',
|
flex: '1 1 180px', minWidth: '180px', maxWidth: '220px', height: '180px',
|
||||||
borderTop: `4px solid ${isPositive ? '#2e7d32' : isNegative ? '#d32f2f' : '#bdbdbd'}`
|
borderTop: `4px solid ${isPositive ? '#2e7d32' : isNegative ? '#d32f2f' : '#bdbdbd'}`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -94,6 +99,7 @@ const GranoCard = ({ grano, onChartClick }: { grano: CotizacionGrano, onChartCli
|
|||||||
|
|
||||||
export const GranosCardWidget = () => {
|
export const GranosCardWidget = () => {
|
||||||
const { data, loading, error } = useApiData<CotizacionGrano[]>('/mercados/granos');
|
const { data, loading, error } = useApiData<CotizacionGrano[]>('/mercados/granos');
|
||||||
|
const isHoliday = useIsHoliday('BA');
|
||||||
const [selectedGrano, setSelectedGrano] = useState<string | null>(null);
|
const [selectedGrano, setSelectedGrano] = useState<string | null>(null);
|
||||||
const triggerButtonRef = useRef<HTMLButtonElement | null>(null);
|
const triggerButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
@@ -110,6 +116,7 @@ export const GranosCardWidget = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
// El spinner de carga sigue siendo prioritario
|
||||||
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +130,12 @@ export const GranosCardWidget = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Si es feriado (y la comprobación ha terminado), mostramos la alerta encima */}
|
||||||
|
{isHoliday === true && (
|
||||||
|
<Box sx={{ mb: 2 }}> {/* Añadimos un margen inferior a la alerta */}
|
||||||
|
<HolidayAlert />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -2,19 +2,20 @@ import {
|
|||||||
Box, CircularProgress, Alert, Table, TableBody, TableCell,
|
Box, CircularProgress, Alert, Table, TableBody, TableCell,
|
||||||
TableContainer, TableHead, TableRow, Paper, Typography, Tooltip
|
TableContainer, TableHead, TableRow, Paper, Typography, Tooltip
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import type { CotizacionGrano } from '../models/mercadoModels';
|
|
||||||
import { useApiData } from '../hooks/useApiData';
|
|
||||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||||
import RemoveIcon from '@mui/icons-material/Remove';
|
import RemoveIcon from '@mui/icons-material/Remove';
|
||||||
|
|
||||||
const formatNumber = (num: number) => {
|
// Importaciones de nuestro proyecto
|
||||||
return new Intl.NumberFormat('es-AR', {
|
import type { CotizacionGrano } from '../models/mercadoModels';
|
||||||
minimumFractionDigits: 0,
|
import { useApiData } from '../hooks/useApiData';
|
||||||
maximumFractionDigits: 2,
|
import { useIsHoliday } from '../hooks/useIsHoliday';
|
||||||
}).format(num);
|
import { formatInteger, formatDateOnly, formatFullDateTime } from '../utils/formatters';
|
||||||
};
|
import { HolidayAlert } from './common/HolidayAlert';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub-componente para mostrar la variación con icono y color.
|
||||||
|
*/
|
||||||
const Variacion = ({ value }: { value: number }) => {
|
const Variacion = ({ value }: { value: number }) => {
|
||||||
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
|
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
|
||||||
const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon;
|
const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon;
|
||||||
@@ -23,28 +24,48 @@ const Variacion = ({ value }: { value: number }) => {
|
|||||||
<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' }}>
|
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>
|
||||||
{formatNumber(value)}
|
{formatInteger(value)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget autónomo para la tabla detallada del mercado de granos.
|
||||||
|
*/
|
||||||
export const GranosWidget = () => {
|
export const GranosWidget = () => {
|
||||||
const { data, loading, error } = useApiData<CotizacionGrano[]>('/mercados/granos');
|
// 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 isHoliday = useIsHoliday('BA');
|
||||||
|
|
||||||
if (loading) {
|
// Estado de carga unificado.
|
||||||
|
const isLoading = dataLoading || isHoliday === null;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (dataError) {
|
||||||
return <Alert severity="error">{error}</Alert>;
|
return <Alert severity="error">{dataError}</Alert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Si no hay ningún dato que mostrar.
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
|
if (isHoliday) {
|
||||||
|
return <HolidayAlert />;
|
||||||
|
}
|
||||||
return <Alert severity="info">No hay datos de granos disponibles en este momento.</Alert>;
|
return <Alert severity="info">No hay datos de granos disponibles en este momento.</Alert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Si es feriado, mostramos la alerta como un aviso encima del contenido. */}
|
||||||
|
{isHoliday && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<HolidayAlert />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<TableContainer component={Paper}>
|
<TableContainer component={Paper}>
|
||||||
<Table size="small" aria-label="tabla granos">
|
<Table size="small" aria-label="tabla granos">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
@@ -61,20 +82,21 @@ export const GranosWidget = () => {
|
|||||||
<TableCell component="th" scope="row">
|
<TableCell component="th" scope="row">
|
||||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.nombre}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.nombre}</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">${formatNumber(row.precio)}</TableCell>
|
<TableCell align="right">${formatInteger(row.precio)}</TableCell>
|
||||||
<TableCell align="center">
|
<TableCell align="center">
|
||||||
<Variacion value={row.variacionPrecio} />
|
<Variacion value={row.variacionPrecio} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">{new Date(row.fechaOperacion).toLocaleDateString('es-AR')}</TableCell>
|
<TableCell align="right">{formatDateOnly(row.fechaOperacion)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
<Tooltip title={`Última actualización: ${new Date(data[0].fechaRegistro).toLocaleString('es-AR')}`}>
|
<Tooltip title={`Última actualización: ${formatFullDateTime(data[0].fechaRegistro)}`}>
|
||||||
<Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}>
|
||||||
Fuente: Bolsa de Comercio de Rosario
|
Fuente: Bolsa de Comercio de Rosario
|
||||||
</Typography>
|
</Typography>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -2,6 +2,7 @@ import { Box, CircularProgress, Alert } from '@mui/material';
|
|||||||
import type { CotizacionBolsa } from '../models/mercadoModels';
|
import type { CotizacionBolsa } from '../models/mercadoModels';
|
||||||
import { useApiData } from '../hooks/useApiData';
|
import { useApiData } from '../hooks/useApiData';
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||||
|
import { formatFullDateTime, formatCurrency2Decimal, formatCurrency } from '../utils/formatters';
|
||||||
|
|
||||||
interface HistoricalChartWidgetProps {
|
interface HistoricalChartWidgetProps {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
@@ -9,10 +10,13 @@ interface HistoricalChartWidgetProps {
|
|||||||
dias: number;
|
dias: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Formateador para el eje X (muestra DD/MM)
|
// Formateador para el eje X que solo muestra día/mes
|
||||||
const formatXAxis = (tickItem: string) => {
|
const formatXAxis = (tickItem: string) => {
|
||||||
const date = new Date(tickItem);
|
return new Date(tickItem).toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' });
|
||||||
return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' });
|
};
|
||||||
|
|
||||||
|
const formatTooltipLabel = (label: string) => {
|
||||||
|
return formatFullDateTime(label);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HistoricalChartWidget = ({ ticker, mercado, dias }: HistoricalChartWidgetProps) => {
|
export const HistoricalChartWidget = ({ ticker, mercado, dias }: HistoricalChartWidgetProps) => {
|
||||||
@@ -31,15 +35,42 @@ export const HistoricalChartWidget = ({ ticker, mercado, dias }: HistoricalChart
|
|||||||
return <Alert severity="info" sx={{ height: 300 }}>No hay suficientes datos históricos para graficar.</Alert>;
|
return <Alert severity="info" sx={{ height: 300 }}>No hay suficientes datos históricos para graficar.</Alert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. Calcular el dominio del eje Y con un margen
|
||||||
|
const prices = data.map(p => p.precioActual);
|
||||||
|
const dataMin = Math.min(...prices);
|
||||||
|
const dataMax = Math.max(...prices);
|
||||||
|
const padding = (dataMax - dataMin) * 0.5; // 5% de padding
|
||||||
|
const domainMin = Math.floor(dataMin - padding);
|
||||||
|
const domainMax = Math.ceil(dataMax + padding);
|
||||||
|
|
||||||
|
// 2. Formateador de ticks para el eje Y más robusto
|
||||||
|
const yAxisTickFormatter = (tick: number) => {
|
||||||
|
// Usamos el formateador de moneda
|
||||||
|
return `$${formatCurrency(tick)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Calcular el ancho del eje Y dinámicamente
|
||||||
|
const maxLabel = yAxisTickFormatter(dataMax);
|
||||||
|
// Calculamos un ancho base + un extra por cada carácter en la etiqueta más larga.
|
||||||
|
const dynamicWidth = mercado === 'EEUU' ? 5 + (maxLabel.length * 4.5) : 15 + (maxLabel.length * 5);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="fechaRegistro" tickFormatter={formatXAxis} />
|
<XAxis dataKey="fechaRegistro" tickFormatter={formatXAxis} />
|
||||||
<YAxis domain={['dataMin - 1', 'dataMax + 1']} tickFormatter={(tick) => `$${tick.toLocaleString('es-AR')}`} />
|
<YAxis
|
||||||
<Tooltip formatter={(value: number) => [`$${value.toFixed(2)}`, 'Precio']} />
|
domain={[domainMin, domainMax]}
|
||||||
|
tickFormatter={yAxisTickFormatter}
|
||||||
|
width={dynamicWidth}
|
||||||
|
tickMargin={5}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [`${formatCurrency2Decimal(value, mercado === 'EEUU' ? 'USD' : 'ARS')}`, 'Precio']}
|
||||||
|
labelFormatter={formatTooltipLabel}
|
||||||
|
/>
|
||||||
<Legend />
|
<Legend />
|
||||||
<Line type="monotone" dataKey="precioActual" name="Precio de Cierre" stroke="#028fbe" strokeWidth={2} dot={false} />
|
<Line type="monotone" dataKey="precioActual" name="Precio de Cierre" stroke="#8884d8" strokeWidth={2} dot={false} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Box, CircularProgress, Alert, Paper, Typography, Dialog,
|
Box, CircularProgress, Alert, Paper, Typography, Dialog,
|
||||||
DialogTitle, DialogContent, IconButton
|
DialogTitle, DialogContent, IconButton
|
||||||
@@ -8,34 +8,35 @@ import ScaleIcon from '@mui/icons-material/Scale';
|
|||||||
import { PiChartLineUpBold } from "react-icons/pi";
|
import { PiChartLineUpBold } from "react-icons/pi";
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
|
||||||
|
// Importaciones de nuestro proyecto
|
||||||
import type { CotizacionGanado } from '../models/mercadoModels';
|
import type { CotizacionGanado } from '../models/mercadoModels';
|
||||||
import { useApiData } from '../hooks/useApiData';
|
import { useApiData } from '../hooks/useApiData';
|
||||||
import { formatCurrency, formatInteger } from '../utils/formatters';
|
import { useIsHoliday } from '../hooks/useIsHoliday';
|
||||||
|
import { formatCurrency, formatInteger, formatDateOnly } from '../utils/formatters';
|
||||||
import { AgroHistoricalChartWidget } from './AgroHistoricalChartWidget';
|
import { AgroHistoricalChartWidget } from './AgroHistoricalChartWidget';
|
||||||
|
import { HolidayAlert } from './common/HolidayAlert';
|
||||||
|
|
||||||
// El subcomponente ahora tendrá un botón para el gráfico.
|
/**
|
||||||
const AgroCard = ({ registro, onChartClick }: { registro: CotizacionGanado, onChartClick: () => void }) => {
|
* Sub-componente para una única tarjeta de categoría de ganado.
|
||||||
|
*/
|
||||||
|
const AgroCard = ({ registro, onChartClick }: { registro: CotizacionGanado, onChartClick: (event: React.MouseEvent<HTMLButtonElement>) => void }) => {
|
||||||
return (
|
return (
|
||||||
// Añadimos posición relativa para poder posicionar el botón del gráfico.
|
<Paper elevation={2} sx={{ p: 2, flex: '1 1 250px', minWidth: '250px', maxWidth: '300px', position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
||||||
<Paper elevation={2} sx={{ p: 2, flex: '1 1 250px', minWidth: '250px', maxWidth: '300px', position: 'relative' }}>
|
{/* Contenido principal de la tarjeta */}
|
||||||
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label="ver historial"
|
aria-label="ver historial"
|
||||||
onClick={(e) => {
|
onClick={onChartClick}
|
||||||
e.stopPropagation();
|
|
||||||
onChartClick();
|
|
||||||
}}
|
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute', top: 8, right: 8,
|
||||||
top: 8,
|
backgroundColor: 'rgba(255, 255, 255, 0.7)',
|
||||||
right: 8,
|
backdropFilter: 'blur(2px)',
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.7)', // Fondo semitransparente
|
|
||||||
backdropFilter: 'blur(2px)', // Efecto "frosty glass" para el fondo
|
|
||||||
border: '1px solid rgba(0, 0, 0, 0.1)',
|
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||||
boxShadow: '0 2px 5px rgba(0,0,0,0.1)',
|
boxShadow: '0 2px 5px rgba(0,0,0,0.1)',
|
||||||
transition: 'all 0.2s ease-in-out', // Transición suave para todos los cambios
|
transition: 'all 0.2s ease-in-out',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
transform: 'translateY(-2px)', // Se eleva un poco
|
transform: 'translateY(-2px)',
|
||||||
boxShadow: '0 4px 10px rgba(0,0,0,0.2)', // La sombra se hace más grande
|
boxShadow: '0 4px 10px rgba(0,0,0,0.2)',
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -43,7 +44,7 @@ const AgroCard = ({ registro, onChartClick }: { registro: CotizacionGanado, onCh
|
|||||||
<PiChartLineUpBold size="20" />
|
<PiChartLineUpBold size="20" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold', borderBottom: 1, borderColor: 'divider', pb: 1, mb: 2, pr: 5 /* Espacio para el botón */ }}>
|
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold', borderBottom: 1, borderColor: 'divider', pb: 1, mb: 2, pr: 5 }}>
|
||||||
{registro.categoria}
|
{registro.categoria}
|
||||||
<Typography variant="body2" color="text.secondary">{registro.especificaciones}</Typography>
|
<Typography variant="body2" color="text.secondary">{registro.especificaciones}</Typography>
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -60,8 +61,11 @@ const AgroCard = ({ registro, onChartClick }: { registro: CotizacionGanado, onCh
|
|||||||
<Typography variant="body2" color="text.secondary">Precio Mediano:</Typography>
|
<Typography variant="body2" color="text.secondary">Precio Mediano:</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>${formatCurrency(registro.mediano)}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>${formatCurrency(registro.mediano)}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3, pt: 1, borderTop: 1, borderColor: 'divider' }}>
|
{/* Pie de la tarjeta */}
|
||||||
|
<Box sx={{ mt: 'auto', pt: 1, borderTop: 1, borderColor: 'divider' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
<PiCow size="28" />
|
<PiCow size="28" />
|
||||||
<Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(registro.cabezas)}</Typography>
|
<Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(registro.cabezas)}</Typography>
|
||||||
@@ -73,66 +77,90 @@ const AgroCard = ({ registro, onChartClick }: { registro: CotizacionGanado, onCh
|
|||||||
<Typography variant="caption" color="text.secondary">Kilos</Typography>
|
<Typography variant="caption" color="text.secondary">Kilos</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', textAlign: 'left', color: 'text.secondary', mt: 1, pt: 1, borderTop: 1, borderColor: 'divider' }}>
|
||||||
|
{formatDateOnly(registro.fechaRegistro)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget autónomo para las tarjetas de resumen del Mercado Agroganadero.
|
||||||
|
*/
|
||||||
export const MercadoAgroCardWidget = () => {
|
export const MercadoAgroCardWidget = () => {
|
||||||
const { data, loading, error } = useApiData<CotizacionGanado[]>('/mercados/agroganadero');
|
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGanado[]>('/mercados/agroganadero');
|
||||||
const [selectedCategory, setSelectedCategory] = useState<CotizacionGanado | null>(null);
|
const isHoliday = useIsHoliday('BA');
|
||||||
|
|
||||||
const handleChartClick = (registro: CotizacionGanado) => {
|
const [selectedCategory, setSelectedCategory] = useState<CotizacionGanado | null>(null);
|
||||||
|
const triggerButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
const handleChartClick = (registro: CotizacionGanado, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
triggerButtonRef.current = event.currentTarget;
|
||||||
setSelectedCategory(registro);
|
setSelectedCategory(registro);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseDialog = () => {
|
const handleCloseDialog = () => {
|
||||||
setSelectedCategory(null);
|
setSelectedCategory(null);
|
||||||
|
setTimeout(() => {
|
||||||
|
triggerButtonRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
const isLoading = dataLoading || isHoliday === null;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||||
}
|
}
|
||||||
if (error) {
|
|
||||||
return <Alert severity="error">{error}</Alert>;
|
if (dataError) {
|
||||||
|
return <Alert severity="error">{dataError}</Alert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
|
if (isHoliday) {
|
||||||
|
return <HolidayAlert />;
|
||||||
|
}
|
||||||
return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>;
|
return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, justifyContent: 'center' }}>
|
{isHoliday && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<HolidayAlert />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: { xs: 2, sm: 2, md: 3 }, justifyContent: 'center' }}>
|
||||||
{data.map(registro => (
|
{data.map(registro => (
|
||||||
<AgroCard key={registro.id} registro={registro} onChartClick={() => handleChartClick(registro)} />
|
<AgroCard key={registro.id} registro={registro} onChartClick={(event) => handleChartClick(registro, event)} />
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={Boolean(selectedCategory)}
|
open={Boolean(selectedCategory)}
|
||||||
onClose={handleCloseDialog}
|
onClose={handleCloseDialog}
|
||||||
maxWidth="md"
|
maxWidth="md"
|
||||||
fullWidth
|
fullWidth
|
||||||
sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }} // Permite que el botón se vea fuera
|
sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label="close"
|
aria-label="close"
|
||||||
onClick={handleCloseDialog}
|
onClick={handleCloseDialog}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute', top: -15, right: -15,
|
||||||
top: -15, // Mueve el botón hacia arriba, fuera del Dialog
|
|
||||||
right: -15, // Mueve el botón hacia la derecha, fuera del Dialog
|
|
||||||
color: (theme) => theme.palette.grey[500],
|
color: (theme) => theme.palette.grey[500],
|
||||||
backgroundColor: 'white',
|
backgroundColor: 'white', boxShadow: 3,
|
||||||
boxShadow: 3, // Añade una sombra para que destaque
|
'&:hover': { backgroundColor: 'grey.100' },
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'grey.100', // Un leve cambio de color al pasar el mouse
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<DialogTitle sx={{ m: 0, p: 2 }}>
|
<DialogTitle sx={{ m: 0, p: 2 }}>
|
||||||
Mensual de {selectedCategory?.categoria} ({selectedCategory?.especificaciones})
|
Historial de 30 días para {selectedCategory?.categoria} ({selectedCategory?.especificaciones})
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
{selectedCategory && (
|
{selectedCategory && (
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import {
|
|||||||
Box, CircularProgress, Alert, Table, TableBody, TableCell,
|
Box, CircularProgress, Alert, Table, TableBody, TableCell,
|
||||||
TableContainer, TableHead, TableRow, Paper, Typography, Tooltip
|
TableContainer, TableHead, TableRow, Paper, Typography, Tooltip
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
|
// Importaciones de nuestro proyecto
|
||||||
import type { CotizacionGanado } from '../models/mercadoModels';
|
import type { CotizacionGanado } from '../models/mercadoModels';
|
||||||
import { useApiData } from '../hooks/useApiData';
|
import { useApiData } from '../hooks/useApiData';
|
||||||
import { formatCurrency, formatInteger, formatFullDateTime } from '../utils/formatters';
|
import { useIsHoliday } from '../hooks/useIsHoliday';
|
||||||
|
import { formatCurrency, formatInteger, formatDateOnly } from '../utils/formatters';
|
||||||
|
import { HolidayAlert } from './common/HolidayAlert';
|
||||||
|
|
||||||
// --- V INICIO DE LA MODIFICACIÓN V ---
|
/**
|
||||||
// El sub-componente ahora solo necesita renderizar la tarjeta de móvil.
|
* Sub-componente para renderizar cada registro como una tarjeta en la vista móvil.
|
||||||
// La fila de la tabla la haremos directamente en el componente principal.
|
*/
|
||||||
const AgroDataCard = ({ row }: { row: CotizacionGanado }) => {
|
const AgroDataCard = ({ row }: { row: CotizacionGanado }) => {
|
||||||
const commonStyles = {
|
const commonStyles = {
|
||||||
cell: {
|
cell: {
|
||||||
@@ -56,18 +60,42 @@ const AgroDataCard = ({ row }: { row: CotizacionGanado }) => {
|
|||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
// --- ^ FIN DE LA MODIFICACIÓN ^ ---
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget autónomo para la tabla/lista responsiva del Mercado Agroganadero.
|
||||||
|
*/
|
||||||
export const MercadoAgroWidget = () => {
|
export const MercadoAgroWidget = () => {
|
||||||
const { data, loading, error } = useApiData<CotizacionGanado[]>('/mercados/agroganadero');
|
// Hooks para obtener los datos y el estado de feriado.
|
||||||
|
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGanado[]>('/mercados/agroganadero');
|
||||||
|
const isHoliday = useIsHoliday('BA');
|
||||||
|
|
||||||
if (loading) { return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; }
|
// Estado de carga unificado.
|
||||||
if (error) { return <Alert severity="error">{error}</Alert>; }
|
const isLoading = dataLoading || isHoliday === null;
|
||||||
if (!data || data.length === 0) { return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>; }
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataError) {
|
||||||
|
return <Alert severity="error">{dataError}</Alert>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
if (isHoliday) {
|
||||||
|
return <HolidayAlert />;
|
||||||
|
}
|
||||||
|
return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
{/* Si es feriado, mostramos la alerta como un aviso encima del contenido. */}
|
||||||
|
{isHoliday && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<HolidayAlert />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* VISTA DE ESCRITORIO (se oculta en móvil) */}
|
{/* VISTA DE ESCRITORIO (se oculta en móvil) */}
|
||||||
<TableContainer component={Paper} sx={{ display: { xs: 'none', md: 'block' } }}>
|
<TableContainer component={Paper} sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||||
<Table size="small" aria-label="tabla mercado agroganadero">
|
<Table size="small" aria-label="tabla mercado agroganadero">
|
||||||
@@ -107,9 +135,10 @@ export const MercadoAgroWidget = () => {
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Tooltip title={`Última actualización: ${formatFullDateTime(data[0].fechaRegistro)}`}>
|
{/* La información de la fuente se muestra siempre, usando la fecha del primer registro */}
|
||||||
|
<Tooltip title={`Última actualización: ${formatDateOnly(data[0].fechaRegistro)}`}>
|
||||||
<Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}>
|
||||||
Fuente: Mercado Agroganadero S.A.
|
{formatDateOnly(data[0].fechaRegistro)} - Fuente: Mercado Agroganadero S.A.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
|||||||
import RemoveIcon from '@mui/icons-material/Remove';
|
import RemoveIcon from '@mui/icons-material/Remove';
|
||||||
|
|
||||||
import type { CotizacionBolsa } from '../models/mercadoModels';
|
import type { CotizacionBolsa } from '../models/mercadoModels';
|
||||||
import { formatInteger, formatCurrency } from '../utils/formatters'; // <-- CORREGIDO: necesitamos formatCurrency
|
import { formatCurrency2Decimal, formatCurrency } from '../utils/formatters';
|
||||||
import { HistoricalChartWidget } from './HistoricalChartWidget';
|
import { HistoricalChartWidget } from './HistoricalChartWidget';
|
||||||
import { useApiData } from '../hooks/useApiData';
|
import { useApiData } from '../hooks/useApiData';
|
||||||
|
import { useIsHoliday } from '../hooks/useIsHoliday'; // <-- Importamos el hook
|
||||||
|
import { HolidayAlert } from './common/HolidayAlert'; // <-- Importamos la alerta
|
||||||
|
|
||||||
// --- V SUB-COMPONENTE AÑADIDO V ---
|
/**
|
||||||
|
* Sub-componente para la variación del índice.
|
||||||
|
*/
|
||||||
const VariacionMerval = ({ actual, anterior }: { actual: number, anterior: number }) => {
|
const VariacionMerval = ({ actual, anterior }: { actual: number, anterior: number }) => {
|
||||||
if (anterior === 0) return null; // Evitar división por cero
|
if (anterior === 0) return null;
|
||||||
const variacionPuntos = actual - anterior;
|
const variacionPuntos = actual - anterior;
|
||||||
const variacionPorcentaje = (variacionPuntos / anterior) * 100;
|
const variacionPorcentaje = (variacionPuntos / anterior) * 100;
|
||||||
|
|
||||||
@@ -34,31 +38,65 @@ const VariacionMerval = ({ actual, anterior }: { actual: number, anterior: numbe
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
// --- ^ SUB-COMPONENTE AÑADIDO ^ ---
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget autónomo para la tarjeta de héroe del S&P Merval.
|
||||||
|
*/
|
||||||
export const MervalHeroCard = () => {
|
export const MervalHeroCard = () => {
|
||||||
const { data: allLocalData, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local');
|
// Cada widget gestiona sus propias llamadas a la API
|
||||||
|
const { data: allLocalData, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local');
|
||||||
|
const isHoliday = useIsHoliday('BA');
|
||||||
|
|
||||||
|
// Estado interno para el gráfico
|
||||||
const [dias, setDias] = useState<number>(30);
|
const [dias, setDias] = useState<number>(30);
|
||||||
|
|
||||||
const handleRangoChange = ( _event: React.MouseEvent<HTMLElement>, nuevoRango: number | null ) => {
|
const handleRangoChange = ( _event: React.MouseEvent<HTMLElement>, nuevoRango: number | null ) => {
|
||||||
if (nuevoRango !== null) { setDias(nuevoRango); }
|
if (nuevoRango !== null) { setDias(nuevoRango); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filtramos el dato específico que este widget necesita
|
||||||
const mervalData = allLocalData?.find(d => d.ticker === '^MERV');
|
const mervalData = allLocalData?.find(d => d.ticker === '^MERV');
|
||||||
|
|
||||||
if (loading) { return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; }
|
// --- LÓGICA DE RENDERIZADO CORREGIDA ---
|
||||||
if (error) { return <Alert severity="error">{error}</Alert>; }
|
|
||||||
if (!mervalData) { return <Alert severity="info">No se encontraron datos para el índice MERVAL.</Alert>; }
|
|
||||||
|
|
||||||
|
// El estado de carga depende de AMBAS llamadas a la API.
|
||||||
|
const isLoading = dataLoading || isHoliday === null;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4, height: '288px' }}><CircularProgress /></Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataError) {
|
||||||
|
return <Alert severity="error">{dataError}</Alert>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si no hay datos del Merval, es un estado final.
|
||||||
|
if (!mervalData) {
|
||||||
|
// Si no hay datos PERO sabemos que es feriado, la alerta de feriado es más informativa.
|
||||||
|
if (isHoliday) {
|
||||||
|
return <HolidayAlert />;
|
||||||
|
}
|
||||||
|
return <Alert severity="info">No se encontraron datos para el índice MERVAL.</Alert>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si llegamos aquí, SÍ tenemos datos para mostrar.
|
||||||
return (
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Si es feriado, mostramos la alerta como un AVISO encima del contenido. */}
|
||||||
|
{isHoliday && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<HolidayAlert />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* El contenido principal del widget siempre se muestra si hay datos. */}
|
||||||
<Paper elevation={3} sx={{ p: 2, mb: 3 }}>
|
<Paper elevation={3} sx={{ p: 2, mb: 3 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>Índice S&P MERVAL</Typography>
|
<Typography variant="h6" component="h2" sx={{ fontWeight: 'bold' }}>Índice S&P MERVAL</Typography>
|
||||||
<Typography variant="h3" component="p" sx={{ fontWeight: 'bold', mt:1 }}>{formatInteger(mervalData.precioActual)}</Typography>
|
<Typography variant="h4" component="p" sx={{ fontWeight: 'bold', mt:1 }}>{formatCurrency2Decimal(mervalData.precioActual)}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ pt: 2 }}>
|
<Box sx={{ pt: 2 }}>
|
||||||
{/* Ahora sí encontrará el componente */}
|
|
||||||
<VariacionMerval actual={mervalData.precioActual} anterior={mervalData.cierreAnterior} />
|
<VariacionMerval actual={mervalData.precioActual} anterior={mervalData.cierreAnterior} />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -73,5 +111,6 @@ export const MervalHeroCard = () => {
|
|||||||
<HistoricalChartWidget ticker={mervalData.ticker} mercado="Local" dias={dias} />
|
<HistoricalChartWidget ticker={mervalData.ticker} mercado="Local" dias={dias} />
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
import { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Box, Paper, Typography, ToggleButton, ToggleButtonGroup, CircularProgress, Alert } from '@mui/material';
|
import { Box, Paper, Typography, ToggleButton, ToggleButtonGroup, CircularProgress, Alert } from '@mui/material';
|
||||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||||
import RemoveIcon from '@mui/icons-material/Remove';
|
import RemoveIcon from '@mui/icons-material/Remove';
|
||||||
|
|
||||||
|
// Importaciones de nuestro proyecto
|
||||||
import type { CotizacionBolsa } from '../models/mercadoModels';
|
import type { CotizacionBolsa } from '../models/mercadoModels';
|
||||||
import { useApiData } from '../hooks/useApiData';
|
import { useApiData } from '../hooks/useApiData';
|
||||||
|
import { useIsHoliday } from '../hooks/useIsHoliday';
|
||||||
import { formatInteger } from '../utils/formatters';
|
import { formatInteger } from '../utils/formatters';
|
||||||
import { HistoricalChartWidget } from './HistoricalChartWidget';
|
import { HistoricalChartWidget } from './HistoricalChartWidget';
|
||||||
|
import { HolidayAlert } from './common/HolidayAlert';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub-componente interno para mostrar la variación del índice.
|
||||||
|
*/
|
||||||
const VariacionIndex = ({ actual, anterior }: { actual: number, anterior: number }) => {
|
const VariacionIndex = ({ actual, anterior }: { actual: number, anterior: number }) => {
|
||||||
if (anterior === 0) return null;
|
if (anterior === 0) return null;
|
||||||
const variacionPuntos = actual - anterior;
|
const variacionPuntos = actual - anterior;
|
||||||
@@ -33,26 +40,58 @@ const VariacionIndex = ({ actual, anterior }: { actual: number, anterior: number
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget autónomo para la tarjeta de héroe del S&P 500.
|
||||||
|
*/
|
||||||
export const UsaIndexHeroCard = () => {
|
export const UsaIndexHeroCard = () => {
|
||||||
const { data: allUsaData, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu');
|
// Hooks para obtener los datos y el estado de feriado para el mercado de EEUU.
|
||||||
|
const { data: allUsaData, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu');
|
||||||
|
const isHoliday = useIsHoliday('US');
|
||||||
|
|
||||||
|
// Estado interno para el gráfico.
|
||||||
const [dias, setDias] = useState<number>(30);
|
const [dias, setDias] = useState<number>(30);
|
||||||
|
|
||||||
const handleRangoChange = ( _event: React.MouseEvent<HTMLElement>, nuevoRango: number | null ) => {
|
const handleRangoChange = ( _event: React.MouseEvent<HTMLElement>, nuevoRango: number | null ) => {
|
||||||
if (nuevoRango !== null) { setDias(nuevoRango); }
|
if (nuevoRango !== null) { setDias(nuevoRango); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filtramos el dato específico que este widget necesita.
|
||||||
const indexData = allUsaData?.find(d => d.ticker === '^GSPC');
|
const indexData = allUsaData?.find(d => d.ticker === '^GSPC');
|
||||||
|
|
||||||
if (loading) { return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; }
|
// Estado de carga unificado: esperamos a que AMBAS llamadas a la API terminen.
|
||||||
if (error) { return <Alert severity="error">{error}</Alert>; }
|
const isLoading = dataLoading || isHoliday === null;
|
||||||
if (!indexData) { return <Alert severity="info">No se encontraron datos para el índice S&P 500.</Alert>; }
|
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', p: 4, height: { xs: 'auto', md: '445px' } }}><CircularProgress /></Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataError) {
|
||||||
|
return <Alert severity="error">{dataError}</Alert>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si no hay datos del S&P 500, mostramos el mensaje apropiado.
|
||||||
|
if (!indexData) {
|
||||||
|
if (isHoliday) {
|
||||||
|
return <HolidayAlert />;
|
||||||
|
}
|
||||||
|
return <Alert severity="info">No se encontraron datos para el índice S&P 500.</Alert>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si hay datos, renderizamos el contenido completo.
|
||||||
return (
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Si es feriado, mostramos la alerta como un aviso encima del contenido. */}
|
||||||
|
{isHoliday && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<HolidayAlert />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Paper elevation={3} sx={{ p: 2, mb: 3 }}>
|
<Paper elevation={3} sx={{ p: 2, mb: 3 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>S&P 500 Index</Typography>
|
<Typography variant="h6" component="h2" sx={{ fontWeight: 'bold' }}>S&P 500 Index</Typography>
|
||||||
<Typography variant="h3" component="p" sx={{ fontWeight: 'bold', mt:1 }}>{formatInteger(indexData.precioActual)}</Typography>
|
<Typography variant="h4" component="p" sx={{ fontWeight: 'bold', mt:1 }}>{formatInteger(indexData.precioActual)}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ pt: 2 }}>
|
<Box sx={{ pt: 2 }}>
|
||||||
<VariacionIndex actual={indexData.precioActual} anterior={indexData.cierreAnterior} />
|
<VariacionIndex actual={indexData.precioActual} anterior={indexData.cierreAnterior} />
|
||||||
@@ -69,5 +108,6 @@ export const UsaIndexHeroCard = () => {
|
|||||||
<HistoricalChartWidget ticker={indexData.ticker} mercado="EEUU" dias={dias} />
|
<HistoricalChartWidget ticker={indexData.ticker} mercado="EEUU" dias={dias} />
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
16
frontend/src/components/common/HolidayAlert.tsx
Normal file
16
frontend/src/components/common/HolidayAlert.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Alert } from '@mui/material';
|
||||||
|
import CelebrationIcon from '@mui/icons-material/Celebration';
|
||||||
|
import { formatDateOnly } from '../../utils/formatters';
|
||||||
|
|
||||||
|
export const HolidayAlert = () => {
|
||||||
|
// Obtener la fecha actual en la zona horaria de Buenos Aires
|
||||||
|
const nowInBuenosAires = new Date(
|
||||||
|
new Date().toLocaleString('en-US', { timeZone: 'America/Argentina/Buenos_Aires' })
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert severity="info" icon={<CelebrationIcon fontSize="inherit" />}>
|
||||||
|
{formatDateOnly(nowInBuenosAires.toISOString())} Mercado cerrado por feriado.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
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 } 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 { CotizacionGanado } from '../../models/mercadoModels';
|
import type { CotizacionGanado } from '../../models/mercadoModels';
|
||||||
import { formatInteger, formatCurrency, formatFullDateTime } from '../../utils/formatters';
|
import { formatInteger, formatCurrency, formatFullDateTime } from '../../utils/formatters';
|
||||||
import { copyToClipboard } from '../../utils/clipboardUtils';
|
import { copyToClipboard } from '../../utils/clipboardUtils';
|
||||||
|
import { HolidayAlert } from '../common/HolidayAlert';
|
||||||
|
|
||||||
// Función para convertir datos a formato CSV
|
/**
|
||||||
|
* Función para convertir los datos de la tabla a un formato CSV para el portapapeles.
|
||||||
|
*/
|
||||||
const toCSV = (headers: string[], data: CotizacionGanado[]) => {
|
const toCSV = (headers: string[], data: CotizacionGanado[]) => {
|
||||||
const headerRow = headers.join(';');
|
const headerRow = headers.join(';');
|
||||||
const dataRows = data.map(row =>
|
const dataRows = data.map(row =>
|
||||||
@@ -17,18 +23,25 @@ const toCSV = (headers: string[], data: CotizacionGanado[]) => {
|
|||||||
formatCurrency(row.mediano),
|
formatCurrency(row.mediano),
|
||||||
formatInteger(row.cabezas),
|
formatInteger(row.cabezas),
|
||||||
formatInteger(row.kilosTotales),
|
formatInteger(row.kilosTotales),
|
||||||
formatInteger(row.importeTotal)
|
formatInteger(row.importeTotal),
|
||||||
|
formatFullDateTime(row.fechaRegistro)
|
||||||
].join(';')
|
].join(';')
|
||||||
);
|
);
|
||||||
return [headerRow, ...dataRows].join('\n');
|
return [headerRow, ...dataRows].join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente de tabla de datos crudos para el Mercado Agroganadero,
|
||||||
|
* diseñado para la página de redacción.
|
||||||
|
*/
|
||||||
export const RawAgroTable = () => {
|
export const RawAgroTable = () => {
|
||||||
const { data, loading, error } = useApiData<CotizacionGanado[]>('/mercados/agroganadero');
|
// Hooks para obtener los datos y el estado de feriado.
|
||||||
|
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGanado[]>('/mercados/agroganadero');
|
||||||
|
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"];
|
const headers = ["Categoría", "Especificaciones", "Máximo", "Mínimo", "Mediano", "Cabezas", "Kg Total", "Importe Total", "Fecha de Registro"];
|
||||||
const csvData = toCSV(headers, data);
|
const csvData = toCSV(headers, data);
|
||||||
|
|
||||||
copyToClipboard(csvData)
|
copyToClipboard(csvData)
|
||||||
@@ -39,12 +52,28 @@ export const RawAgroTable = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <CircularProgress />;
|
// Estado de carga unificado.
|
||||||
if (error) return <Alert severity="error">{error}</Alert>;
|
const isLoading = dataLoading || isHoliday === null;
|
||||||
if (!data) return null;
|
|
||||||
|
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 del mercado agroganadero disponibles.</Alert>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
{/* Si es feriado, mostramos una alerta informativa encima de la tabla. */}
|
||||||
|
{isHoliday && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<HolidayAlert />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}>
|
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}>
|
||||||
Copiar como CSV
|
Copiar como CSV
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
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 } 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';
|
||||||
|
|
||||||
// Función para convertir datos a formato CSV
|
/**
|
||||||
|
* Función para convertir los datos de la tabla a formato CSV.
|
||||||
|
*/
|
||||||
const toCSV = (headers: string[], data: CotizacionBolsa[]) => {
|
const toCSV = (headers: string[], data: CotizacionBolsa[]) => {
|
||||||
const headerRow = headers.join(';');
|
const headerRow = headers.join(';');
|
||||||
const dataRows = data.map(row =>
|
const dataRows = data.map(row =>
|
||||||
@@ -14,36 +20,57 @@ const toCSV = (headers: string[], data: CotizacionBolsa[]) => {
|
|||||||
row.nombreEmpresa,
|
row.nombreEmpresa,
|
||||||
formatCurrency(row.precioActual),
|
formatCurrency(row.precioActual),
|
||||||
formatCurrency(row.cierreAnterior),
|
formatCurrency(row.cierreAnterior),
|
||||||
`${row.porcentajeCambio.toFixed(2)}%`
|
`${row.porcentajeCambio.toFixed(2)}%`,
|
||||||
|
formatFullDateTime(row.fechaRegistro)
|
||||||
].join(';')
|
].join(';')
|
||||||
);
|
);
|
||||||
return [headerRow, ...dataRows].join('\n');
|
return [headerRow, ...dataRows].join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente de tabla de datos crudos para la Bolsa Local (MERVAL y acciones),
|
||||||
|
* diseñado para la página de redacción.
|
||||||
|
*/
|
||||||
export const RawBolsaLocalTable = () => {
|
export const RawBolsaLocalTable = () => {
|
||||||
const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local');
|
// Hooks para obtener los datos y el estado de feriado.
|
||||||
|
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local');
|
||||||
|
const isHoliday = useIsHoliday('BA');
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const headers = ["Ticker", "Nombre", "Último Precio", "Cierre Anterior", "Variación %"];
|
const headers = ["Ticker", "Nombre", "Último Precio", "Cierre Anterior", "Variación %", "Fecha de Registro"];
|
||||||
const csvData = toCSV(headers, data);
|
const csvData = toCSV(headers, data);
|
||||||
|
|
||||||
copyToClipboard(csvData)
|
copyToClipboard(csvData)
|
||||||
.then(() => {
|
.then(() => alert('¡Tabla copiada al portapapeles!'))
|
||||||
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.');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <CircularProgress />;
|
// Estado de carga unificado.
|
||||||
if (error) return <Alert severity="error">{error}</Alert>;
|
const isLoading = dataLoading || isHoliday === null;
|
||||||
if (!data) return null;
|
|
||||||
|
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. */}
|
||||||
|
{isHoliday && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<HolidayAlert />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}>
|
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}>
|
||||||
Copiar como CSV
|
Copiar como CSV
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
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 } 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';
|
||||||
|
|
||||||
// Función para convertir datos a formato CSV
|
/**
|
||||||
|
* Función para convertir los datos de la tabla a formato CSV.
|
||||||
|
*/
|
||||||
const toCSV = (headers: string[], data: CotizacionBolsa[]) => {
|
const toCSV = (headers: string[], data: CotizacionBolsa[]) => {
|
||||||
const headerRow = headers.join(';');
|
const headerRow = headers.join(';');
|
||||||
const dataRows = data.map(row =>
|
const dataRows = data.map(row =>
|
||||||
@@ -14,18 +20,25 @@ const toCSV = (headers: string[], data: CotizacionBolsa[]) => {
|
|||||||
row.nombreEmpresa,
|
row.nombreEmpresa,
|
||||||
formatCurrency(row.precioActual, 'USD'),
|
formatCurrency(row.precioActual, 'USD'),
|
||||||
formatCurrency(row.cierreAnterior, 'USD'),
|
formatCurrency(row.cierreAnterior, 'USD'),
|
||||||
`${row.porcentajeCambio.toFixed(2)}%`
|
`${row.porcentajeCambio.toFixed(2)}%`,
|
||||||
|
formatFullDateTime(row.fechaRegistro)
|
||||||
].join(';')
|
].join(';')
|
||||||
);
|
);
|
||||||
return [headerRow, ...dataRows].join('\n');
|
return [headerRow, ...dataRows].join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente de tabla de datos crudos para la Bolsa de EEUU y ADRs,
|
||||||
|
* diseñado para la página de redacción.
|
||||||
|
*/
|
||||||
export const RawBolsaUsaTable = () => {
|
export const RawBolsaUsaTable = () => {
|
||||||
const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu');
|
// 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 isHoliday = useIsHoliday('US');
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const headers = ["Ticker", "Nombre", "Último Precio (USD)", "Cierre Anterior (USD)", "Variación %"];
|
const headers = ["Ticker", "Nombre", "Último Precio (USD)", "Cierre Anterior (USD)", "Variación %", "Fecha de Registro"];
|
||||||
const csvData = toCSV(headers, data);
|
const csvData = toCSV(headers, data);
|
||||||
|
|
||||||
copyToClipboard(csvData)
|
copyToClipboard(csvData)
|
||||||
@@ -36,12 +49,28 @@ export const RawBolsaUsaTable = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <CircularProgress />;
|
// Estado de carga unificado.
|
||||||
if (error) return <Alert severity="error">{error}</Alert>;
|
const isLoading = dataLoading || isHoliday === null;
|
||||||
if (!data || data.length === 0) return <Alert severity="info">No hay datos disponibles (el fetcher puede estar desactivado).</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 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>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}>
|
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}>
|
||||||
Copiar como CSV
|
Copiar como CSV
|
||||||
</Button>
|
</Button>
|
||||||
@@ -62,8 +91,8 @@ export const RawBolsaUsaTable = () => {
|
|||||||
<TableRow key={row.id}>
|
<TableRow key={row.id}>
|
||||||
<TableCell>{row.ticker}</TableCell>
|
<TableCell>{row.ticker}</TableCell>
|
||||||
<TableCell>{row.nombreEmpresa}</TableCell>
|
<TableCell>{row.nombreEmpresa}</TableCell>
|
||||||
<TableCell align="right">${formatCurrency(row.precioActual, 'USD')}</TableCell>
|
<TableCell align="right">{formatCurrency(row.precioActual, 'USD')}</TableCell>
|
||||||
<TableCell align="right">${formatCurrency(row.cierreAnterior, 'USD')}</TableCell>
|
<TableCell align="right">{formatCurrency(row.cierreAnterior, 'USD')}</TableCell>
|
||||||
<TableCell align="right">{row.porcentajeCambio.toFixed(2)}%</TableCell>
|
<TableCell align="right">{row.porcentajeCambio.toFixed(2)}%</TableCell>
|
||||||
<TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell>
|
<TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
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 } 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 { CotizacionGrano } from '../../models/mercadoModels';
|
import type { CotizacionGrano } from '../../models/mercadoModels';
|
||||||
import { formatInteger, formatDateOnly, formatFullDateTime } from '../../utils/formatters';
|
import { formatInteger, formatDateOnly, formatFullDateTime } from '../../utils/formatters';
|
||||||
import { copyToClipboard } from '../../utils/clipboardUtils';
|
import { copyToClipboard } from '../../utils/clipboardUtils';
|
||||||
|
import { HolidayAlert } from '../common/HolidayAlert';
|
||||||
|
|
||||||
// Función para convertir datos a formato CSV
|
/**
|
||||||
|
* Función para convertir los datos de la tabla a formato CSV.
|
||||||
|
*/
|
||||||
const toCSV = (headers: string[], data: CotizacionGrano[]) => {
|
const toCSV = (headers: string[], data: CotizacionGrano[]) => {
|
||||||
const headerRow = headers.join(';');
|
const headerRow = headers.join(';');
|
||||||
const dataRows = data.map(row =>
|
const dataRows = data.map(row =>
|
||||||
@@ -13,18 +19,25 @@ const toCSV = (headers: string[], data: CotizacionGrano[]) => {
|
|||||||
row.nombre,
|
row.nombre,
|
||||||
formatInteger(row.precio),
|
formatInteger(row.precio),
|
||||||
formatInteger(row.variacionPrecio),
|
formatInteger(row.variacionPrecio),
|
||||||
formatDateOnly(row.fechaOperacion)
|
formatDateOnly(row.fechaOperacion),
|
||||||
|
formatFullDateTime(row.fechaRegistro)
|
||||||
].join(';')
|
].join(';')
|
||||||
);
|
);
|
||||||
return [headerRow, ...dataRows].join('\n');
|
return [headerRow, ...dataRows].join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente de tabla de datos crudos para el mercado de Granos,
|
||||||
|
* diseñado para la página de redacción.
|
||||||
|
*/
|
||||||
export const RawGranosTable = () => {
|
export const RawGranosTable = () => {
|
||||||
const { data, loading, error } = useApiData<CotizacionGrano[]>('/mercados/granos');
|
// 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 isHoliday = useIsHoliday('BA');
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const headers = ["Grano", "Precio ($/Tn)", "Variación", "Fecha Op."];
|
const headers = ["Grano", "Precio ($/Tn)", "Variación", "Fecha Op.", "Fecha de Registro"];
|
||||||
const csvData = toCSV(headers, data);
|
const csvData = toCSV(headers, data);
|
||||||
|
|
||||||
copyToClipboard(csvData)
|
copyToClipboard(csvData)
|
||||||
@@ -35,12 +48,28 @@ export const RawGranosTable = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <CircularProgress />;
|
// Estado de carga unificado.
|
||||||
if (error) return <Alert severity="error">{error}</Alert>;
|
const isLoading = dataLoading || isHoliday === null;
|
||||||
if (!data) return null;
|
|
||||||
|
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 de granos disponibles.</Alert>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
{/* Si es feriado, mostramos una alerta informativa encima de la tabla. */}
|
||||||
|
{isHoliday && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<HolidayAlert />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}>
|
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}>
|
||||||
Copiar como CSV
|
Copiar como CSV
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { useState, useEffect, useCallback } from 'react';
|
|||||||
import apiClient from '../api/apiClient';
|
import apiClient from '../api/apiClient';
|
||||||
import { AxiosError } from 'axios';
|
import { AxiosError } from 'axios';
|
||||||
|
|
||||||
// T es el tipo de dato que esperamos de la API (ej. CotizacionBolsa[])
|
// Definimos la URL de la API en un solo lugar y de forma explícita.
|
||||||
export function useApiData<T>(url: string) {
|
const API_ROOT = 'https://widgets.eldia.com/api';
|
||||||
|
|
||||||
|
export function useApiData<T>(endpoint: string) {
|
||||||
const [data, setData] = useState<T | null>(null);
|
const [data, setData] = useState<T | null>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -12,11 +14,13 @@ export function useApiData<T>(url: string) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<T>(url);
|
// Construimos la URL completa y absoluta para la llamada.
|
||||||
|
const fullUrl = `${API_ROOT}${endpoint}`;
|
||||||
|
const response = await apiClient.get<T>(fullUrl);
|
||||||
setData(response.data);
|
setData(response.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AxiosError) {
|
if (err instanceof AxiosError) {
|
||||||
setError(`Error al cargar datos: ${err.message}`);
|
setError(`Error de red o de la API: ${err.message}`);
|
||||||
} else {
|
} else {
|
||||||
setError('Ocurrió un error inesperado.');
|
setError('Ocurrió un error inesperado.');
|
||||||
}
|
}
|
||||||
@@ -24,7 +28,7 @@ export function useApiData<T>(url: string) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [url]);
|
}, [endpoint]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
|
|||||||
21
frontend/src/hooks/useIsHoliday.ts
Normal file
21
frontend/src/hooks/useIsHoliday.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useApiData } from './useApiData';
|
||||||
|
|
||||||
|
export function useIsHoliday(marketCode: 'BA' | 'US') {
|
||||||
|
// Reutilizamos el hook que ya sabe cómo obtener datos de nuestra API.
|
||||||
|
// Le pasamos el endpoint específico para los feriados.
|
||||||
|
const { data: isHoliday, loading, error } = useApiData<boolean>(`/mercados/es-feriado/${marketCode}`);
|
||||||
|
|
||||||
|
// Si hay un error al cargar los feriados, por seguridad asumimos que no es feriado.
|
||||||
|
if (error) {
|
||||||
|
console.error(`Error al verificar feriado para ${marketCode}, asumiendo que no lo es.`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si está cargando, devolvemos null para que el componente sepa que debe esperar.
|
||||||
|
if (loading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devolvemos el dato booleano que llegó de la API.
|
||||||
|
return isHoliday;
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ const widgetRegistry = {
|
|||||||
'mercado-agro-tarjetas': MercadoAgroCardWidget,
|
'mercado-agro-tarjetas': MercadoAgroCardWidget,
|
||||||
'mercado-agro-tabla': MercadoAgroWidget,
|
'mercado-agro-tabla': MercadoAgroWidget,
|
||||||
|
|
||||||
// Página completa como un widget
|
// Widget Página datos crudos
|
||||||
'pagina-datos-crudos': RawDataView,
|
'pagina-datos-crudos': RawDataView,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ export const formatCurrency = (num: number, currency = 'ARS') => {
|
|||||||
}).format(num);
|
}).format(num);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatCurrency2Decimal = (num: number, currency = 'ARS') => {
|
||||||
|
return new Intl.NumberFormat('es-AR', {
|
||||||
|
style: 'decimal',
|
||||||
|
currency: currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(num);
|
||||||
|
};
|
||||||
|
|
||||||
export const formatInteger = (num: number) => {
|
export const formatInteger = (num: number) => {
|
||||||
return new Intl.NumberFormat('es-AR').format(num);
|
return new Intl.NumberFormat('es-AR').format(num);
|
||||||
};
|
};
|
||||||
@@ -23,7 +32,7 @@ export const formatFullDateTime = (dateString: string) => {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
hourCycle: 'h23', // <--- LA CLAVE PARA EL FORMATO 24HS
|
hourCycle: 'h23',
|
||||||
timeZone: 'America/Argentina/Buenos_Aires',
|
timeZone: 'America/Argentina/Buenos_Aires',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,33 +1,24 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import path from 'path'; // Importa el módulo 'path' de Node
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// frontend/vite.config.ts
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
// --- V INICIO DE LA CONFIGURACIÓN DE LIBRERÍA V ---
|
|
||||||
build: {
|
build: {
|
||||||
lib: {
|
outDir: 'dist',
|
||||||
// La entrada a nuestra librería. Apunta a nuestro main.tsx
|
manifest: 'manifest.json', // Esto asegura que se llame 'manifest.json' y esté en la raíz de 'dist'
|
||||||
entry: path.resolve(__dirname, 'src/main.tsx'),
|
},
|
||||||
// El nombre de la variable global que se expondrá
|
server: {
|
||||||
name: 'MercadosWidgets',
|
proxy: {
|
||||||
// El nombre del archivo de salida
|
// Cualquier petición que empiece con /api...
|
||||||
fileName: (format) => `mercados-widgets.${format}.js`,
|
'/api': {
|
||||||
|
// ...redirígela a nuestro backend de .NET
|
||||||
|
target: 'http://localhost:5045',
|
||||||
|
// Cambia el origen de la petición para que el backend la acepte
|
||||||
|
changeOrigin: true,
|
||||||
|
// No necesitamos reescribir la ruta, ya que el backend espera /api/...
|
||||||
|
// rewrite: (path) => path.replace(/^\/api/, '')
|
||||||
},
|
},
|
||||||
// No necesitamos minificar el CSS si es simple, pero es buena práctica
|
|
||||||
cssCodeSplit: true,
|
|
||||||
// Generar un manifest para saber qué archivos se crearon
|
|
||||||
manifest: true,
|
|
||||||
rollupOptions: {
|
|
||||||
// Asegúrate de no externalizar React, para que se incluya en el bundle
|
|
||||||
external: [],
|
|
||||||
output: {
|
|
||||||
globals: {
|
|
||||||
react: 'React',
|
|
||||||
'react-dom': 'ReactDOM'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
using Mercados.Core.Entities;
|
using Mercados.Core.Entities;
|
||||||
using Mercados.Infrastructure.Persistence.Repositories;
|
using Mercados.Infrastructure.Persistence.Repositories;
|
||||||
|
using Mercados.Infrastructure.Services;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
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
|
||||||
@@ -11,22 +15,35 @@ namespace Mercados.Api.Controllers
|
|||||||
private readonly ICotizacionBolsaRepository _bolsaRepo;
|
private readonly ICotizacionBolsaRepository _bolsaRepo;
|
||||||
private readonly ICotizacionGranoRepository _granoRepo;
|
private readonly ICotizacionGranoRepository _granoRepo;
|
||||||
private readonly ICotizacionGanadoRepository _ganadoRepo;
|
private readonly ICotizacionGanadoRepository _ganadoRepo;
|
||||||
|
private readonly IHolidayService _holidayService;
|
||||||
private readonly ILogger<MercadosController> _logger;
|
private readonly ILogger<MercadosController> _logger;
|
||||||
|
|
||||||
// Inyectamos TODOS los repositorios que necesita el controlador.
|
/// <summary>
|
||||||
|
/// 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,
|
||||||
ICotizacionGanadoRepository ganadoRepo,
|
ICotizacionGanadoRepository ganadoRepo,
|
||||||
|
IHolidayService holidayService,
|
||||||
ILogger<MercadosController> logger)
|
ILogger<MercadosController> logger)
|
||||||
{
|
{
|
||||||
_bolsaRepo = bolsaRepo;
|
_bolsaRepo = bolsaRepo;
|
||||||
_granoRepo = granoRepo;
|
_granoRepo = granoRepo;
|
||||||
_ganadoRepo = ganadoRepo;
|
_ganadoRepo = ganadoRepo;
|
||||||
|
_holidayService = holidayService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Endpoint para Agroganadero ---
|
/// <summary>
|
||||||
|
/// 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)]
|
||||||
@@ -44,7 +61,10 @@ namespace Mercados.Api.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Endpoint para Granos ---
|
/// <summary>
|
||||||
|
/// 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)]
|
||||||
@@ -63,6 +83,10 @@ 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)]
|
||||||
@@ -80,6 +104,10 @@ 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)]
|
||||||
@@ -97,6 +125,13 @@ namespace Mercados.Api.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Obtiene el historial de cotizaciones para un ticker específico en un mercado determinado.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ticker">El identificador del ticker.</param>
|
||||||
|
/// <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>
|
||||||
|
/// <returns>Una colección de objetos CotizacionBolsa.</returns>
|
||||||
[HttpGet("bolsa/history/{ticker}")]
|
[HttpGet("bolsa/history/{ticker}")]
|
||||||
[ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||||
@@ -114,6 +149,13 @@ namespace Mercados.Api.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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)]
|
||||||
@@ -131,6 +173,12 @@ 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)]
|
||||||
@@ -147,5 +195,35 @@ namespace Mercados.Api.Controllers
|
|||||||
return StatusCode(500, "Ocurrió un error interno en el servidor.");
|
return StatusCode(500, "Ocurrió un error interno en el servidor.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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}")]
|
||||||
|
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<IActionResult> IsMarketHoliday(string mercado)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Usamos la fecha actual en la zona horaria de Argentina
|
||||||
|
TimeZoneInfo argentinaTimeZone;
|
||||||
|
try { argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires"); }
|
||||||
|
catch { argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time"); }
|
||||||
|
|
||||||
|
var todayInArgentina = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, argentinaTimeZone);
|
||||||
|
|
||||||
|
var esFeriado = await _holidayService.IsMarketHolidayAsync(mercado.ToUpper(), todayInArgentina);
|
||||||
|
return Ok(esFeriado);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error al comprobar si es feriado para el mercado {Mercado}.", mercado);
|
||||||
|
// Si hay un error, devolvemos 'false' para no bloquear la UI innecesariamente.
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
27
src/Mercados.Api/Dockerfile
Normal file
27
src/Mercados.Api/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# --- Etapa 1: Build ---
|
||||||
|
# Usamos la imagen del SDK de .NET 8 para compilar la aplicación
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copiamos los archivos .csproj de cada proyecto para restaurar las dependencias de forma eficiente
|
||||||
|
COPY ["src/Mercados.Api/Mercados.Api.csproj", "Mercados.Api/"]
|
||||||
|
COPY ["src/Mercados.Infrastructure/Mercados.Infrastructure.csproj", "Mercados.Infrastructure/"]
|
||||||
|
COPY ["src/Mercados.Core/Mercados.Core.csproj", "Mercados.Core/"]
|
||||||
|
COPY ["src/Mercados.Database/Mercados.Database.csproj", "Mercados.Database/"]
|
||||||
|
RUN dotnet restore "Mercados.Api/Mercados.Api.csproj"
|
||||||
|
|
||||||
|
# Copiamos el resto del código fuente
|
||||||
|
COPY src/. .
|
||||||
|
|
||||||
|
# Publicamos la aplicación en modo Release, optimizada para producción
|
||||||
|
WORKDIR "/src/Mercados.Api"
|
||||||
|
RUN dotnet publish "Mercados.Api.csproj" -c Release -o /app/publish
|
||||||
|
|
||||||
|
# --- Etapa 2: Final ---
|
||||||
|
# Usamos la imagen de runtime de ASP.NET, que es mucho más ligera que la del SDK
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
|
# Definimos el punto de entrada para ejecutar la aplicación cuando el contenedor arranque
|
||||||
|
ENTRYPOINT ["dotnet", "Mercados.Api.dll"]
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ using Mercados.Infrastructure;
|
|||||||
using Mercados.Infrastructure.Persistence;
|
using Mercados.Infrastructure.Persistence;
|
||||||
using Mercados.Infrastructure.Persistence.Repositories;
|
using Mercados.Infrastructure.Persistence.Repositories;
|
||||||
using Mercados.Api.Utils;
|
using Mercados.Api.Utils;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
using Mercados.Infrastructure.Services;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -18,7 +20,8 @@ builder.Services.AddCors(options =>
|
|||||||
{
|
{
|
||||||
policy.WithOrigins("http://localhost:5173",
|
policy.WithOrigins("http://localhost:5173",
|
||||||
"http://192.168.10.78:5173",
|
"http://192.168.10.78:5173",
|
||||||
"https://www.eldia.com")
|
"https://www.eldia.com",
|
||||||
|
"https://extras.eldia.com")
|
||||||
.AllowAnyHeader()
|
.AllowAnyHeader()
|
||||||
.AllowAnyMethod();
|
.AllowAnyMethod();
|
||||||
});
|
});
|
||||||
@@ -30,6 +33,10 @@ builder.Services.AddScoped<ICotizacionGanadoRepository, CotizacionGanadoReposito
|
|||||||
builder.Services.AddScoped<ICotizacionGranoRepository, CotizacionGranoRepository>();
|
builder.Services.AddScoped<ICotizacionGranoRepository, CotizacionGranoRepository>();
|
||||||
builder.Services.AddScoped<ICotizacionBolsaRepository, CotizacionBolsaRepository>();
|
builder.Services.AddScoped<ICotizacionBolsaRepository, CotizacionBolsaRepository>();
|
||||||
builder.Services.AddScoped<IFuenteDatoRepository, FuenteDatoRepository>();
|
builder.Services.AddScoped<IFuenteDatoRepository, FuenteDatoRepository>();
|
||||||
|
builder.Services.AddScoped<IMercadoFeriadoRepository, MercadoFeriadoRepository>();
|
||||||
|
builder.Services.AddMemoryCache();
|
||||||
|
builder.Services.AddScoped<IMercadoFeriadoRepository, MercadoFeriadoRepository>();
|
||||||
|
builder.Services.AddScoped<IHolidayService, FinnhubHolidayService>();
|
||||||
|
|
||||||
// Configuración de FluentMigrator (perfecto)
|
// Configuración de FluentMigrator (perfecto)
|
||||||
builder.Services
|
builder.Services
|
||||||
@@ -48,12 +55,23 @@ builder.Services.AddControllers()
|
|||||||
options.JsonSerializerOptions.Converters.Add(new UtcDateTimeConverter());
|
options.JsonSerializerOptions.Converters.Add(new UtcDateTimeConverter());
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
|
{
|
||||||
|
options.ForwardedHeaders =
|
||||||
|
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||||
|
// En un entorno de producción real, deberías limitar esto a las IPs de tus proxies.
|
||||||
|
// options.KnownProxies.Add(IPAddress.Parse("192.168.5.X")); // IP de tu NPM
|
||||||
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Le decimos a la aplicación que USE el middleware de cabeceras de reenvío.
|
||||||
|
// ¡El orden importa! Debe ir antes de UseHttpsRedirection y UseCors.
|
||||||
|
app.UseForwardedHeaders();
|
||||||
|
|
||||||
// Ejecución de migraciones (perfecto)
|
// Ejecución de migraciones (perfecto)
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,12 +9,25 @@ 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>
|
||||||
|
/// Escribe un valor DateTime en formato UTC como una cadena en el escritor JSON.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="writer">El escritor JSON.</param>
|
||||||
|
/// <param name="value">El valor DateTime a escribir.</param>
|
||||||
|
/// <param name="options">Las opciones de serialización JSON.</param>
|
||||||
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
|
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
|
||||||
{
|
{
|
||||||
// Antes de escribir el string, especificamos que el 'Kind' es Utc.
|
// Antes de escribir el string, especificamos que el 'Kind' es Utc.
|
||||||
|
|||||||
@@ -1,15 +1,53 @@
|
|||||||
namespace Mercados.Core.Entities
|
namespace Mercados.Core.Entities
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Representa una única captura de cotización para un activo de la bolsa de valores.
|
||||||
|
/// </summary>
|
||||||
public class CotizacionBolsa
|
public class CotizacionBolsa
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Identificador único del registro en la base de datos.
|
||||||
|
/// </summary>
|
||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
public string Ticker { get; set; } = string.Empty; // "AAPL", "GGAL.BA", etc.
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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; }
|
public string? NombreEmpresa { get; set; }
|
||||||
public string Mercado { get; set; } = string.Empty; // "EEUU" o "Local"
|
|
||||||
|
/// <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; }
|
public decimal PrecioActual { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// El precio del activo al inicio de la jornada de mercado.
|
||||||
|
/// </summary>
|
||||||
public decimal Apertura { get; set; }
|
public decimal Apertura { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// El precio de cierre del activo en la jornada anterior.
|
||||||
|
/// </summary>
|
||||||
public decimal CierreAnterior { get; set; }
|
public decimal CierreAnterior { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// El cambio porcentual del precio actual con respecto al cierre anterior.
|
||||||
|
/// </summary>
|
||||||
public decimal PorcentajeCambio { get; set; }
|
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; }
|
public DateTime FechaRegistro { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,68 @@
|
|||||||
namespace Mercados.Core.Entities
|
namespace Mercados.Core.Entities
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Representa una cotización para una categoría de ganado en el Mercado Agroganadero.
|
||||||
|
/// </summary>
|
||||||
public class CotizacionGanado
|
public class CotizacionGanado
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Identificador único del registro en la base de datos.
|
||||||
|
/// </summary>
|
||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// La categoría principal del ganado (ej. "NOVILLOS", "VACAS").
|
||||||
|
/// </summary>
|
||||||
public string Categoria { get; set; } = string.Empty;
|
public string Categoria { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detalles adicionales sobre la categoría, como raza o peso.
|
||||||
|
/// </summary>
|
||||||
public string Especificaciones { get; set; } = string.Empty;
|
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; }
|
public decimal Maximo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// El precio mínimo alcanzado para esta categoría en la jornada.
|
||||||
|
/// </summary>
|
||||||
public decimal Minimo { get; set; }
|
public decimal Minimo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// El precio promedio ponderado para la categoría.
|
||||||
|
/// </summary>
|
||||||
public decimal Promedio { get; set; }
|
public decimal Promedio { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// El precio mediano (valor central) registrado para la categoría.
|
||||||
|
/// </summary>
|
||||||
public decimal Mediano { get; set; }
|
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; }
|
public int Cabezas { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// El peso total en kilogramos de todo el ganado comercializado.
|
||||||
|
/// </summary>
|
||||||
public int KilosTotales { get; set; }
|
public int KilosTotales { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// El peso promedio por cabeza de ganado.
|
||||||
|
/// </summary>
|
||||||
public int KilosPorCabeza { get; set; }
|
public int KilosPorCabeza { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// El importe total monetario de las transacciones para esta categoría.
|
||||||
|
/// </summary>
|
||||||
public decimal ImporteTotal { get; set; }
|
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; }
|
public DateTime FechaRegistro { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,38 @@
|
|||||||
namespace Mercados.Core.Entities
|
namespace Mercados.Core.Entities
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Representa una cotización para un tipo de grano específico.
|
||||||
|
/// </summary>
|
||||||
public class CotizacionGrano
|
public class CotizacionGrano
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Identificador único del registro en la base de datos.
|
||||||
|
/// </summary>
|
||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
public string Nombre { get; set; } = string.Empty; // "Soja", "Trigo", etc.
|
|
||||||
|
/// <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; }
|
public decimal Precio { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// La variación del precio con respecto a la cotización anterior.
|
||||||
|
/// </summary>
|
||||||
public decimal VariacionPrecio { get; set; }
|
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; }
|
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; }
|
public DateTime FechaRegistro { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,31 @@
|
|||||||
namespace Mercados.Core.Entities
|
namespace Mercados.Core.Entities
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public class FuenteDato
|
public class FuenteDato
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Identificador único del registro en la base de datos.
|
||||||
|
/// </summary>
|
||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
public string Nombre { get; set; } = string.Empty; // "BCR", "Finnhub", "YahooFinance", "MercadoAgroganadero"
|
|
||||||
|
/// <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; }
|
public DateTime UltimaEjecucionExitosa { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// La URL base o principal de la fuente de datos, para referencia.
|
||||||
|
/// </summary>
|
||||||
public string? Url { get; set; }
|
public string? Url { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
28
src/Mercados.Core/Entities/MercadoFeriado.cs
Normal file
28
src/Mercados.Core/Entities/MercadoFeriado.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
namespace Mercados.Core.Entities
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Representa un único día feriado para un mercado bursátil específico.
|
||||||
|
/// </summary>
|
||||||
|
public class MercadoFeriado
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Identificador único del registro en la base de datos.
|
||||||
|
/// </summary>
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
/// <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; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// El nombre o la descripción del feriado (si está disponible).
|
||||||
|
/// </summary>
|
||||||
|
public string? Nombre { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
using FluentMigrator;
|
|
||||||
|
|
||||||
namespace Mercados.Database.Migrations
|
|
||||||
{
|
|
||||||
[Migration(20240702133000)]
|
|
||||||
public class AddNameToStocks : Migration
|
|
||||||
{
|
|
||||||
public override void Up()
|
|
||||||
{
|
|
||||||
Alter.Table("CotizacionesBolsa")
|
|
||||||
.AddColumn("NombreEmpresa").AsString(255).Nullable();
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Down()
|
|
||||||
{
|
|
||||||
Delete.Column("NombreEmpresa").FromTable("CotizacionesBolsa");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,10 @@ using FluentMigrator;
|
|||||||
|
|
||||||
namespace Mercados.Database.Migrations
|
namespace Mercados.Database.Migrations
|
||||||
{
|
{
|
||||||
// El número es la versión única de esta migración.
|
/// <summary>
|
||||||
// Usar un timestamp es una práctica común y segura.
|
/// Migración inicial que crea las tablas necesarias para almacenar
|
||||||
|
/// las cotizaciones de ganado, granos, bolsa y fuentes de datos.
|
||||||
|
/// </summary>
|
||||||
[Migration(20250701113000)]
|
[Migration(20250701113000)]
|
||||||
public class CreateInitialTables : Migration
|
public class CreateInitialTables : Migration
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using FluentMigrator;
|
||||||
|
|
||||||
|
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)]
|
||||||
|
public class AddNameToStocks : Migration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Aplica la migración, añadiendo la columna 'NombreEmpresa'.
|
||||||
|
/// </summary>
|
||||||
|
public override void Up()
|
||||||
|
{
|
||||||
|
Alter.Table("CotizacionesBolsa")
|
||||||
|
.AddColumn("NombreEmpresa").AsString(255).Nullable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Revierte la migración, eliminando la columna 'NombreEmpresa'.
|
||||||
|
/// </summary>
|
||||||
|
public override void Down()
|
||||||
|
{
|
||||||
|
Delete.Column("NombreEmpresa").FromTable("CotizacionesBolsa");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using FluentMigrator;
|
||||||
|
|
||||||
|
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)]
|
||||||
|
public class CreateMercadoFeriadoTable : Migration
|
||||||
|
{
|
||||||
|
private const string TableName = "MercadosFeriados";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Define la estructura de la tabla 'MercadosFeriados' y crea un índice único.
|
||||||
|
/// </summary>
|
||||||
|
public override void Up()
|
||||||
|
{
|
||||||
|
Create.Table(TableName)
|
||||||
|
.WithColumn("Id").AsInt64().PrimaryKey().Identity()
|
||||||
|
.WithColumn("CodigoMercado").AsString(10).NotNullable()
|
||||||
|
.WithColumn("Fecha").AsDate().NotNullable() // Usamos AsDate() para guardar solo la fecha
|
||||||
|
.WithColumn("Nombre").AsString(255).Nullable();
|
||||||
|
|
||||||
|
// Creamos un índice para buscar rápidamente por mercado y fecha
|
||||||
|
Create.Index($"IX_{TableName}_CodigoMercado_Fecha")
|
||||||
|
.OnTable(TableName)
|
||||||
|
.OnColumn("CodigoMercado").Ascending()
|
||||||
|
.OnColumn("Fecha").Ascending()
|
||||||
|
.WithOptions().Unique();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Revierte la migración eliminando la tabla 'MercadosFeriados'.
|
||||||
|
/// </summary>
|
||||||
|
public override void Down()
|
||||||
|
{
|
||||||
|
Delete.Table(TableName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,37 +8,83 @@ 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 }
|
||||||
@@ -50,6 +96,14 @@ 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,
|
||||||
@@ -64,6 +118,7 @@ namespace Mercados.Infrastructure.DataFetchers
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
@@ -124,6 +179,11 @@ 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");
|
||||||
@@ -137,6 +197,9 @@ 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);
|
||||||
|
|||||||
@@ -7,8 +7,18 @@ 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
|
||||||
@@ -16,7 +26,7 @@ namespace Mercados.Infrastructure.DataFetchers
|
|||||||
// Empresas 'Latinas' en Wall Street
|
// Empresas 'Latinas' en Wall Street
|
||||||
"MELI", "GLOB",
|
"MELI", "GLOB",
|
||||||
// ADRs Argentinos
|
// ADRs Argentinos
|
||||||
"YPF", "GGAL", "BMA", "LOMA", "PAM", "TEO", "TGS", "EDN", "CRESY", "CEPU", "BBAR"
|
"YPF", "GGAL", "BMA", "LOMA", "TEO", "TGS", "EDN", "BBAR"
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly FinnhubClient _client;
|
private readonly FinnhubClient _client;
|
||||||
@@ -24,6 +34,17 @@ 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,
|
||||||
@@ -42,6 +63,12 @@ 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);
|
||||||
@@ -88,6 +115,9 @@ 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);
|
||||||
|
|||||||
138
src/Mercados.Infrastructure/DataFetchers/HolidayDataFetcher.cs
Normal file
138
src/Mercados.Infrastructure/DataFetchers/HolidayDataFetcher.cs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Mercados.Core.Entities;
|
||||||
|
using Mercados.Infrastructure.Persistence.Repositories;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Mercados.Infrastructure.DataFetchers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// DTO para deserializar la respuesta de la API de Finnhub al obtener feriados de mercado.
|
||||||
|
/// </summary>
|
||||||
|
public class MarketHolidayResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Lista de feriados del mercado.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("data")]
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Fecha del feriado en formato de cadena (YYYY-MM-DD).
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("at")]
|
||||||
|
public string? At { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fecha del feriado como <see cref="DateOnly"/>.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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 IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly IMercadoFeriadoRepository _feriadoRepository;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
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(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IMercadoFeriadoRepository feriadoRepository,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<HolidayDataFetcher> logger)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_feriadoRepository = feriadoRepository;
|
||||||
|
_configuration = configuration;
|
||||||
|
_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()
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Iniciando actualización de feriados.");
|
||||||
|
|
||||||
|
// Verificamos que la API Key de Finnhub esté configurada
|
||||||
|
var apiKey = _configuration["ApiKeys:Finnhub"];
|
||||||
|
if (string.IsNullOrEmpty(apiKey))
|
||||||
|
{
|
||||||
|
return (false, "API Key de Finnhub no configurada.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = _httpClientFactory.CreateClient("FinnhubDataFetcher");
|
||||||
|
// Iteramos sobre cada código de mercado configurado
|
||||||
|
foreach (var marketCode in _marketCodes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var apiUrl = $"https://finnhub.io/api/v1/stock/market-holiday?exchange={marketCode}&token={apiKey}";
|
||||||
|
// Ahora la deserialización funcionará porque la clase existe
|
||||||
|
var response = await client.GetFromJsonAsync<MarketHolidayResponse>(apiUrl);
|
||||||
|
|
||||||
|
// Si obtuvimos datos en la respuesta
|
||||||
|
if (response?.Data != null)
|
||||||
|
{
|
||||||
|
// Convertimos los datos de la API al formato de nuestra entidad MercadoFeriado
|
||||||
|
var nuevosFeriados = response.Data.Select(h => new MercadoFeriado
|
||||||
|
{
|
||||||
|
CodigoMercado = marketCode,
|
||||||
|
Fecha = h.Date.ToDateTime(TimeOnly.MinValue),
|
||||||
|
Nombre = "Feriado Bursátil"
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
// Guardamos los feriados en la base de datos, reemplazando los existentes para ese mercado
|
||||||
|
await _feriadoRepository.ReemplazarFeriadosPorMercadoAsync(marketCode, nuevosFeriados);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Feriados para {MarketCode} actualizados exitosamente: {Count} registros.",
|
||||||
|
marketCode,
|
||||||
|
nuevosFeriados.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Falló la obtención de feriados para {MarketCode}.", marketCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Retornamos éxito si el proceso completo se ejecutó sin errores irrecuperables
|
||||||
|
return (true, "Actualización de feriados completada.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,15 +6,39 @@ 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;
|
||||||
|
|
||||||
|
/// <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,
|
||||||
@@ -27,6 +51,12 @@ namespace Mercados.Infrastructure.DataFetchers
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
@@ -36,15 +66,12 @@ namespace Mercados.Infrastructure.DataFetchers
|
|||||||
var htmlContent = await GetHtmlContentAsync();
|
var htmlContent = await GetHtmlContentAsync();
|
||||||
if (string.IsNullOrEmpty(htmlContent))
|
if (string.IsNullOrEmpty(htmlContent))
|
||||||
{
|
{
|
||||||
// Esto sigue siendo un fallo, no se pudo obtener la página
|
|
||||||
return (false, "No se pudo obtener el contenido HTML.");
|
return (false, "No se pudo obtener el contenido HTML.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var cotizaciones = ParseHtmlToEntities(htmlContent);
|
var cotizaciones = ParseHtmlToEntities(htmlContent);
|
||||||
|
|
||||||
if (!cotizaciones.Any())
|
if (!cotizaciones.Any())
|
||||||
{
|
{
|
||||||
// La conexión fue exitosa, pero no se encontraron datos válidos.
|
|
||||||
// Esto NO es un error crítico, es un estado informativo.
|
// Esto NO es un error crítico, es un estado informativo.
|
||||||
_logger.LogInformation("La conexión con {SourceName} fue exitosa, pero no se encontraron datos de cotizaciones para procesar.", 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.");
|
return (true, "Conexión exitosa, pero no se encontraron nuevos datos.");
|
||||||
@@ -64,21 +91,34 @@ namespace Mercados.Infrastructure.DataFetchers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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;
|
||||||
@@ -122,13 +162,16 @@ namespace Mercados.Infrastructure.DataFetchers
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "No se pudo parsear una fila de la tabla. Contenido: {RowContent}", string.Join(" | ", celdas));
|
_logger.LogWarning(ex, "No se pudo parsear una fila de la tabla. Contenido: {RowContent}",
|
||||||
|
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);
|
||||||
@@ -150,7 +193,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.
|
||||||
@@ -158,6 +211,12 @@ namespace Mercados.Infrastructure.DataFetchers
|
|||||||
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);
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
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" }, // Cambiado de GSPC a SPY para Finnhub
|
{ "SPY", "S&P 500 ETF" },
|
||||||
{ "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" },
|
||||||
@@ -19,6 +25,7 @@ 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
|
||||||
@@ -53,9 +60,15 @@ 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)
|
||||||
{
|
{
|
||||||
return Names.GetValueOrDefault(ticker);
|
// Devuelve el nombre si existe, o null si no se encuentra la clave.
|
||||||
|
return Names.TryGetValue(ticker, out var name) ? name : $"Ticker no reconocido: {ticker}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,9 +5,24 @@ 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&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",
|
||||||
@@ -15,10 +30,21 @@ 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>
|
||||||
|
/// Inicializa una nueva instancia de la clase <see cref="YahooFinanceDataFetcher"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <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 (Yahoo Finance).</param>
|
||||||
|
/// <param name="logger">Logger para registrar información y errores durante la ejecución.</param>
|
||||||
public YahooFinanceDataFetcher(
|
public YahooFinanceDataFetcher(
|
||||||
ICotizacionBolsaRepository cotizacionRepository,
|
ICotizacionBolsaRepository cotizacionRepository,
|
||||||
IFuenteDatoRepository fuenteDatoRepository,
|
IFuenteDatoRepository fuenteDatoRepository,
|
||||||
@@ -29,6 +55,11 @@ namespace Mercados.Infrastructure.DataFetchers
|
|||||||
_logger = logger;
|
_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);
|
||||||
@@ -41,7 +72,7 @@ namespace Mercados.Infrastructure.DataFetchers
|
|||||||
{
|
{
|
||||||
if (sec.RegularMarketPrice == 0 || sec.RegularMarketPreviousClose == 0) continue;
|
if (sec.RegularMarketPrice == 0 || sec.RegularMarketPreviousClose == 0) continue;
|
||||||
|
|
||||||
string mercado = sec.Symbol.EndsWith(".BA") || sec.Symbol == "^MERV" ? "Local" : "EEUU";
|
string mercado = DetermineMarket(sec.Symbol);
|
||||||
|
|
||||||
cotizaciones.Add(new CotizacionBolsa
|
cotizaciones.Add(new CotizacionBolsa
|
||||||
{
|
{
|
||||||
@@ -75,6 +106,27 @@ 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);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||||
<PackageReference Include="MailKit" Version="4.13.0" />
|
<PackageReference Include="MailKit" Version="4.13.0" />
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.7" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
|
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
|
||||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="9.0.6" />
|
<PackageReference Include="System.Text.Encoding.CodePages" Version="9.0.6" />
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -2,8 +2,15 @@ using System.Data;
|
|||||||
|
|
||||||
namespace Mercados.Infrastructure.Persistence
|
namespace Mercados.Infrastructure.Persistence
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Define una interfaz para una fábrica de conexiones a la base de datos.
|
||||||
|
/// </summary>
|
||||||
public interface IDbConnectionFactory
|
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();
|
IDbConnection CreateConnection();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,26 +4,34 @@ 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 = @"
|
const string sql = @"INSERT INTO
|
||||||
INSERT INTO CotizacionesBolsa (Ticker, NombreEmpresa, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro)
|
CotizacionesBolsa (Ticker, NombreEmpresa, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro)
|
||||||
VALUES (@Ticker, @NombreEmpresa, @Mercado, @PrecioActual, @Apertura, @CierreAnterior, @PorcentajeCambio, @FechaRegistro);";
|
VALUES
|
||||||
|
(@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();
|
||||||
@@ -48,6 +56,7 @@ 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();
|
||||||
|
|||||||
@@ -4,22 +4,29 @@ 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 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 CotizacionesGanado (
|
INSERT INTO
|
||||||
|
CotizacionesGanado (
|
||||||
Categoria, Especificaciones, Maximo, Minimo, Promedio, Mediano,
|
Categoria, Especificaciones, Maximo, Minimo, Promedio, Mediano,
|
||||||
Cabezas, KilosTotales, KilosPorCabeza, ImporteTotal, FechaRegistro
|
Cabezas, KilosTotales, KilosPorCabeza, ImporteTotal, FechaRegistro
|
||||||
)
|
)
|
||||||
@@ -30,6 +37,8 @@ namespace Mercados.Infrastructure.Persistence.Repositories
|
|||||||
|
|
||||||
await connection.ExecuteAsync(sql, cotizaciones);
|
await connection.ExecuteAsync(sql, cotizaciones);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public async Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaAsync()
|
public async Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaAsync()
|
||||||
{
|
{
|
||||||
using IDbConnection connection = _connectionFactory.CreateConnection();
|
using IDbConnection connection = _connectionFactory.CreateConnection();
|
||||||
@@ -44,6 +53,7 @@ namespace Mercados.Infrastructure.Persistence.Repositories
|
|||||||
return await connection.QueryAsync<CotizacionGanado>(sql);
|
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();
|
||||||
|
|||||||
@@ -4,25 +4,34 @@ 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 = @"
|
const string sql = @"INSERT INTO
|
||||||
INSERT INTO CotizacionesGranos (Nombre, Precio, VariacionPrecio, FechaOperacion, FechaRegistro)
|
CotizacionesGranos (Nombre, Precio, VariacionPrecio, FechaOperacion, FechaRegistro)
|
||||||
VALUES (@Nombre, @Precio, @VariacionPrecio, @FechaOperacion, @FechaRegistro);";
|
VALUES
|
||||||
|
(@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();
|
||||||
@@ -45,6 +54,7 @@ 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();
|
||||||
|
|||||||
@@ -4,36 +4,41 @@ 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 = @"
|
const string sql = @"INSERT INTO FuentesDatos (Nombre, UltimaEjecucionExitosa, Url)
|
||||||
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 = @"
|
const string sql = @"UPDATE FuentesDatos
|
||||||
UPDATE FuentesDatos
|
|
||||||
SET UltimaEjecucionExitosa = @UltimaEjecucionExitosa, Url = @Url
|
SET UltimaEjecucionExitosa = @UltimaEjecucionExitosa, Url = @Url
|
||||||
WHERE Id = @Id;";
|
WHERE Id = @Id;";
|
||||||
await connection.ExecuteAsync(sql, fuenteDato);
|
await connection.ExecuteAsync(sql, fuenteDato);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
namespace Mercados.Infrastructure.Persistence.Repositories
|
namespace Mercados.Infrastructure.Persistence.Repositories
|
||||||
{
|
{
|
||||||
// Esta interfaz no es estrictamente necesaria ahora, pero es útil para futuras abstracciones.
|
/// <summary>
|
||||||
|
/// 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
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,31 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,30 @@ 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>
|
||||||
|
/// Guarda una colección de cotizaciones de ganado en la base de datos.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cotizaciones">La colección de entidades CotizacionGanado a guardar.</param>
|
||||||
Task GuardarMuchosAsync(IEnumerable<CotizacionGanado> cotizaciones);
|
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();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,29 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,28 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Mercados.Core.Entities;
|
||||||
|
|
||||||
|
namespace Mercados.Infrastructure.Persistence.Repositories
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Define el contrato para el repositorio que gestiona los feriados de los mercados.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Mercados.Core.Entities;
|
||||||
|
using System.Data;
|
||||||
|
|
||||||
|
namespace Mercados.Infrastructure.Persistence.Repositories
|
||||||
|
{
|
||||||
|
/// <inheritdoc cref="IMercadoFeriadoRepository"/>
|
||||||
|
public class MercadoFeriadoRepository : IMercadoFeriadoRepository
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
_connectionFactory = connectionFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IEnumerable<MercadoFeriado>> ObtenerPorMercadoYAnioAsync(string codigoMercado, int anio)
|
||||||
|
{
|
||||||
|
using IDbConnection connection = _connectionFactory.CreateConnection();
|
||||||
|
const string sql = @"SELECT *
|
||||||
|
FROM MercadosFeriados
|
||||||
|
WHERE CodigoMercado = @CodigoMercado AND YEAR(Fecha) = @Anio;";
|
||||||
|
return await connection.QueryAsync<MercadoFeriado>(sql, new
|
||||||
|
{
|
||||||
|
CodigoMercado = codigoMercado,
|
||||||
|
Anio = anio
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ReemplazarFeriadosPorMercadoAsync(string codigoMercado, IEnumerable<MercadoFeriado> nuevosFeriados)
|
||||||
|
{
|
||||||
|
using IDbConnection connection = _connectionFactory.CreateConnection();
|
||||||
|
connection.Open();
|
||||||
|
using var transaction = connection.BeginTransaction();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Obtenemos el año del primer feriado (asumimos que todos son del mismo año)
|
||||||
|
var anio = nuevosFeriados.FirstOrDefault()?.Fecha.Year;
|
||||||
|
if (!anio.HasValue) return; // Si no hay feriados, no hay nada que hacer
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
VALUES (@CodigoMercado, @Fecha, @Nombre);";
|
||||||
|
await connection.ExecuteAsync(insertSql, nuevosFeriados, transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si todo sale bien, confirmar la transacción
|
||||||
|
transaction.Commit();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Si hay algún error, deshacer la transacción para no dejar datos inconsistentes
|
||||||
|
transaction.Rollback();
|
||||||
|
|
||||||
|
// Relanzar la excepción para que el llamador sepa que algo falló
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,17 @@ 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,
|
||||||
@@ -17,6 +24,7 @@ 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);
|
||||||
|
|||||||
@@ -6,17 +6,26 @@ 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)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Mercados.Core.Entities;
|
||||||
|
using Mercados.Infrastructure.Persistence.Repositories;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Mercados.Infrastructure.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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 IMemoryCache _cache;
|
||||||
|
private readonly ILogger<FinnhubHolidayService> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inicializa una nueva instancia de la clase <see cref="FinnhubHolidayService"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="feriadoRepository">Repositorio para acceder a los feriados de mercado.</param>
|
||||||
|
/// <param name="cache">Caché en memoria para almacenar los feriados.</param>
|
||||||
|
/// <param name="logger">Logger para registrar información y errores.</param>
|
||||||
|
public FinnhubHolidayService(
|
||||||
|
IMercadoFeriadoRepository feriadoRepository,
|
||||||
|
IMemoryCache cache,
|
||||||
|
ILogger<FinnhubHolidayService> logger)
|
||||||
|
{
|
||||||
|
_feriadoRepository = feriadoRepository;
|
||||||
|
_cache = cache;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determina si una fecha específica es feriado de mercado para el código de mercado proporcionado.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="marketCode">Código del mercado a consultar.</param>
|
||||||
|
/// <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))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Caché de feriados no encontrada para {MarketCode}. Obteniendo desde la base de datos.", marketCode);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Llama a NUESTRA base de datos, no a la API externa.
|
||||||
|
var feriadosDesdeDb = await _feriadoRepository.ObtenerPorMercadoYAnioAsync(marketCode, date.Year);
|
||||||
|
holidays = feriadosDesdeDb.Select(h => DateOnly.FromDateTime(h.Fecha)).ToHashSet();
|
||||||
|
_cache.Set(cacheKey, holidays, TimeSpan.FromHours(24));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "No se pudo obtener la lista de feriados para {MarketCode} desde la DB.", marketCode);
|
||||||
|
return false; // Asumimos que no es feriado si la DB falla
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return holidays?.Contains(dateOnly) ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/Mercados.Infrastructure/Services/IHolidayService.cs
Normal file
16
src/Mercados.Infrastructure/Services/IHolidayService.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Mercados.Infrastructure.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Define un servicio para consultar si una fecha es feriado para un mercado.
|
||||||
|
/// </summary>
|
||||||
|
public interface IHolidayService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Comprueba si la fecha dada es un feriado bursátil para el mercado especificado.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="marketCode">El código del mercado (ej. "BA" para Buenos Aires, "US" para EEUU).</param>
|
||||||
|
/// <param name="date">La fecha a comprobar.</param>
|
||||||
|
/// <returns>True si es feriado, false si no lo es.</returns>
|
||||||
|
Task<bool> IsMarketHolidayAsync(string marketCode, DateTime date);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,21 +15,47 @@ namespace Mercados.Worker
|
|||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly TimeZoneInfo _argentinaTimeZone;
|
private readonly TimeZoneInfo _argentinaTimeZone;
|
||||||
|
|
||||||
// Almacenamos las expresiones Cron parseadas para no tener que hacerlo en cada ciclo.
|
/// <summary>
|
||||||
|
/// 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;
|
||||||
|
|
||||||
// Almacenamos la próxima ejecución calculada para cada tarea.
|
/// <summary>Próxima hora de ejecución programada para la tarea de Mercado Agroganadero.</summary>
|
||||||
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;
|
||||||
|
|
||||||
// Diccionario para rastrear la hora de la última alerta ENVIADA por cada tarea.
|
/// <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();
|
||||||
// Definimos el período de "silencio" para las alertas (ej. 4 horas).
|
/// <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>
|
||||||
|
/// 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,
|
||||||
@@ -55,53 +81,98 @@ namespace Mercados.Worker
|
|||||||
_agroSchedule = CronExpression.Parse(configuration["Schedules:MercadoAgroganadero"]!);
|
_agroSchedule = CronExpression.Parse(configuration["Schedules:MercadoAgroganadero"]!);
|
||||||
_bcrSchedule = CronExpression.Parse(configuration["Schedules:BCR"]!);
|
_bcrSchedule = CronExpression.Parse(configuration["Schedules:BCR"]!);
|
||||||
_bolsasSchedule = CronExpression.Parse(configuration["Schedules:Bolsas"]!);
|
_bolsasSchedule = CronExpression.Parse(configuration["Schedules:Bolsas"]!);
|
||||||
|
_holidaysSchedule = CronExpression.Parse(configuration["Schedules:Holidays"]!);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Método principal del servicio. Se ejecuta una vez cuando el servicio arranca.
|
/// Método principal del servicio que se ejecuta en segundo plano. Contiene el bucle
|
||||||
|
/// 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);
|
||||||
|
|
||||||
// Ejecutamos una vez al inicio para tener datos frescos inmediatamente.
|
// La ejecución inicial sigue comentada
|
||||||
await RunAllFetchersAsync(stoppingToken);
|
// await RunAllFetchersAsync(stoppingToken);
|
||||||
|
|
||||||
// Calculamos las primeras ejecuciones programadas al arrancar.
|
// Calculamos las primeras ejecuciones programadas al arrancar.
|
||||||
var utcNow = DateTime.UtcNow;
|
var utcNow = DateTime.UtcNow;
|
||||||
_nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
_nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
||||||
_nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
_nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
||||||
_nextBolsasRun = _bolsasSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
_nextBolsasRun = _bolsasSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
||||||
|
_nextHolidaysRun = _holidaysSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
||||||
|
|
||||||
// Usamos un PeriodicTimer que "despierta" cada 30 segundos para revisar si hay tareas pendientes.
|
// Usamos un PeriodicTimer que "despierta" cada 30 segundos.
|
||||||
// Un intervalo más corto aumenta la precisión del disparo de las tareas.
|
|
||||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
|
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
|
||||||
{
|
{
|
||||||
utcNow = DateTime.UtcNow;
|
utcNow = DateTime.UtcNow;
|
||||||
|
var nowInArgentina = TimeZoneInfo.ConvertTimeFromUtc(utcNow, _argentinaTimeZone);
|
||||||
|
|
||||||
// Comprobamos si ha llegado el momento de la próxima ejecución para cada tarea.
|
// Tarea de actualización de Feriados (semanal)
|
||||||
|
if (_nextHolidaysRun.HasValue && utcNow >= _nextHolidaysRun.Value)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Ejecutando tarea semanal de actualización de feriados.");
|
||||||
|
await RunFetcherByNameAsync("Holidays", stoppingToken);
|
||||||
|
_nextHolidaysRun = _holidaysSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tarea de Mercado Agroganadero (diaria)
|
||||||
if (_nextAgroRun.HasValue && utcNow >= _nextAgroRun.Value)
|
if (_nextAgroRun.HasValue && utcNow >= _nextAgroRun.Value)
|
||||||
|
{
|
||||||
|
// Comprueba si NO es feriado en Argentina para ejecutar
|
||||||
|
if (!await IsMarketHolidayAsync("BA", nowInArgentina))
|
||||||
{
|
{
|
||||||
await RunFetcherByNameAsync("MercadoAgroganadero", stoppingToken);
|
await RunFetcherByNameAsync("MercadoAgroganadero", stoppingToken);
|
||||||
// Inmediatamente después de ejecutar, calculamos la SIGUIENTE ocurrencia.
|
}
|
||||||
|
else { _logger.LogInformation("Ejecución de MercadoAgroganadero omitida por ser feriado."); }
|
||||||
|
|
||||||
|
// Recalcula la próxima ejecución sin importar si corrió o fue feriado
|
||||||
_nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
_nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tarea de Granos BCR (diaria)
|
||||||
if (_nextBcrRun.HasValue && utcNow >= _nextBcrRun.Value)
|
if (_nextBcrRun.HasValue && utcNow >= _nextBcrRun.Value)
|
||||||
|
{
|
||||||
|
if (!await IsMarketHolidayAsync("BA", nowInArgentina))
|
||||||
{
|
{
|
||||||
await RunFetcherByNameAsync("BCR", stoppingToken);
|
await RunFetcherByNameAsync("BCR", stoppingToken);
|
||||||
|
}
|
||||||
|
else { _logger.LogInformation("Ejecución de BCR omitida por ser feriado."); }
|
||||||
|
|
||||||
_nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
_nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tarea de Bolsas (recurrente)
|
||||||
if (_nextBolsasRun.HasValue && utcNow >= _nextBolsasRun.Value)
|
if (_nextBolsasRun.HasValue && utcNow >= _nextBolsasRun.Value)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Ventana de ejecución para Bolsas. Iniciando en paralelo...");
|
_logger.LogInformation("Ventana de ejecución para Bolsas detectada.");
|
||||||
await Task.WhenAll(
|
|
||||||
RunFetcherByNameAsync("YahooFinance", stoppingToken),
|
var bolsaTasks = new List<Task>();
|
||||||
RunFetcherByNameAsync("Finnhub", stoppingToken)
|
|
||||||
);
|
// Comprueba el mercado local (Argentina)
|
||||||
|
if (!await IsMarketHolidayAsync("BA", nowInArgentina))
|
||||||
|
{
|
||||||
|
bolsaTasks.Add(RunFetcherByNameAsync("YahooFinance", stoppingToken));
|
||||||
|
}
|
||||||
|
else { _logger.LogInformation("Ejecución de YahooFinance (Mercado Local) omitida por ser feriado."); }
|
||||||
|
|
||||||
|
// Comprueba el mercado de EEUU
|
||||||
|
if (!await IsMarketHolidayAsync("US", nowInArgentina))
|
||||||
|
{
|
||||||
|
bolsaTasks.Add(RunFetcherByNameAsync("Finnhub", stoppingToken));
|
||||||
|
}
|
||||||
|
else { _logger.LogInformation("Ejecución de Finnhub (Mercado EEUU) omitida por ser feriado."); }
|
||||||
|
|
||||||
|
// Si hay alguna tarea para ejecutar, las lanza en paralelo
|
||||||
|
if (bolsaTasks.Any())
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Iniciando {Count} fetcher(s) de bolsa en paralelo...", bolsaTasks.Count);
|
||||||
|
await Task.WhenAll(bolsaTasks);
|
||||||
|
}
|
||||||
|
|
||||||
_nextBolsasRun = _bolsasSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
_nextBolsasRun = _bolsasSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,6 +181,9 @@ 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;
|
||||||
@@ -149,6 +223,8 @@ 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...");
|
||||||
@@ -167,6 +243,8 @@ 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))
|
||||||
@@ -179,5 +257,19 @@ namespace Mercados.Worker
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Comprueba si una fecha dada es feriado para un mercado específico.
|
||||||
|
/// </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)
|
||||||
|
{
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var holidayService = scope.ServiceProvider.GetRequiredService<IHolidayService>();
|
||||||
|
return await holidayService.IsMarketHolidayAsync(marketCode, date);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
31
src/Mercados.Worker/Dockerfile
Normal file
31
src/Mercados.Worker/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# --- Etapa 1: Build ---
|
||||||
|
# Usamos la imagen del SDK completa para tener todas las herramientas de compilación
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copiamos los archivos .csproj y restauramos las dependencias
|
||||||
|
COPY ["src/Mercados.Worker/Mercados.Worker.csproj", "Mercados.Worker/"]
|
||||||
|
COPY ["src/Mercados.Infrastructure/Mercados.Infrastructure.csproj", "Mercados.Infrastructure/"]
|
||||||
|
COPY ["src/Mercados.Core/Mercados.Core.csproj", "Mercados.Core/"]
|
||||||
|
RUN dotnet restore "Mercados.Worker/Mercados.Worker.csproj"
|
||||||
|
|
||||||
|
# Copiamos el resto del código fuente
|
||||||
|
COPY src/. .
|
||||||
|
|
||||||
|
# Publicamos la aplicación en modo Release
|
||||||
|
WORKDIR "/src/Mercados.Worker"
|
||||||
|
RUN dotnet publish "Mercados.Worker.csproj" -c Release -o /app/publish
|
||||||
|
|
||||||
|
# --- Etapa 2: Final ---
|
||||||
|
# Usamos la imagen de runtime, que es más ligera
|
||||||
|
FROM mcr.microsoft.com/dotnet/runtime:9.0 AS final
|
||||||
|
|
||||||
|
# Instalamos las librerías de soporte para globalización e ICU (International Components for Unicode)
|
||||||
|
# Esto es necesario en imágenes de Linux minimalistas para poder usar codificaciones no-UTF8 como windows-1252.
|
||||||
|
RUN apt-get update && apt-get install -y libicu-dev && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
|
# El punto de entrada no cambia
|
||||||
|
ENTRYPOINT ["dotnet", "Mercados.Worker.dll"]
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ IHost host = Host.CreateDefaultBuilder(args)
|
|||||||
services.AddHttpClient("BcrDataFetcher").AddPolicyHandler(GetRetryPolicy());
|
services.AddHttpClient("BcrDataFetcher").AddPolicyHandler(GetRetryPolicy());
|
||||||
services.AddHttpClient("FinnhubDataFetcher").AddPolicyHandler(GetRetryPolicy());
|
services.AddHttpClient("FinnhubDataFetcher").AddPolicyHandler(GetRetryPolicy());
|
||||||
|
|
||||||
|
// Servicio de caché en memoria de .NET
|
||||||
|
services.AddMemoryCache();
|
||||||
|
// Registramos nuestro nuevo servicio de feriados
|
||||||
|
services.AddScoped<IHolidayService, FinnhubHolidayService>();
|
||||||
|
services.AddScoped<IDataFetcher, HolidayDataFetcher>();
|
||||||
|
services.AddScoped<IMercadoFeriadoRepository, MercadoFeriadoRepository>();
|
||||||
|
|
||||||
services.AddHostedService<DataFetchingService>();
|
services.AddHostedService<DataFetchingService>();
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
"Schedules": {
|
"Schedules": {
|
||||||
"MercadoAgroganadero": "0 11 * * 1-5",
|
"MercadoAgroganadero": "0 11 * * 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"
|
||||||
},
|
},
|
||||||
"ApiKeys": {
|
"ApiKeys": {
|
||||||
"Finnhub": "",
|
"Finnhub": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user