diff --git a/.env b/.env
new file mode 100644
index 0000000..4287c9c
--- /dev/null
+++ b/.env
@@ -0,0 +1,7 @@
+# --- Conexión a la Base de Datos ---
+DB_CONNECTION_STRING="Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;"
+
+# --- Claves de APIs Externas ---
+FINNHUB_API_KEY="cuvhr0hr01qs9e81st2gcuvhr0hr01qs9e81st30"
+BCR_API_KEY="D1782A51-A5FD-EF11-9445-00155D09E201"
+BCR_API_SECRET="da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3"
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
index 7dc52fb..facbc20 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,13 +1,63 @@
-
+
- Mercadors - El Día
+ Mercados Widgets - Catálogo de Componentes
+
-
+ Catálogo Completo de Widgets de Mercados
+ Esta página muestra todos los componentes disponibles para ser integrados.
+
+
+
+ Sección: Mercado Local
+ Componente: Tarjeta de Héroe (Merval)
+
+
+ Componente: Tabla de Acciones Líderes
+
+
+
+
+ Sección: Mercado de EEUU
+ Componente: Tarjeta de Héroe (S&P 500)
+
+
+ Componente: Tabla de Acciones de EEUU
+
+
+
+
+ Sección: Granos
+ Componente: Tarjetas de Resumen
+
+
+ Componente: Tabla Detallada
+
+
+
+
+ Sección: Mercado Agroganadero
+ Componente: Tarjetas de Resumen
+
+
+ Componente: Tabla/Lista Responsiva Detallada
+
+
+
+
+ Sección: Página para Redacción (embebida)
+
+
+
+
-
+
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index c849938..f056aff 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -17,10 +17,12 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
+ "react-router-dom": "^7.6.3",
"recharts": "^3.0.2"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
+ "@types/node": "^24.0.10",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
@@ -1901,6 +1903,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "24.0.10",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.10.tgz",
+ "integrity": "sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.8.0"
+ }
+ },
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@@ -2522,6 +2534,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
+ "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@@ -4090,6 +4111,44 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-router": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.3.tgz",
+ "integrity": "sha512-zf45LZp5skDC6I3jDLXQUu0u26jtuP4lEGbc7BbdyxenBN1vJSTA18czM2D+h5qyMBuMrD+9uB+mU37HIoKGRA==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.3.tgz",
+ "integrity": "sha512-DiWJm9qdUAmiJrVWaeJdu4TKu13+iB/8IEi0EW/XgaHCjW/vWGrwzup0GVvaMteuZjKnh5bEvJP/K0MDnzawHw==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.6.3"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -4274,6 +4333,12 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
+ "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
+ "license": "MIT"
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -4487,6 +4552,13 @@
"typescript": ">=4.8.4 <5.9.0"
}
},
+ "node_modules/undici-types": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
+ "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index b703220..83a7c97 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -19,10 +19,12 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
+ "react-router-dom": "^7.6.3",
"recharts": "^3.0.2"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
+ "@types/node": "^24.0.10",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index bb8f687..68ca4a3 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -24,11 +24,11 @@ function App() {
Datos del Mercado
- {/* --- Sección 1: Mercado Agroganadero de Cañuelas --- */}
+ {/* --- Sección 1: Mercado Agroganadero de Cañuelas ---
Mercado Agroganadero de Cañuelas
-
+ */}
{/* --- Sección 1: Mercado Agroganadero de Cañuelas --- */}
@@ -50,11 +50,11 @@ function App() {
- {/* --- Sección 2: Granos - Bolsa de Comercio de Rosario --- */}
+ {/* --- Sección 2: Granos - Bolsa de Comercio de Rosario ---
Granos - Bolsa de Comercio de Rosario
-
+ */}
{/* --- Sección 3: Mercado de Valores de Estados Unidos --- */}
diff --git a/frontend/src/components/AgroHistoricalChartWidget.tsx b/frontend/src/components/AgroHistoricalChartWidget.tsx
new file mode 100644
index 0000000..f7a3816
--- /dev/null
+++ b/frontend/src/components/AgroHistoricalChartWidget.tsx
@@ -0,0 +1,44 @@
+import { Box, CircularProgress, Alert } from '@mui/material';
+import type { CotizacionGanado } from '../models/mercadoModels';
+import { useApiData } from '../hooks/useApiData';
+import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
+
+interface AgroHistoricalChartWidgetProps {
+ categoria: string;
+ especificaciones: string;
+}
+
+const formatXAxis = (tickItem: string) => {
+ const date = new Date(tickItem);
+ return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' });
+};
+
+export const AgroHistoricalChartWidget = ({ categoria, especificaciones }: AgroHistoricalChartWidgetProps) => {
+ const apiUrl = `/mercados/agroganadero/history?categoria=${encodeURIComponent(categoria)}&especificaciones=${encodeURIComponent(especificaciones)}&dias=30`;
+ const { data, loading, error } = useApiData(apiUrl);
+
+ if (loading) {
+ return ;
+ }
+
+ if (error) {
+ return {error} ;
+ }
+
+ if (!data || data.length < 2) {
+ return No hay suficientes datos históricos para graficar esta categoría. ;
+ }
+
+ return (
+
+
+
+
+ `$${tick.toLocaleString('es-AR')}`} />
+ [`$${value.toFixed(2)}`, 'Precio Promedio']} />
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/BolsaLocalWidget.tsx b/frontend/src/components/BolsaLocalWidget.tsx
index a9a8e3e..0714ff9 100644
--- a/frontend/src/components/BolsaLocalWidget.tsx
+++ b/frontend/src/components/BolsaLocalWidget.tsx
@@ -8,12 +8,11 @@ import CloseIcon from '@mui/icons-material/Close';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import RemoveIcon from '@mui/icons-material/Remove';
-import { formatFullDateTime } from '../utils/formatters';
+import { formatFullDateTime, formatCurrency } from '../utils/formatters';
import type { CotizacionBolsa } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData';
import { HistoricalChartWidget } from './HistoricalChartWidget';
-
-const formatNumber = (num: number) => new Intl.NumberFormat('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(num);
+import { PiChartLineUpBold } from 'react-icons/pi';
const Variacion = ({ value }: { value: number }) => {
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
@@ -21,7 +20,7 @@ const Variacion = ({ value }: { value: number }) => {
return (
- {formatNumber(value)}%
+ {value.toFixed(2)}%
);
};
@@ -30,13 +29,10 @@ export const BolsaLocalWidget = () => {
const { data, loading, error } = useApiData('/mercados/bolsa/local');
const [selectedTicker, setSelectedTicker] = useState(null);
- const handleRowClick = (ticker: string) => {
- setSelectedTicker(ticker);
- };
+ const handleRowClick = (ticker: string) => setSelectedTicker(ticker);
+ const handleCloseDialog = () => setSelectedTicker(null);
- const handleCloseDialog = () => {
- setSelectedTicker(null);
- };
+ const panelPrincipal = data?.filter(d => d.ticker !== '^MERV') || [];
if (loading) {
return ;
@@ -47,54 +43,75 @@ export const BolsaLocalWidget = () => {
}
if (!data || data.length === 0) {
- return No hay datos disponibles para el mercado local en este momento. ;
+ return No hay datos disponibles para el mercado local. ;
}
return (
<>
-
-
-
- Última actualización: {formatFullDateTime(data[0].fechaRegistro)}
-
-
-
-
-
- Símbolo
- Precio Actual
- Apertura
- Cierre Anterior
- % Cambio
-
-
-
- {data.map((row) => (
- handleRowClick(row.ticker)}>
- {row.ticker}
- ${formatNumber(row.precioActual)}
- ${formatNumber(row.apertura)}
- ${formatNumber(row.cierreAnterior)}
-
-
- ))}
-
-
-
-
-
- Historial de 30 días para: {selectedTicker}
- theme.palette.grey[500] }}
- >
-
-
-
+ {panelPrincipal.length > 0 && (
+
+
+
+ Última actualización de acciones: {formatFullDateTime(panelPrincipal[0].fechaRegistro)}
+
+
+
+
+
+ Símbolo
+ Precio Actual
+ Apertura
+ Cierre Anterior
+ % Cambio
+ Historial
+
+
+
+ {panelPrincipal.map((row) => (
+
+ {row.ticker}
+ ${formatCurrency(row.precioActual)}
+ ${formatCurrency(row.apertura)}
+ ${formatCurrency(row.cierreAnterior)}
+
+
+ handleRowClick(row.ticker)}
+ sx={{
+ boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
+ transition: 'all 0.2s ease-in-out',
+ '&:hover': { transform: 'scale(1.1)', boxShadow: '0 2px 6px rgba(0,0,0,0.2)' }
+ }}
+ >
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ theme.palette.grey[500],
+ backgroundColor: 'white', boxShadow: 3, '&:hover': { backgroundColor: 'grey.100' },
+ }}
+ >
+
+
+ Historial de 30 días para: {selectedTicker}
- {selectedTicker && }
+ {selectedTicker && }
>
diff --git a/frontend/src/components/BolsaUsaWidget.tsx b/frontend/src/components/BolsaUsaWidget.tsx
index c11ed61..38b1e36 100644
--- a/frontend/src/components/BolsaUsaWidget.tsx
+++ b/frontend/src/components/BolsaUsaWidget.tsx
@@ -8,14 +8,11 @@ import CloseIcon from '@mui/icons-material/Close';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import RemoveIcon from '@mui/icons-material/Remove';
-import { formatFullDateTime } from '../utils/formatters';
+import { formatFullDateTime, formatCurrency } from '../utils/formatters';
import type { CotizacionBolsa } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData';
import { HistoricalChartWidget } from './HistoricalChartWidget';
-
-// Usamos el formato de EEUU para los precios en dólares
-const formatCurrency = (num: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(num);
-const formatPercentage = (num: number) => num.toFixed(2);
+import { PiChartLineUpBold } from 'react-icons/pi';
const Variacion = ({ value }: { value: number }) => {
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
@@ -23,7 +20,7 @@ const Variacion = ({ value }: { value: number }) => {
return (
- {formatPercentage(value)}%
+ {value.toFixed(2)}%
);
};
@@ -32,13 +29,10 @@ export const BolsaUsaWidget = () => {
const { data, loading, error } = useApiData('/mercados/bolsa/eeuu');
const [selectedTicker, setSelectedTicker] = useState(null);
- const handleRowClick = (ticker: string) => {
- setSelectedTicker(ticker);
- };
+ const handleRowClick = (ticker: string) => setSelectedTicker(ticker);
+ const handleCloseDialog = () => setSelectedTicker(null);
- const handleCloseDialog = () => {
- setSelectedTicker(null);
- };
+ const otherStocks = data?.filter(d => d.ticker !== '^GSPC') || [];
if (loading) {
return ;
@@ -48,56 +42,64 @@ export const BolsaUsaWidget = () => {
return {error} ;
}
- // Recordatorio de que el fetcher puede estar desactivado
if (!data || data.length === 0) {
- return No hay datos disponibles para el mercado de EEUU. (El fetcher podría estar desactivado en el Worker). ;
+ return No hay datos disponibles para el mercado de EEUU. ;
}
return (
<>
-
-
-
- Última actualización: {formatFullDateTime(data[0].fechaRegistro)}
-
-
-
-
-
- Símbolo
- Precio Actual
- Apertura
- Cierre Anterior
- % Cambio
-
-
-
- {data.map((row) => (
- handleRowClick(row.ticker)}>
- {row.ticker}
- {formatCurrency(row.precioActual)}
- {formatCurrency(row.apertura)}
- {formatCurrency(row.cierreAnterior)}
-
-
- ))}
-
-
-
+ {/* Renderizamos la tabla solo si hay otras acciones */}
+ {otherStocks.length > 0 && (
+
+
+
+ Última actualización de acciones: {formatFullDateTime(otherStocks[0].fechaRegistro)}
+
+
+
+
+
+ Símbolo
+ Precio Actual
+
+ Apertura
+ Cierre Anterior
-
-
- Historial de 30 días para: {selectedTicker}
- theme.palette.grey[500] }}
- >
-
-
-
+ % Cambio
+ Historial
+
+
+
+ {otherStocks.map((row) => (
+
+ {row.ticker}
+ {formatCurrency(row.precioActual, 'USD')}
+
+ {formatCurrency(row.apertura, 'USD')}
+ {formatCurrency(row.cierreAnterior, 'USD')}
+
+
+
+ handleRowClick(row.ticker)}
+ sx={{ boxShadow: '0 1px 3px rgba(0,0,0,0.1)', transition: 'all 0.2s ease-in-out', '&:hover': { transform: 'scale(1.1)', boxShadow: '0 2px 6px rgba(0,0,0,0.2)' } }}
+ >
+
+
+ ))}
+
+
+
+ )}
+
+
+ theme.palette.grey[500], backgroundColor: 'white', boxShadow: 3, '&:hover': { backgroundColor: 'grey.100' }, }}>
+
+
+ Historial de 30 días para: {selectedTicker}
- {selectedTicker && }
+ {selectedTicker && }
>
diff --git a/frontend/src/components/GrainsHistoricalChartWidget.tsx b/frontend/src/components/GrainsHistoricalChartWidget.tsx
new file mode 100644
index 0000000..c81d142
--- /dev/null
+++ b/frontend/src/components/GrainsHistoricalChartWidget.tsx
@@ -0,0 +1,43 @@
+import { Box, CircularProgress, Alert } from '@mui/material';
+import type { CotizacionGrano } from '../models/mercadoModels';
+import { useApiData } from '../hooks/useApiData';
+import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
+
+interface GrainsHistoricalChartWidgetProps {
+ nombre: string;
+}
+
+const formatXAxis = (tickItem: string) => {
+ const date = new Date(tickItem);
+ return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' });
+};
+
+export const GrainsHistoricalChartWidget = ({ nombre }: GrainsHistoricalChartWidgetProps) => {
+ const apiUrl = `/mercados/granos/history/${encodeURIComponent(nombre)}?dias=30`;
+ const { data, loading, error } = useApiData(apiUrl);
+
+ if (loading) {
+ return ;
+ }
+
+ if (error) {
+ return {error} ;
+ }
+
+ if (!data || data.length < 2) {
+ return No hay suficientes datos históricos para graficar este grano. ;
+ }
+
+ return (
+
+
+
+
+ `$${tick.toLocaleString('es-AR')}`} />
+ [`$${value.toFixed(0)}`, 'Precio']} />
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/GranosCardWidget.tsx b/frontend/src/components/GranosCardWidget.tsx
index cf532d5..bc06759 100644
--- a/frontend/src/components/GranosCardWidget.tsx
+++ b/frontend/src/components/GranosCardWidget.tsx
@@ -1,84 +1,113 @@
-import { Box, CircularProgress, Alert, Paper, Typography } from '@mui/material';
+import React, { useState, useRef } from 'react';
+import {
+ Box, CircularProgress, Alert, Paper, Typography, Dialog,
+ DialogTitle, DialogContent, IconButton
+} from '@mui/material';
+import { PiChartLineUpBold } from "react-icons/pi";
+import CloseIcon from '@mui/icons-material/Close';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import RemoveIcon from '@mui/icons-material/Remove';
-// Iconos de react-icons para cada grano
import { GiSunflower, GiWheat, GiCorn, GiGrain } from "react-icons/gi";
import { TbGrain } from "react-icons/tb";
import type { CotizacionGrano } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData';
-import { formatCurrency, formatDateOnly } from '../utils/formatters';
+import { formatInteger, formatDateOnly } from '../utils/formatters';
+import { GrainsHistoricalChartWidget } from './GrainsHistoricalChartWidget';
-// Función para elegir el icono según el nombre del grano
const getGrainIcon = (nombre: string) => {
- switch (nombre.toLowerCase()) {
- case 'girasol':
- return ;
- case 'trigo':
- return ;
- case 'sorgo':
- return ;
- case 'maiz':
- return ;
- default:
- return ;
- }
+ switch (nombre.toLowerCase()) {
+ case 'girasol': return ;
+ case 'trigo': return ;
+ case 'sorgo': return ;
+ case 'maiz': return ;
+ default: return ;
+ }
};
// Subcomponente para una única tarjeta de grano
-const GranoCard = ({ grano }: { grano: CotizacionGrano }) => {
- const isPositive = grano.variacionPrecio > 0;
- const isNegative = grano.variacionPrecio < 0;
- const color = isPositive ? 'success.main' : isNegative ? 'error.main' : 'text.secondary';
- const Icon = isPositive ? ArrowUpwardIcon : isNegative ? ArrowDownwardIcon : RemoveIcon;
+const GranoCard = ({ grano, onChartClick }: { grano: CotizacionGrano, onChartClick: (event: React.MouseEvent) => void }) => {
+ const isPositive = grano.variacionPrecio > 0;
+ const isNegative = grano.variacionPrecio < 0;
+ const color = isPositive ? 'success.main' : isNegative ? 'error.main' : 'text.secondary';
+ const Icon = isPositive ? ArrowUpwardIcon : isNegative ? ArrowDownwardIcon : RemoveIcon;
+ return (
+
+
+
+
+
+ {getGrainIcon(grano.nombre)}
+
+ {grano.nombre}
+
+
- return (
-
-
- {getGrainIcon(grano.nombre)}
-
- {grano.nombre}
-
-
+
+
+ ${formatInteger(grano.precio)}
+
+
+ por Tonelada
+
+
-
-
- ${formatCurrency(grano.precio)}
-
-
- por Tonelada
-
-
-
-
-
-
- {formatCurrency(grano.variacionPrecio)}
-
-
-
- Operación: {formatDateOnly(grano.fechaOperacion)}
-
-
- );
+
+
+
+ {formatInteger(grano.variacionPrecio)}
+
+
+
+ Operación: {formatDateOnly(grano.fechaOperacion)}
+
+
+ );
};
export const GranosCardWidget = () => {
const { data, loading, error } = useApiData('/mercados/granos');
+ const [selectedGrano, setSelectedGrano] = useState(null);
+ const triggerButtonRef = useRef(null);
+
+ const handleChartClick = (nombreGrano: string, event: React.MouseEvent) => {
+ triggerButtonRef.current = event.currentTarget;
+ setSelectedGrano(nombreGrano);
+ };
+
+ const handleCloseDialog = () => {
+ setSelectedGrano(null);
+ setTimeout(() => {
+ triggerButtonRef.current?.focus();
+ }, 0);
+ };
if (loading) {
return ;
@@ -93,10 +122,47 @@ export const GranosCardWidget = () => {
}
return (
-
+ <>
+
{data.map((grano) => (
-
- ))}
-
+ handleChartClick(grano.nombre, event)} />
+ ))}
+
+
+ theme.palette.grey[500],
+ backgroundColor: 'white',
+ boxShadow: 3, // Añade una sombra para que destaque
+ '&:hover': {
+ backgroundColor: 'grey.100', // Un leve cambio de color al pasar el mouse
+ },
+ }}
+ >
+
+
+ Mensual de {selectedGrano}
+
+ {selectedGrano && }
+
+
+ >
);
};
\ No newline at end of file
diff --git a/frontend/src/components/GranosWidget.tsx b/frontend/src/components/GranosWidget.tsx
index 0260ffa..f163cf8 100644
--- a/frontend/src/components/GranosWidget.tsx
+++ b/frontend/src/components/GranosWidget.tsx
@@ -10,7 +10,7 @@ import RemoveIcon from '@mui/icons-material/Remove';
const formatNumber = (num: number) => {
return new Intl.NumberFormat('es-AR', {
- minimumFractionDigits: 2,
+ minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(num);
};
diff --git a/frontend/src/components/HistoricalChartWidget.tsx b/frontend/src/components/HistoricalChartWidget.tsx
index b59db4d..d3c87e1 100644
--- a/frontend/src/components/HistoricalChartWidget.tsx
+++ b/frontend/src/components/HistoricalChartWidget.tsx
@@ -6,6 +6,7 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Responsi
interface HistoricalChartWidgetProps {
ticker: string;
mercado: 'Local' | 'EEUU';
+ dias: number;
}
// Formateador para el eje X (muestra DD/MM)
@@ -14,9 +15,9 @@ const formatXAxis = (tickItem: string) => {
return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' });
};
-export const HistoricalChartWidget = ({ ticker, mercado }: HistoricalChartWidgetProps) => {
- // Usamos el hook para obtener los datos del historial de los últimos 30 días
- const { data, loading, error } = useApiData(`/mercados/bolsa/history/${ticker}?mercado=${mercado}&dias=30`);
+export const HistoricalChartWidget = ({ ticker, mercado, dias }: HistoricalChartWidgetProps) => {
+ const apiUrl = `/mercados/bolsa/history/${ticker}?mercado=${mercado}&dias=${dias}`;
+ const { data, loading, error } = useApiData(apiUrl);
if (loading) {
return ;
@@ -38,7 +39,7 @@ export const HistoricalChartWidget = ({ ticker, mercado }: HistoricalChartWidget
`$${tick.toLocaleString('es-AR')}`} />
[`$${value.toFixed(2)}`, 'Precio']} />
-
+
);
diff --git a/frontend/src/components/MercadoAgroCardWidget.tsx b/frontend/src/components/MercadoAgroCardWidget.tsx
index 9ed9402..f64656b 100644
--- a/frontend/src/components/MercadoAgroCardWidget.tsx
+++ b/frontend/src/components/MercadoAgroCardWidget.tsx
@@ -1,39 +1,75 @@
-import { Box, CircularProgress, Alert, Paper, Typography } from '@mui/material';
-import { PiCow } from "react-icons/pi"; // Un icono divertido para "cabezas"
-import ScaleIcon from '@mui/icons-material/Scale'; // Para kilos
+import { useState } from 'react';
+import {
+ Box, CircularProgress, Alert, Paper, Typography, Dialog,
+ DialogTitle, DialogContent, IconButton
+} from '@mui/material';
+import { PiCow } from "react-icons/pi";
+import ScaleIcon from '@mui/icons-material/Scale';
+import { PiChartLineUpBold } from "react-icons/pi";
+import CloseIcon from '@mui/icons-material/Close';
import type { CotizacionGanado } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData';
import { formatCurrency, formatInteger } from '../utils/formatters';
+import { AgroHistoricalChartWidget } from './AgroHistoricalChartWidget';
-const AgroCard = ({ categoria }: { categoria: CotizacionGanado }) => {
+// El subcomponente ahora tendrá un botón para el gráfico.
+const AgroCard = ({ registro, onChartClick }: { registro: CotizacionGanado, onChartClick: () => void }) => {
return (
-
-
- {categoria.categoria}
+ // Añadimos posición relativa para poder posicionar el botón del gráfico.
+
+ {
+ e.stopPropagation();
+ onChartClick();
+ }}
+ sx={{
+ position: 'absolute',
+ top: 8,
+ right: 8,
+ 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)',
+ boxShadow: '0 2px 5px rgba(0,0,0,0.1)',
+ transition: 'all 0.2s ease-in-out', // Transición suave para todos los cambios
+ '&:hover': {
+ transform: 'translateY(-2px)', // Se eleva un poco
+ boxShadow: '0 4px 10px rgba(0,0,0,0.2)', // La sombra se hace más grande
+ backgroundColor: 'rgba(255, 255, 255, 0.9)',
+ }
+ }}
+ >
+
+
+
+
+ {registro.categoria}
+ {registro.especificaciones}
+
Precio Máximo:
- ${formatCurrency(categoria.maximo)}
+ ${formatCurrency(registro.maximo)}
Precio Mínimo:
- ${formatCurrency(categoria.minimo)}
+ ${formatCurrency(registro.minimo)}
Precio Mediano:
- ${formatCurrency(categoria.mediano)}
+ ${formatCurrency(registro.mediano)}
-
+
-
- {formatInteger(categoria.cabezas)}
+
+ {formatInteger(registro.cabezas)}
Cabezas
-
+
- {formatInteger(categoria.kilosTotales)}
+ {formatInteger(registro.kilosTotales)}
Kilos
@@ -41,9 +77,17 @@ const AgroCard = ({ categoria }: { categoria: CotizacionGanado }) => {
);
};
-// Este widget agrupa los datos por categoría para un resumen más limpio.
export const MercadoAgroCardWidget = () => {
const { data, loading, error } = useApiData('/mercados/agroganadero');
+ const [selectedCategory, setSelectedCategory] = useState(null);
+
+ const handleChartClick = (registro: CotizacionGanado) => {
+ setSelectedCategory(registro);
+ };
+
+ const handleCloseDialog = () => {
+ setSelectedCategory(null);
+ };
if (loading) {
return ;
@@ -55,25 +99,50 @@ export const MercadoAgroCardWidget = () => {
return No hay datos del mercado agroganadero disponibles. ;
}
- // Agrupamos y sumamos los datos por categoría principal
- const resumenPorCategoria = data.reduce((acc, item) => {
- if (!acc[item.categoria]) {
- acc[item.categoria] = { ...item };
- } else {
- acc[item.categoria].cabezas += item.cabezas;
- acc[item.categoria].kilosTotales += item.kilosTotales;
- acc[item.categoria].importeTotal += item.importeTotal;
- acc[item.categoria].maximo = Math.max(acc[item.categoria].maximo, item.maximo);
- acc[item.categoria].minimo = Math.min(acc[item.categoria].minimo, item.minimo);
- }
- return acc;
- }, {} as Record);
-
return (
-
- {Object.values(resumenPorCategoria).map(categoria => (
-
- ))}
-
+ <>
+
+ {data.map(registro => (
+ handleChartClick(registro)} />
+ ))}
+
+
+ theme.palette.grey[500],
+ backgroundColor: 'white',
+ boxShadow: 3, // Añade una sombra para que destaque
+ '&:hover': {
+ backgroundColor: 'grey.100', // Un leve cambio de color al pasar el mouse
+ },
+ }}
+ >
+
+
+
+
+ Mensual de {selectedCategory?.categoria} ({selectedCategory?.especificaciones})
+
+
+ {selectedCategory && (
+
+ )}
+
+
+ >
);
};
\ No newline at end of file
diff --git a/frontend/src/components/MercadoAgroWidget.tsx b/frontend/src/components/MercadoAgroWidget.tsx
index e63ea65..1c27d4a 100644
--- a/frontend/src/components/MercadoAgroWidget.tsx
+++ b/frontend/src/components/MercadoAgroWidget.tsx
@@ -4,66 +4,114 @@ import {
} from '@mui/material';
import type { CotizacionGanado } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData';
+import { formatCurrency, formatInteger, formatFullDateTime } from '../utils/formatters';
-const formatNumber = (num: number, fractionDigits = 2) => {
- return new Intl.NumberFormat('es-AR', {
- minimumFractionDigits: fractionDigits,
- maximumFractionDigits: fractionDigits,
- }).format(num);
+// --- V INICIO DE LA MODIFICACIÓN V ---
+// El sub-componente ahora solo necesita renderizar la tarjeta de móvil.
+// La fila de la tabla la haremos directamente en el componente principal.
+const AgroDataCard = ({ row }: { row: CotizacionGanado }) => {
+ const commonStyles = {
+ cell: {
+ display: 'flex', justifyContent: 'space-between', py: 0.5, px: 1,
+ borderBottom: '1px solid rgba(224, 224, 224, 1)',
+ },
+ label: { fontWeight: 'bold', color: 'text.secondary' },
+ value: { textAlign: 'right' }
+ };
+
+ return (
+
+
+ Categoría:
+ {row.categoria}
+
+
+ Especificaciones:
+ {row.especificaciones}
+
+
+ Máximo:
+ ${formatCurrency(row.maximo)}
+
+
+ Mínimo:
+ ${formatCurrency(row.minimo)}
+
+
+ Mediano:
+ ${formatCurrency(row.mediano)}
+
+
+ Cabezas:
+ {formatInteger(row.cabezas)}
+
+
+ Kg Total:
+ {formatInteger(row.kilosTotales)}
+
+
+ Importe Total:
+ ${formatInteger(row.importeTotal)}
+
+
+ );
};
+// --- ^ FIN DE LA MODIFICACIÓN ^ ---
+
export const MercadoAgroWidget = () => {
const { data, loading, error } = useApiData('/mercados/agroganadero');
- if (loading) {
- return ;
- }
-
- if (error) {
- return {error} ;
- }
-
- if (!data || data.length === 0) {
- return No hay datos del mercado agroganadero disponibles. ;
- }
+ if (loading) { return ; }
+ if (error) { return {error} ; }
+ if (!data || data.length === 0) { return No hay datos del mercado agroganadero disponibles. ; }
return (
-
-
-
-
- Categoría
- Especificaciones
- Máximo
- Mínimo
- Mediano
- Cabezas
- Kilos Totales
- Importe Total
-
-
-
- {data.map((row) => (
-
-
- {row.categoria}
-
- {row.especificaciones}
- ${formatNumber(row.maximo)}
- ${formatNumber(row.minimo)}
- ${formatNumber(row.mediano)}
- {formatNumber(row.cabezas, 0)}
- {formatNumber(row.kilosTotales, 0)}
- ${formatNumber(row.importeTotal)}
+
+ {/* VISTA DE ESCRITORIO (se oculta en móvil) */}
+
+
+
+
+ Categoría
+ Especificaciones
+ Máximo
+ Mínimo
+ Mediano
+ Cabezas
+ Kg Total
+ Importe Total
+
+
+ {data.map((row) => (
+
+ {row.categoria}
+ {row.especificaciones}
+ ${formatCurrency(row.maximo)}
+ ${formatCurrency(row.minimo)}
+ ${formatCurrency(row.mediano)}
+ {formatInteger(row.cabezas)}
+ {formatInteger(row.kilosTotales)}
+ ${formatInteger(row.importeTotal)}
+
+ ))}
+
+
+
+
+ {/* VISTA DE MÓVIL (se oculta en escritorio) */}
+
+ {data.map((row) => (
+
))}
-
-
-
+
+
+
Fuente: Mercado Agroganadero S.A.
-
+
);
};
\ No newline at end of file
diff --git a/frontend/src/components/MervalHeroCard.tsx b/frontend/src/components/MervalHeroCard.tsx
new file mode 100644
index 0000000..36fca17
--- /dev/null
+++ b/frontend/src/components/MervalHeroCard.tsx
@@ -0,0 +1,77 @@
+import { useState } from 'react';
+import { Box, Paper, Typography, ToggleButton, ToggleButtonGroup, CircularProgress, Alert } from '@mui/material';
+import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
+import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
+import RemoveIcon from '@mui/icons-material/Remove';
+
+import type { CotizacionBolsa } from '../models/mercadoModels';
+import { formatInteger, formatCurrency } from '../utils/formatters'; // <-- CORREGIDO: necesitamos formatCurrency
+import { HistoricalChartWidget } from './HistoricalChartWidget';
+import { useApiData } from '../hooks/useApiData';
+
+// --- V SUB-COMPONENTE AÑADIDO V ---
+const VariacionMerval = ({ actual, anterior }: { actual: number, anterior: number }) => {
+ if (anterior === 0) return null; // Evitar división por cero
+ const variacionPuntos = actual - anterior;
+ const variacionPorcentaje = (variacionPuntos / anterior) * 100;
+
+ const isPositive = variacionPuntos > 0;
+ const isNegative = variacionPuntos < 0;
+ const color = isPositive ? 'success.main' : isNegative ? 'error.main' : 'text.secondary';
+ const Icon = isPositive ? ArrowUpwardIcon : isNegative ? ArrowDownwardIcon : RemoveIcon;
+
+ return (
+
+
+
+
+ {formatCurrency(variacionPuntos)}
+
+
+ ({variacionPorcentaje.toFixed(2)}%)
+
+
+
+ );
+};
+// --- ^ SUB-COMPONENTE AÑADIDO ^ ---
+
+export const MervalHeroCard = () => {
+ const { data: allLocalData, loading, error } = useApiData('/mercados/bolsa/local');
+ const [dias, setDias] = useState(30);
+
+ const handleRangoChange = ( _event: React.MouseEvent, nuevoRango: number | null ) => {
+ if (nuevoRango !== null) { setDias(nuevoRango); }
+ };
+
+ const mervalData = allLocalData?.find(d => d.ticker === '^MERV');
+
+ if (loading) { return ; }
+ if (error) { return {error} ; }
+ if (!mervalData) { return No se encontraron datos para el índice MERVAL. ; }
+
+ return (
+
+
+
+ Índice S&P MERVAL
+ {formatInteger(mervalData.precioActual)}
+
+
+ {/* Ahora sí encontrará el componente */}
+
+
+
+
+
+
+ Semanal
+ Mensual
+ Anual
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/UsaIndexHeroCard.tsx b/frontend/src/components/UsaIndexHeroCard.tsx
new file mode 100644
index 0000000..4a55b27
--- /dev/null
+++ b/frontend/src/components/UsaIndexHeroCard.tsx
@@ -0,0 +1,73 @@
+import { useState } from 'react';
+import { Box, Paper, Typography, ToggleButton, ToggleButtonGroup, CircularProgress, Alert } from '@mui/material';
+import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
+import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
+import RemoveIcon from '@mui/icons-material/Remove';
+import type { CotizacionBolsa } from '../models/mercadoModels';
+import { useApiData } from '../hooks/useApiData';
+import { formatInteger } from '../utils/formatters';
+import { HistoricalChartWidget } from './HistoricalChartWidget';
+
+const VariacionIndex = ({ actual, anterior }: { actual: number, anterior: number }) => {
+ if (anterior === 0) return null;
+ const variacionPuntos = actual - anterior;
+ const variacionPorcentaje = (variacionPuntos / anterior) * 100;
+
+ const isPositive = variacionPuntos > 0;
+ const isNegative = variacionPuntos < 0;
+ const color = isPositive ? 'success.main' : isNegative ? 'error.main' : 'text.secondary';
+ const Icon = isPositive ? ArrowUpwardIcon : isNegative ? ArrowDownwardIcon : RemoveIcon;
+
+ return (
+
+
+
+
+ {variacionPuntos.toFixed(2)}
+
+
+ ({variacionPorcentaje.toFixed(2)}%)
+
+
+
+ );
+};
+
+export const UsaIndexHeroCard = () => {
+ const { data: allUsaData, loading, error } = useApiData('/mercados/bolsa/eeuu');
+ const [dias, setDias] = useState(30);
+
+ const handleRangoChange = ( _event: React.MouseEvent, nuevoRango: number | null ) => {
+ if (nuevoRango !== null) { setDias(nuevoRango); }
+ };
+
+ const indexData = allUsaData?.find(d => d.ticker === '^GSPC');
+
+ if (loading) { return ; }
+ if (error) { return {error} ; }
+ if (!indexData) { return No se encontraron datos para el índice S&P 500. ; }
+
+ return (
+
+
+
+ S&P 500 Index
+ {formatInteger(indexData.precioActual)}
+
+
+
+
+
+
+
+
+ Semanal
+ Mensual
+ Anual
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/raw-data/RawAgroTable.tsx b/frontend/src/components/raw-data/RawAgroTable.tsx
new file mode 100644
index 0000000..7cfd63d
--- /dev/null
+++ b/frontend/src/components/raw-data/RawAgroTable.tsx
@@ -0,0 +1,85 @@
+import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import { useApiData } from '../../hooks/useApiData';
+import type { CotizacionGanado } from '../../models/mercadoModels';
+import { formatInteger, formatCurrency, formatFullDateTime } from '../../utils/formatters';
+import { copyToClipboard } from '../../utils/clipboardUtils';
+
+// Función para convertir datos a formato CSV
+const toCSV = (headers: string[], data: CotizacionGanado[]) => {
+ const headerRow = headers.join(';');
+ const dataRows = data.map(row =>
+ [
+ row.categoria,
+ row.especificaciones,
+ formatCurrency(row.maximo),
+ formatCurrency(row.minimo),
+ formatCurrency(row.mediano),
+ formatInteger(row.cabezas),
+ formatInteger(row.kilosTotales),
+ formatInteger(row.importeTotal)
+ ].join(';')
+ );
+ return [headerRow, ...dataRows].join('\n');
+};
+
+export const RawAgroTable = () => {
+ const { data, loading, error } = useApiData('/mercados/agroganadero');
+
+ const handleCopy = () => {
+ if (!data) return;
+ const headers = ["Categoría", "Especificaciones", "Máximo", "Mínimo", "Mediano", "Cabezas", "Kg Total", "Importe Total"];
+ const csvData = toCSV(headers, data);
+
+ copyToClipboard(csvData)
+ .then(() => alert('¡Tabla copiada al portapapeles!'))
+ .catch(err => {
+ console.error('Error al copiar:', err);
+ alert('Error: No se pudo copiar la tabla.');
+ });
+ };
+
+ if (loading) return ;
+ if (error) return {error} ;
+ if (!data) return null;
+
+ return (
+
+ } onClick={handleCopy} sx={{ mb: 1 }}>
+ Copiar como CSV
+
+
+
+
+
+ Categoría
+ Especificaciones
+ Máximo
+ Mínimo
+ Mediano
+ Cabezas
+ Kg Total
+ Importe Total
+ Última Act.
+
+
+
+ {data.map(row => (
+
+ {row.categoria}
+ {row.especificaciones}
+ ${formatCurrency(row.maximo)}
+ ${formatCurrency(row.minimo)}
+ ${formatCurrency(row.mediano)}
+ {formatInteger(row.cabezas)}
+ {formatInteger(row.kilosTotales)}
+ ${formatInteger(row.importeTotal)}
+ {formatFullDateTime(row.fechaRegistro)}
+
+ ))}
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/raw-data/RawBolsaLocalTable.tsx b/frontend/src/components/raw-data/RawBolsaLocalTable.tsx
new file mode 100644
index 0000000..8d4ec32
--- /dev/null
+++ b/frontend/src/components/raw-data/RawBolsaLocalTable.tsx
@@ -0,0 +1,78 @@
+import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import { useApiData } from '../../hooks/useApiData';
+import type { CotizacionBolsa } from '../../models/mercadoModels';
+import { formatCurrency, formatFullDateTime } from '../../utils/formatters';
+import { copyToClipboard } from '../../utils/clipboardUtils';
+
+// Función para convertir datos a formato CSV
+const toCSV = (headers: string[], data: CotizacionBolsa[]) => {
+ const headerRow = headers.join(';');
+ const dataRows = data.map(row =>
+ [
+ row.ticker,
+ row.nombreEmpresa,
+ formatCurrency(row.precioActual),
+ formatCurrency(row.cierreAnterior),
+ `${row.porcentajeCambio.toFixed(2)}%`
+ ].join(';')
+ );
+ return [headerRow, ...dataRows].join('\n');
+};
+
+export const RawBolsaLocalTable = () => {
+ const { data, loading, error } = useApiData('/mercados/bolsa/local');
+
+ const handleCopy = () => {
+ if (!data) return;
+ const headers = ["Ticker", "Nombre", "Último Precio", "Cierre Anterior", "Variación %"];
+ const csvData = toCSV(headers, data);
+
+ copyToClipboard(csvData)
+ .then(() => {
+ alert('¡Tabla copiada al portapapeles!');
+ })
+ .catch(err => {
+ console.error('Error al copiar:', err);
+ alert('Error: No se pudo copiar la tabla.');
+ });
+ };
+
+ if (loading) return ;
+ if (error) return {error} ;
+ if (!data) return null;
+
+ return (
+
+ } onClick={handleCopy} sx={{ mb: 1 }}>
+ Copiar como CSV
+
+
+
+
+
+ Ticker
+ Nombre
+ Último Precio
+ Cierre Anterior
+ Variación %
+ Última Act.
+
+
+
+ {data.map(row => (
+
+ {row.ticker}
+ {row.nombreEmpresa}
+ ${formatCurrency(row.precioActual)}
+ ${formatCurrency(row.cierreAnterior)}
+ {row.porcentajeCambio.toFixed(2)}%
+ {formatFullDateTime(row.fechaRegistro)}
+
+ ))}
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/raw-data/RawBolsaUsaTable.tsx b/frontend/src/components/raw-data/RawBolsaUsaTable.tsx
new file mode 100644
index 0000000..2514887
--- /dev/null
+++ b/frontend/src/components/raw-data/RawBolsaUsaTable.tsx
@@ -0,0 +1,76 @@
+import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import { useApiData } from '../../hooks/useApiData';
+import type { CotizacionBolsa } from '../../models/mercadoModels';
+import { formatCurrency, formatFullDateTime } from '../../utils/formatters';
+import { copyToClipboard } from '../../utils/clipboardUtils';
+
+// Función para convertir datos a formato CSV
+const toCSV = (headers: string[], data: CotizacionBolsa[]) => {
+ const headerRow = headers.join(';');
+ const dataRows = data.map(row =>
+ [
+ row.ticker,
+ row.nombreEmpresa,
+ formatCurrency(row.precioActual, 'USD'),
+ formatCurrency(row.cierreAnterior, 'USD'),
+ `${row.porcentajeCambio.toFixed(2)}%`
+ ].join(';')
+ );
+ return [headerRow, ...dataRows].join('\n');
+};
+
+export const RawBolsaUsaTable = () => {
+ const { data, loading, error } = useApiData('/mercados/bolsa/eeuu');
+
+ const handleCopy = () => {
+ if (!data) return;
+ const headers = ["Ticker", "Nombre", "Último Precio (USD)", "Cierre Anterior (USD)", "Variación %"];
+ const csvData = toCSV(headers, data);
+
+ copyToClipboard(csvData)
+ .then(() => alert('¡Tabla copiada al portapapeles!'))
+ .catch(err => {
+ console.error('Error al copiar:', err);
+ alert('Error: No se pudo copiar la tabla.');
+ });
+ };
+
+ if (loading) return ;
+ if (error) return {error} ;
+ if (!data || data.length === 0) return No hay datos disponibles (el fetcher puede estar desactivado). ;
+
+ return (
+
+ } onClick={handleCopy} sx={{ mb: 1 }}>
+ Copiar como CSV
+
+
+
+
+
+ Ticker
+ Nombre
+ Último Precio
+ Cierre Anterior
+ Variación %
+ Última Act.
+
+
+
+ {data.map(row => (
+
+ {row.ticker}
+ {row.nombreEmpresa}
+ ${formatCurrency(row.precioActual, 'USD')}
+ ${formatCurrency(row.cierreAnterior, 'USD')}
+ {row.porcentajeCambio.toFixed(2)}%
+ {formatFullDateTime(row.fechaRegistro)}
+
+ ))}
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/raw-data/RawGranosTable.tsx b/frontend/src/components/raw-data/RawGranosTable.tsx
new file mode 100644
index 0000000..84abc67
--- /dev/null
+++ b/frontend/src/components/raw-data/RawGranosTable.tsx
@@ -0,0 +1,73 @@
+import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import { useApiData } from '../../hooks/useApiData';
+import type { CotizacionGrano } from '../../models/mercadoModels';
+import { formatInteger, formatDateOnly, formatFullDateTime } from '../../utils/formatters';
+import { copyToClipboard } from '../../utils/clipboardUtils';
+
+// Función para convertir datos a formato CSV
+const toCSV = (headers: string[], data: CotizacionGrano[]) => {
+ const headerRow = headers.join(';');
+ const dataRows = data.map(row =>
+ [
+ row.nombre,
+ formatInteger(row.precio),
+ formatInteger(row.variacionPrecio),
+ formatDateOnly(row.fechaOperacion)
+ ].join(';')
+ );
+ return [headerRow, ...dataRows].join('\n');
+};
+
+export const RawGranosTable = () => {
+ const { data, loading, error } = useApiData('/mercados/granos');
+
+ const handleCopy = () => {
+ if (!data) return;
+ const headers = ["Grano", "Precio ($/Tn)", "Variación", "Fecha Op."];
+ const csvData = toCSV(headers, data);
+
+ copyToClipboard(csvData)
+ .then(() => alert('¡Tabla copiada al portapapeles!'))
+ .catch(err => {
+ console.error('Error al copiar:', err);
+ alert('Error: No se pudo copiar la tabla.');
+ });
+ };
+
+ if (loading) return ;
+ if (error) return {error} ;
+ if (!data) return null;
+
+ return (
+
+ } onClick={handleCopy} sx={{ mb: 1 }}>
+ Copiar como CSV
+
+
+
+
+
+ Grano
+ Precio ($/Tn)
+ Variación
+ Fecha Op.
+ Última Act.
+
+
+
+ {data.map(row => (
+
+ {row.nombre}
+ ${formatInteger(row.precio)}
+ {formatInteger(row.variacionPrecio)}
+ {formatDateOnly(row.fechaOperacion)}
+ {formatFullDateTime(row.fechaRegistro)}
+
+ ))}
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 4aff025..fbabc28 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,9 +1,67 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import App from './App.tsx'
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { CssBaseline, ThemeProvider, createTheme } from '@mui/material';
-createRoot(document.getElementById('root')!).render(
-
-
- ,
-)
+// Importamos TODOS los widgets que queremos que estén disponibles
+import { MercadoAgroWidget } from './components/MercadoAgroWidget';
+import { MercadoAgroCardWidget } from './components/MercadoAgroCardWidget';
+import { GranosWidget } from './components/GranosWidget';
+import { GranosCardWidget } from './components/GranosCardWidget';
+import { BolsaUsaWidget } from './components/BolsaUsaWidget';
+import { UsaIndexHeroCard } from './components/UsaIndexHeroCard';
+import { BolsaLocalWidget } from './components/BolsaLocalWidget';
+import { MervalHeroCard } from './components/MervalHeroCard';
+import { RawDataView } from './pages/RawDataView';
+
+// El registro de todos nuestros widgets disponibles
+const widgetRegistry = {
+ // Widgets del Mercado Local
+ 'merval-heroe': MervalHeroCard,
+ 'bolsa-local-tabla': BolsaLocalWidget,
+
+ // Widgets del Mercado de EEUU
+ 'sp500-heroe': UsaIndexHeroCard,
+ 'bolsa-eeuu-tabla': BolsaUsaWidget,
+
+ // Widgets de Granos
+ 'granos-tarjetas': GranosCardWidget,
+ 'granos-tabla': GranosWidget,
+
+ // Widgets del Mercado Agroganadero
+ 'mercado-agro-tarjetas': MercadoAgroCardWidget,
+ 'mercado-agro-tabla': MercadoAgroWidget,
+
+ // Página completa como un widget
+ 'pagina-datos-crudos': RawDataView,
+};
+
+// Tema base de Material-UI
+const theme = createTheme();
+
+// Exponemos la función de renderizado en el objeto window
+// @ts-ignore
+window.MercadosWidgets = {
+ render: () => {
+ const widgetContainers = document.querySelectorAll('[data-mercado-widget]');
+ widgetContainers.forEach(container => {
+ const widgetName = container.dataset.mercadoWidget as keyof typeof widgetRegistry;
+ if (widgetName && widgetRegistry[widgetName]) {
+ const WidgetComponent = widgetRegistry[widgetName];
+ ReactDOM.createRoot(container).render(
+
+
+
+
+
+
+ );
+ }
+ });
+ }
+};
+
+// Si estamos en modo de desarrollo, llamamos a render automáticamente
+if (import.meta.env.DEV) {
+ // @ts-ignore
+ window.MercadosWidgets.render();
+}
\ No newline at end of file
diff --git a/frontend/src/models/mercadoModels.ts b/frontend/src/models/mercadoModels.ts
index 641159c..2a790d1 100644
--- a/frontend/src/models/mercadoModels.ts
+++ b/frontend/src/models/mercadoModels.ts
@@ -28,6 +28,7 @@ export interface CotizacionGrano {
export interface CotizacionBolsa {
id: number;
ticker: string;
+ nombreEmpresa?: string;
mercado: string;
precioActual: number;
apertura: number;
diff --git a/frontend/src/pages/RawDataPage.tsx b/frontend/src/pages/RawDataPage.tsx
new file mode 100644
index 0000000..f16f035
--- /dev/null
+++ b/frontend/src/pages/RawDataPage.tsx
@@ -0,0 +1,22 @@
+import { AppBar, Toolbar, Typography, Button } from '@mui/material';
+import { Link } from 'react-router-dom';
+import { RawDataView } from './RawDataView'; // <-- Importamos la vista
+
+// Este componente es una "Página" completa, con su propio layout.
+// Solo debe ser usado dentro de un Router.
+export const RawDataPage = () => {
+ return (
+ <>
+
+
+
+ Datos para Redacción
+
+ Volver a la vista principal
+
+
+ {/* Reutilizamos el contenido */}
+
+ >
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/pages/RawDataView.tsx b/frontend/src/pages/RawDataView.tsx
new file mode 100644
index 0000000..04760ab
--- /dev/null
+++ b/frontend/src/pages/RawDataView.tsx
@@ -0,0 +1,22 @@
+import { Container, Typography } from '@mui/material';
+import { RawAgroTable } from '../components/raw-data/RawAgroTable';
+import { RawGranosTable } from '../components/raw-data/RawGranosTable';
+import { RawBolsaUsaTable } from '../components/raw-data/RawBolsaUsaTable';
+import { RawBolsaLocalTable } from '../components/raw-data/RawBolsaLocalTable';
+
+// Este componente solo muestra el contenido, sin la barra de navegación.
+// Es seguro para ser embebido en cualquier lugar.
+export const RawDataView = () => {
+ return (
+
+ Mercado Agroganadero
+
+ Granos
+
+ Bolsa de Valores EEUU
+
+ Bolsa de Valores Argentina
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/utils/clipboardUtils.ts b/frontend/src/utils/clipboardUtils.ts
new file mode 100644
index 0000000..138c122
--- /dev/null
+++ b/frontend/src/utils/clipboardUtils.ts
@@ -0,0 +1,34 @@
+export const copyToClipboard = (text: string): Promise => {
+ // Intenta usar la API moderna (navigator.clipboard)
+ if (navigator.clipboard && window.isSecureContext) {
+ return navigator.clipboard.writeText(text);
+ } else {
+ // Fallback para navegadores antiguos o contextos no seguros (HTTP, IPs)
+ return new Promise((resolve, reject) => {
+ const textArea = document.createElement('textarea');
+ textArea.value = text;
+
+ // Haz el textarea invisible
+ textArea.style.position = 'absolute';
+ textArea.style.left = '-9999px';
+ textArea.style.top = '0';
+
+ document.body.appendChild(textArea);
+ textArea.focus();
+ textArea.select();
+
+ try {
+ const successful = document.execCommand('copy');
+ if (successful) {
+ resolve();
+ } else {
+ reject(new Error('No se pudo copiar el texto.'));
+ }
+ } catch (err) {
+ reject(err);
+ } finally {
+ document.body.removeChild(textArea);
+ }
+ });
+ }
+};
\ No newline at end of file
diff --git a/frontend/src/utils/formatters.ts b/frontend/src/utils/formatters.ts
index 288000c..1103340 100644
--- a/frontend/src/utils/formatters.ts
+++ b/frontend/src/utils/formatters.ts
@@ -6,7 +6,7 @@ export const formatCurrency = (num: number, currency = 'ARS') => {
return new Intl.NumberFormat(locale, {
style: style,
currency: currency,
- minimumFractionDigits: 2,
+ minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(num);
};
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index f77e6e9..5a98b85 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,11 +1,33 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
+import path from 'path'; // Importa el módulo 'path' de Node
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
- server: {
- host: true, // o "0.0.0.0"
- port: 5173 // el puerto que uses, opcional
+ // --- V INICIO DE LA CONFIGURACIÓN DE LIBRERÍA V ---
+ build: {
+ lib: {
+ // La entrada a nuestra librería. Apunta a nuestro main.tsx
+ entry: path.resolve(__dirname, 'src/main.tsx'),
+ // El nombre de la variable global que se expondrá
+ name: 'MercadosWidgets',
+ // El nombre del archivo de salida
+ fileName: (format) => `mercados-widgets.${format}.js`,
+ },
+ // 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'
+ }
+ }
+ }
}
-})
+})
\ No newline at end of file
diff --git a/src/Mercados.Api/Controllers/MercadosController.cs b/src/Mercados.Api/Controllers/MercadosController.cs
index a50519a..f657d09 100644
--- a/src/Mercados.Api/Controllers/MercadosController.cs
+++ b/src/Mercados.Api/Controllers/MercadosController.cs
@@ -113,5 +113,39 @@ namespace Mercados.Api.Controllers
return StatusCode(500, "Ocurrió un error interno en el servidor.");
}
}
+
+ [HttpGet("agroganadero/history")]
+ [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public async Task GetAgroganaderoHistory([FromQuery] string categoria, [FromQuery] string especificaciones, [FromQuery] int dias = 30)
+ {
+ try
+ {
+ var data = await _ganadoRepo.ObtenerHistorialAsync(categoria, especificaciones, dias);
+ return Ok(data);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error al obtener historial para la categoría {Categoria}.", categoria);
+ return StatusCode(500, "Ocurrió un error interno en el servidor.");
+ }
+ }
+
+ [HttpGet("granos/history/{nombre}")]
+ [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public async Task GetGranoHistory(string nombre, [FromQuery] int dias = 30)
+ {
+ try
+ {
+ var data = await _granoRepo.ObtenerHistorialAsync(nombre, dias);
+ return Ok(data);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error al obtener historial para el grano {Grano}.", nombre);
+ return StatusCode(500, "Ocurrió un error interno en el servidor.");
+ }
+ }
}
}
\ No newline at end of file
diff --git a/src/Mercados.Api/Mercados.Api.csproj b/src/Mercados.Api/Mercados.Api.csproj
index d2cd28a..a586c48 100644
--- a/src/Mercados.Api/Mercados.Api.csproj
+++ b/src/Mercados.Api/Mercados.Api.csproj
@@ -7,6 +7,7 @@
+
diff --git a/src/Mercados.Api/Program.cs b/src/Mercados.Api/Program.cs
index 1813740..37cea6a 100644
--- a/src/Mercados.Api/Program.cs
+++ b/src/Mercados.Api/Program.cs
@@ -5,6 +5,9 @@ using Mercados.Infrastructure.Persistence;
using Mercados.Infrastructure.Persistence.Repositories;
using System.Reflection;
+// Carga las variables de entorno desde el archivo .env en la raíz de la solución.
+DotNetEnv.Env.Load();
+
var builder = WebApplication.CreateBuilder(args);
// Nombre para política de CORS
@@ -16,7 +19,10 @@ builder.Services.AddCors(options =>
options.AddPolicy(name: MyAllowSpecificOrigins,
policy =>
{
- policy.WithOrigins("http://localhost:5173", "http://192.168.10.78:5173")
+ policy.WithOrigins("http://localhost:5173", // Desarrollo Frontend
+ "http://192.168.10.78:5173", // Desarrollo en Red Local
+ "https://www.eldia.com" // <--- DOMINIO DE PRODUCCIÓN
+ )
.AllowAnyHeader()
.AllowAnyMethod();
});
diff --git a/src/Mercados.Api/appsettings.json b/src/Mercados.Api/appsettings.json
index 5644844..6c56bd7 100644
--- a/src/Mercados.Api/appsettings.json
+++ b/src/Mercados.Api/appsettings.json
@@ -7,13 +7,13 @@
},
"AllowedHosts": "*",
"ConnectionStrings": {
- "DefaultConnection": "Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;"
+ "DefaultConnection": ""
},
"ApiKeys": {
- "Finnhub": "cuvhr0hr01qs9e81st2gcuvhr0hr01qs9e81st30",
+ "Finnhub": "",
"Bcr": {
- "Key": "D1782A51-A5FD-EF11-9445-00155D09E201",
- "Secret": "da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3"
+ "Key": "",
+ "Secret": ""
}
}
}
\ No newline at end of file
diff --git a/src/Mercados.Core/Entities/CotizacionBolsa.cs b/src/Mercados.Core/Entities/CotizacionBolsa.cs
index 22eaf00..a8f0a82 100644
--- a/src/Mercados.Core/Entities/CotizacionBolsa.cs
+++ b/src/Mercados.Core/Entities/CotizacionBolsa.cs
@@ -4,6 +4,7 @@ namespace Mercados.Core.Entities
{
public long Id { get; set; }
public string Ticker { get; set; } = string.Empty; // "AAPL", "GGAL.BA", etc.
+ public string? NombreEmpresa { get; set; }
public string Mercado { get; set; } = string.Empty; // "EEUU" o "Local"
public decimal PrecioActual { get; set; }
public decimal Apertura { get; set; }
diff --git a/src/Mercados.Database/Class1.cs b/src/Mercados.Database/Class1.cs
deleted file mode 100644
index da9d75c..0000000
--- a/src/Mercados.Database/Class1.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace Mercados.Database;
-
-public class Class1
-{
-
-}
diff --git a/src/Mercados.Database/Migrations/20240702133000_AddNameToStocks.cs b/src/Mercados.Database/Migrations/20240702133000_AddNameToStocks.cs
new file mode 100644
index 0000000..f5b3df5
--- /dev/null
+++ b/src/Mercados.Database/Migrations/20240702133000_AddNameToStocks.cs
@@ -0,0 +1,19 @@
+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");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Mercados.Infrastructure/Class1.cs b/src/Mercados.Infrastructure/Class1.cs
deleted file mode 100644
index 4a2d363..0000000
--- a/src/Mercados.Infrastructure/Class1.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace Mercados.Infrastructure;
-
-public class Class1
-{
-
-}
diff --git a/src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs b/src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs
index 393e087..7e435f6 100644
--- a/src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs
+++ b/src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs
@@ -122,8 +122,8 @@ namespace Mercados.Infrastructure.DataFetchers
private async Task GetAuthTokenAsync(HttpClient client)
{
var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/Login");
- request.Headers.Add("api_key", _configuration["ApiKeys:Bcr:Key"]);
- request.Headers.Add("secret", _configuration["ApiKeys:Bcr:Secret"]);
+ request.Headers.Add("api_key", Environment.GetEnvironmentVariable("BCR_API_KEY"));
+ request.Headers.Add("secret", Environment.GetEnvironmentVariable("BCR_API_SECRET"));
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
diff --git a/src/Mercados.Infrastructure/DataFetchers/FinnhubDataFetcher.cs b/src/Mercados.Infrastructure/DataFetchers/FinnhubDataFetcher.cs
index f5c3bcc..0a5b9f4 100644
--- a/src/Mercados.Infrastructure/DataFetchers/FinnhubDataFetcher.cs
+++ b/src/Mercados.Infrastructure/DataFetchers/FinnhubDataFetcher.cs
@@ -11,9 +11,12 @@ namespace Mercados.Infrastructure.DataFetchers
{
public string SourceName => "Finnhub";
private readonly List _tickers = new() {
- "AAPL", "AMD", "AMZN", "BRK-B", "KO", "MSFT", "NVDA", "GLD",
- "XLF", "XLI", "XLE", "XLK", "YPF", "GGAL", "BMA", "TEO",
- "PAM", "CEPU", "LOMA", "CRESY", "BBAR", "TGS", "EDN", "MELI", "GLOB"
+ // Tecnológicas y ETFs
+ "AAPL", "MSFT", "AMZN", "NVDA", "AMD", "KO", "BRK-B", "GLD", "XLF", "XLI", "XLE", "XLK",
+ // Empresas 'Latinas' en Wall Street
+ "MELI", "GLOB",
+ // ADRs Argentinos
+ "YPF", "GGAL", "BMA", "LOMA", "PAM", "TEO", "TGS", "EDN", "CRESY", "CEPU", "BBAR"
};
private readonly FinnhubClient _client;
@@ -28,7 +31,7 @@ namespace Mercados.Infrastructure.DataFetchers
IFuenteDatoRepository fuenteDatoRepository,
ILogger logger)
{
- var apiKey = configuration["ApiKeys:Finnhub"];
+ var apiKey = Environment.GetEnvironmentVariable("FINNHUB_API_KEY");
if (string.IsNullOrEmpty(apiKey))
{
throw new InvalidOperationException("La clave de API de Finnhub no está configurada en appsettings.json (ApiKeys:Finnhub)");
@@ -53,9 +56,11 @@ namespace Mercados.Infrastructure.DataFetchers
if (quote.Current == 0 || quote.PreviousClose == 0) continue;
var pctChange = ((quote.Current - quote.PreviousClose) / quote.PreviousClose) * 100;
+
cotizaciones.Add(new CotizacionBolsa
{
Ticker = ticker,
+ NombreEmpresa = TickerNameMapping.GetName(ticker),
Mercado = "EEUU",
PrecioActual = (decimal)quote.Current,
Apertura = (decimal)quote.Open,
diff --git a/src/Mercados.Infrastructure/DataFetchers/TickerNameMapping.cs b/src/Mercados.Infrastructure/DataFetchers/TickerNameMapping.cs
new file mode 100644
index 0000000..a80c191
--- /dev/null
+++ b/src/Mercados.Infrastructure/DataFetchers/TickerNameMapping.cs
@@ -0,0 +1,61 @@
+namespace Mercados.Infrastructure.DataFetchers
+{
+ public static class TickerNameMapping
+ {
+ private static readonly Dictionary Names = new(StringComparer.OrdinalIgnoreCase)
+ {
+ // USA
+ { "SPY", "S&P 500 ETF" }, // Cambiado de GSPC a SPY para Finnhub
+ { "AAPL", "Apple Inc." },
+ { "MSFT", "Microsoft Corp." },
+ { "AMZN", "Amazon.com, Inc." },
+ { "NVDA", "NVIDIA Corp." },
+ { "AMD", "Advanced Micro Devices" },
+ { "KO", "The Coca-Cola Company" },
+ { "BRK-B", "Berkshire Hathaway Inc." },
+ { "GLD", "SPDR Gold Shares" },
+ { "XLF", "Financial Select Sector SPDR" },
+ { "XLI", "Industrial Select Sector SPDR" },
+ { "XLE", "Energy Select Sector SPDR" },
+ { "XLK", "Technology Select Sector SPDR" },
+ { "MELI", "MercadoLibre, Inc." },
+ { "GLOB", "Globant" },
+
+ // ADRs Argentinos que cotizan en EEUU
+ { "YPF", "YPF S.A. (ADR)" },
+ { "GGAL", "Grupo Financiero Galicia (ADR)" },
+ { "BMA", "Banco Macro (ADR)" },
+ { "LOMA", "Loma Negra (ADR)" },
+ { "PAM", "Pampa Energía (ADR)" },
+ { "TEO", "Telecom Argentina (ADR)" },
+ { "TGS", "Transportadora de Gas del Sur (ADR)" },
+ { "EDN", "Edenor (ADR)" },
+ { "CRESY", "Cresud (ADR)" },
+ { "CEPU", "Central Puerto (ADR)" },
+ { "BBAR", "BBVA Argentina (ADR)" },
+
+ // Argentina Local
+ { "^GSPC", "S&P 500 Index" }, // Lo dejamos por si Yahoo lo devuelve
+ { "^MERV", "S&P Merval" },
+ { "GGAL.BA", "Grupo Financiero Galicia" },
+ { "YPFD.BA", "YPF S.A." },
+ { "PAMP.BA", "Pampa Energía" },
+ { "BMA.BA", "Banco Macro" },
+ { "COME.BA", "Sociedad Comercial del Plata" },
+ { "TECO2.BA", "Telecom Argentina" },
+ { "EDN.BA", "Edenor" },
+ { "CRES.BA", "Cresud" },
+ { "TXAR.BA", "Ternium Argentina" },
+ { "MIRG.BA", "Mirgor" },
+ { "CEPU.BA", "Central Puerto" },
+ { "LOMA.BA", "Loma Negra" },
+ { "VALO.BA", "Banco de Valores" },
+ { "MELI.BA", "MercadoLibre (CEDEAR)" }, // Aclaramos que es el CEDEAR
+ };
+
+ public static string? GetName(string ticker)
+ {
+ return Names.GetValueOrDefault(ticker);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Mercados.Infrastructure/DataFetchers/YahooFinanceDataFetcher.cs b/src/Mercados.Infrastructure/DataFetchers/YahooFinanceDataFetcher.cs
index c177850..05bb29a 100644
--- a/src/Mercados.Infrastructure/DataFetchers/YahooFinanceDataFetcher.cs
+++ b/src/Mercados.Infrastructure/DataFetchers/YahooFinanceDataFetcher.cs
@@ -9,9 +9,10 @@ namespace Mercados.Infrastructure.DataFetchers
{
public string SourceName => "YahooFinance";
private readonly List _tickers = new() {
+ "^GSPC", // Índice S&P 500
"^MERV", "GGAL.BA", "YPFD.BA", "PAMP.BA", "BMA.BA", "COME.BA",
"TECO2.BA", "EDN.BA", "CRES.BA", "TXAR.BA", "MIRG.BA",
- "CEPU.BA", "LOMA.BA", "VALO.BA"
+ "CEPU.BA", "LOMA.BA", "VALO.BA", "MELI.BA"
};
private readonly ICotizacionBolsaRepository _cotizacionRepository;
@@ -33,7 +34,6 @@ namespace Mercados.Infrastructure.DataFetchers
_logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName);
try
{
- // La librería puede obtener múltiples tickers en una sola llamada.
var securities = await Yahoo.Symbols(_tickers.ToArray()).Fields(Field.RegularMarketPrice, Field.RegularMarketOpen, Field.RegularMarketPreviousClose, Field.RegularMarketChangePercent).QueryAsync();
var cotizaciones = new List();
@@ -41,10 +41,13 @@ namespace Mercados.Infrastructure.DataFetchers
{
if (sec.RegularMarketPrice == 0 || sec.RegularMarketPreviousClose == 0) continue;
+ string mercado = sec.Symbol.EndsWith(".BA") || sec.Symbol == "^MERV" ? "Local" : "EEUU";
+
cotizaciones.Add(new CotizacionBolsa
{
Ticker = sec.Symbol,
- Mercado = "Local",
+ NombreEmpresa = TickerNameMapping.GetName(sec.Symbol),
+ Mercado = mercado,
PrecioActual = (decimal)sec.RegularMarketPrice,
Apertura = (decimal)sec.RegularMarketOpen,
CierreAnterior = (decimal)sec.RegularMarketPreviousClose,
diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionBolsaRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionBolsaRepository.cs
index a42e8e1..f453456 100644
--- a/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionBolsaRepository.cs
+++ b/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionBolsaRepository.cs
@@ -18,8 +18,8 @@ namespace Mercados.Infrastructure.Persistence.Repositories
using IDbConnection connection = _connectionFactory.CreateConnection();
const string sql = @"
- INSERT INTO CotizacionesBolsa (Ticker, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro)
- VALUES (@Ticker, @Mercado, @PrecioActual, @Apertura, @CierreAnterior, @PorcentajeCambio, @FechaRegistro);";
+ INSERT INTO CotizacionesBolsa (Ticker, NombreEmpresa, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro)
+ VALUES (@Ticker, @NombreEmpresa, @Mercado, @PrecioActual, @Apertura, @CierreAnterior, @PorcentajeCambio, @FechaRegistro);";
await connection.ExecuteAsync(sql, cotizaciones);
}
@@ -27,10 +27,7 @@ namespace Mercados.Infrastructure.Persistence.Repositories
public async Task> ObtenerUltimasPorMercadoAsync(string mercado)
{
using IDbConnection connection = _connectionFactory.CreateConnection();
-
- // Esta consulta usa una "Common Table Expression" (CTE)
- // y la función ROW_NUMBER() para obtener el registro más reciente para cada Ticker
- // dentro del mercado especificado. Es extremadamente eficiente.
+
const string sql = @"
WITH RankedCotizaciones AS (
SELECT
@@ -42,7 +39,7 @@ namespace Mercados.Infrastructure.Persistence.Repositories
Mercado = @Mercado
)
SELECT
- Id, Ticker, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro
+ Id, Ticker, NombreEmpresa, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro
FROM
RankedCotizaciones
WHERE
@@ -57,7 +54,7 @@ namespace Mercados.Infrastructure.Persistence.Repositories
const string sql = @"
SELECT
- Id, Ticker, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro
+ Id, Ticker, NombreEmpresa, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro
FROM
CotizacionesBolsa
WHERE
@@ -65,7 +62,7 @@ namespace Mercados.Infrastructure.Persistence.Repositories
AND Mercado = @Mercado
AND FechaRegistro >= DATEADD(day, -@Dias, GETUTCDATE())
ORDER BY
- FechaRegistro ASC;"; // ASC es importante para dibujar la línea del gráfico
+ FechaRegistro ASC;";
return await connection.QueryAsync(sql, new { Ticker = ticker, Mercado = mercado, Dias = dias });
}
diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGanadoRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGanadoRepository.cs
index 17809c0..5257f69 100644
--- a/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGanadoRepository.cs
+++ b/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGanadoRepository.cs
@@ -43,5 +43,28 @@ namespace Mercados.Infrastructure.Persistence.Repositories
return await connection.QueryAsync(sql);
}
+
+ public async Task> ObtenerHistorialAsync(string categoria, string especificaciones, int dias)
+ {
+ using IDbConnection connection = _connectionFactory.CreateConnection();
+
+ const string sql = @"
+ SELECT
+ *
+ FROM
+ CotizacionesGanado
+ WHERE
+ Categoria = @Categoria
+ AND Especificaciones = @Especificaciones
+ AND FechaRegistro >= DATEADD(day, -@Dias, GETUTCDATE())
+ ORDER BY
+ FechaRegistro ASC;";
+
+ return await connection.QueryAsync(sql, new {
+ Categoria = categoria,
+ Especificaciones = especificaciones,
+ Dias = dias
+ });
+ }
}
}
\ No newline at end of file
diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGranoRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGranoRepository.cs
index 5d6678c..bc68297 100644
--- a/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGranoRepository.cs
+++ b/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGranoRepository.cs
@@ -44,5 +44,23 @@ namespace Mercados.Infrastructure.Persistence.Repositories
return await connection.QueryAsync(sql);
}
+
+ public async Task> ObtenerHistorialAsync(string nombre, int dias)
+ {
+ using IDbConnection connection = _connectionFactory.CreateConnection();
+
+ const string sql = @"
+ SELECT
+ *
+ FROM
+ CotizacionesGranos
+ WHERE
+ Nombre = @Nombre
+ AND FechaRegistro >= DATEADD(day, -@Dias, GETUTCDATE())
+ ORDER BY
+ FechaRegistro ASC;";
+
+ return await connection.QueryAsync(sql, new { Nombre = nombre, Dias = dias });
+ }
}
}
\ No newline at end of file
diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGanadoRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGanadoRepository.cs
index 122643f..21f6053 100644
--- a/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGanadoRepository.cs
+++ b/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGanadoRepository.cs
@@ -6,5 +6,6 @@ namespace Mercados.Infrastructure.Persistence.Repositories
{
Task GuardarMuchosAsync(IEnumerable cotizaciones);
Task> ObtenerUltimaTandaAsync();
+ Task> ObtenerHistorialAsync(string categoria, string especificaciones, int dias);
}
}
\ No newline at end of file
diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGranoRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGranoRepository.cs
index 84ca922..6b24bbe 100644
--- a/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGranoRepository.cs
+++ b/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGranoRepository.cs
@@ -6,5 +6,6 @@ namespace Mercados.Infrastructure.Persistence.Repositories
{
Task GuardarMuchosAsync(IEnumerable cotizaciones);
Task> ObtenerUltimasAsync();
+ Task> ObtenerHistorialAsync(string nombre, int dias);
}
}
\ No newline at end of file
diff --git a/src/Mercados.Infrastructure/Persistence/SqlConnectionFactory.cs b/src/Mercados.Infrastructure/Persistence/SqlConnectionFactory.cs
index 50d01bb..8343c4e 100644
--- a/src/Mercados.Infrastructure/Persistence/SqlConnectionFactory.cs
+++ b/src/Mercados.Infrastructure/Persistence/SqlConnectionFactory.cs
@@ -11,8 +11,9 @@ namespace Mercados.Infrastructure
public SqlConnectionFactory(IConfiguration configuration)
{
- _connectionString = configuration.GetConnectionString("DefaultConnection")
- ?? throw new ArgumentNullException(nameof(configuration), "La cadena de conexión 'DefaultConnection' no fue encontrada.");
+ // Leemos directamente de la variable de entorno
+ _connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING")
+ ?? throw new ArgumentNullException(nameof(configuration), "La variable de entorno 'DB_CONNECTION_STRING' no fue encontrada.");
}
public IDbConnection CreateConnection()
diff --git a/src/Mercados.Worker/DataFetchingService.cs b/src/Mercados.Worker/DataFetchingService.cs
index 9848bbd..2cac2d2 100644
--- a/src/Mercados.Worker/DataFetchingService.cs
+++ b/src/Mercados.Worker/DataFetchingService.cs
@@ -2,70 +2,109 @@ using Mercados.Infrastructure.DataFetchers;
namespace Mercados.Worker
{
+ ///
+ /// Servicio de fondo que orquesta la obtención de datos de diversas fuentes
+ /// de forma programada y periódica.
+ ///
public class DataFetchingService : BackgroundService
{
private readonly ILogger _logger;
private readonly IServiceProvider _serviceProvider;
+ private readonly TimeZoneInfo _argentinaTimeZone;
- // Diccionario para rastrear la última vez que se ejecutó una tarea diaria.
+ // Diccionario para rastrear la última vez que se ejecutó una tarea diaria
+ // y evitar que se ejecute múltiples veces si el servicio se reinicia.
private readonly Dictionary _lastDailyRun = new();
public DataFetchingService(ILogger logger, IServiceProvider serviceProvider)
{
_logger = logger;
_serviceProvider = serviceProvider;
+
+ // Se define explícitamente la zona horaria de Argentina.
+ // Esto asegura que los cálculos de tiempo sean correctos, sin importar
+ // la configuración de zona horaria del servidor donde se ejecute el worker.
+ try
+ {
+ // El ID estándar para Linux y macOS
+ _argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires");
+ }
+ catch (TimeZoneNotFoundException)
+ {
+ // El ID equivalente para Windows
+ _argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time");
+ }
}
+ ///
+ /// Método principal del servicio. Se ejecuta una vez cuando el servicio arranca.
+ ///
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now);
- // Ejecutamos una vez al inicio para tener datos frescos inmediatamente.
- await RunAllFetchersAsync();
+ // Se recomienda una ejecución inicial para poblar la base de datos inmediatamente
+ // al iniciar el servicio, en lugar de esperar al primer horario programado.
+ //await RunAllFetchersAsync(stoppingToken);
- // Usamos un PeriodicTimer que "despierta" cada minuto para revisar si hay tareas pendientes.
+ // PeriodicTimer es una forma moderna y eficiente de crear un bucle de "tic-tac"
+ // sin bloquear un hilo con Task.Delay.
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
- while (await timer.WaitForNextTickAsync(stoppingToken))
+ // El bucle se ejecuta cada minuto mientras el servicio no reciba una señal de detención.
+ while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
{
- await RunScheduledTasksAsync();
+ await RunScheduledTasksAsync(stoppingToken);
}
}
- private async Task RunScheduledTasksAsync()
+ ///
+ /// Revisa la hora actual y ejecuta las tareas que coincidan con su horario programado.
+ ///
+ private async Task RunScheduledTasksAsync(CancellationToken stoppingToken)
{
- // --- Lógica de Planificación ---
- var now = DateTime.Now;
+ // Se obtiene la hora actual convertida a la zona horaria de Argentina.
+ var nowInArgentina = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _argentinaTimeZone);
- // Tarea 1: Mercado Agroganadero (todos los días a las 11:00)
- if (now.Hour == 11 && now.Minute == 0 && HasNotRunToday("MercadoAgroganadero"))
+ // --- Tarea 1: Mercado Agroganadero (L-V a las 11:00 AM) ---
+ if (IsWeekDay(nowInArgentina) && nowInArgentina.Hour == 11 && nowInArgentina.Minute == 0 && HasNotRunToday("MercadoAgroganadero"))
{
- await RunFetcherByNameAsync("MercadoAgroganadero");
- _lastDailyRun["MercadoAgroganadero"] = now.Date;
+ await RunFetcherByNameAsync("MercadoAgroganadero", stoppingToken);
+ _lastDailyRun["MercadoAgroganadero"] = nowInArgentina.Date;
}
- // Tarea 2: Granos BCR (todos los días a las 11:30)
- if (now.Hour == 11 && now.Minute == 30 && HasNotRunToday("BCR"))
+ // --- Tarea 2: Granos BCR (L-V a las 11:30 AM) ---
+ if (IsWeekDay(nowInArgentina) && nowInArgentina.Hour == 11 && nowInArgentina.Minute == 30 && HasNotRunToday("BCR"))
{
- await RunFetcherByNameAsync("BCR");
- _lastDailyRun["BCR"] = now.Date;
+ await RunFetcherByNameAsync("BCR", stoppingToken);
+ _lastDailyRun["BCR"] = nowInArgentina.Date;
}
- // Tarea 3: Mercados de Bolsa (cada 10 minutos si el mercado está abierto)
- if (IsMarketOpen(now) && now.Minute % 10 == 0)
+ // --- Tarea 3 y 4: Mercados de Bolsa (L-V, durante horario de mercado, una vez por hora) ---
+ // Se ejecutan si el mercado está abierto y si el minuto actual es exactamente 10.
+ // Esto replica la lógica de "cada hora a las y 10".
+ if (IsArgentineMarketOpen(nowInArgentina) && nowInArgentina.Minute == 10)
{
- _logger.LogInformation("Mercados abiertos. Ejecutando fetchers de bolsa.");
- await RunFetcherByNameAsync("Finnhub");
- await RunFetcherByNameAsync("YahooFinance");
+ _logger.LogInformation("Hora de actualización de mercados de bolsa. Ejecutando fetchers...");
+
+ await RunFetcherByNameAsync("YahooFinance", stoppingToken);
+ // Si Finnhub está desactivado en Program.cs, este simplemente no se encontrará y se omitirá.
+ await RunFetcherByNameAsync("Finnhub", stoppingToken);
}
}
- // Esta función crea un "scope" para ejecutar un fetcher específico.
- // Esto es crucial para que la inyección de dependencias funcione correctamente.
- private async Task RunFetcherByNameAsync(string sourceName)
+ ///
+ /// Ejecuta un fetcher específico por su nombre. Utiliza un scope de DI para gestionar
+ /// correctamente el ciclo de vida de los servicios (como las conexiones a la BD).
+ ///
+ private async Task RunFetcherByNameAsync(string sourceName, CancellationToken stoppingToken)
{
+ if (stoppingToken.IsCancellationRequested) return;
+
_logger.LogInformation("Intentando ejecutar fetcher: {sourceName}", sourceName);
+ // Crea un "scope" de servicios. Todos los servicios "scoped" (como los repositorios)
+ // se crearán de nuevo para esta ejecución y se desecharán al final, evitando problemas.
using var scope = _serviceProvider.CreateScope();
var fetchers = scope.ServiceProvider.GetRequiredService>();
var fetcher = fetchers.FirstOrDefault(f => f.SourceName.Equals(sourceName, StringComparison.OrdinalIgnoreCase));
@@ -84,32 +123,42 @@ namespace Mercados.Worker
}
}
- // Función de ayuda para ejecutar todos los fetchers (usada al inicio).
- private async Task RunAllFetchersAsync()
+ ///
+ /// Ejecuta todos los fetchers al iniciar el servicio. Esto es útil para poblar
+ /// la base de datos inmediatamente al arrancar el worker.
+ ///
+ /*
+ private async Task RunAllFetchersAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Ejecutando todos los fetchers al iniciar...");
using var scope = _serviceProvider.CreateScope();
var fetchers = scope.ServiceProvider.GetRequiredService>();
foreach (var fetcher in fetchers)
{
- await RunFetcherByNameAsync(fetcher.SourceName);
+ if (stoppingToken.IsCancellationRequested) break;
+ await RunFetcherByNameAsync(fetcher.SourceName, stoppingToken);
}
}
+ */
#region Funciones de Ayuda para la Planificación
private bool HasNotRunToday(string taskName)
{
- return !_lastDailyRun.ContainsKey(taskName) || _lastDailyRun[taskName].Date < DateTime.Now.Date;
+ // Comprueba si la tarea ya se ejecutó en la fecha actual (en zona horaria de Argentina).
+ return !_lastDailyRun.ContainsKey(taskName) || _lastDailyRun[taskName].Date < TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _argentinaTimeZone).Date;
}
- private bool IsMarketOpen(DateTime now)
+ private bool IsWeekDay(DateTime now)
{
- // Lunes a Viernes (1 a 5, Domingo es 0)
- if (now.DayOfWeek == DayOfWeek.Saturday || now.DayOfWeek == DayOfWeek.Sunday)
- return false;
+ return now.DayOfWeek >= DayOfWeek.Monday && now.DayOfWeek <= DayOfWeek.Friday;
+ }
+
+ private bool IsArgentineMarketOpen(DateTime now)
+ {
+ if (!IsWeekDay(now)) return false;
- // Horario de mercado de 11:00 a 17:15 (hora de Argentina)
+ // Rango de 11:00 a 17:15, para asegurar la captura del cierre a las 17:10.
var marketOpen = new TimeSpan(11, 0, 0);
var marketClose = new TimeSpan(17, 15, 0);
diff --git a/src/Mercados.Worker/Mercados.Worker.csproj b/src/Mercados.Worker/Mercados.Worker.csproj
index 5648ea4..25c5b8d 100644
--- a/src/Mercados.Worker/Mercados.Worker.csproj
+++ b/src/Mercados.Worker/Mercados.Worker.csproj
@@ -8,6 +8,7 @@
+
diff --git a/src/Mercados.Worker/Program.cs b/src/Mercados.Worker/Program.cs
index 4e5af0a..745e9d8 100644
--- a/src/Mercados.Worker/Program.cs
+++ b/src/Mercados.Worker/Program.cs
@@ -5,6 +5,9 @@ using Mercados.Infrastructure.Persistence;
using Mercados.Infrastructure.Persistence.Repositories;
using Mercados.Worker;
+// Carga las variables de entorno desde el archivo .env en la raíz de la solución.
+DotNetEnv.Env.Load();
+
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
// --- Configuración del Host ---
// Esto prepara el host del servicio, permitiendo la inyección de dependencias,
diff --git a/src/Mercados.Worker/appsettings.json b/src/Mercados.Worker/appsettings.json
index 5644844..6c56bd7 100644
--- a/src/Mercados.Worker/appsettings.json
+++ b/src/Mercados.Worker/appsettings.json
@@ -7,13 +7,13 @@
},
"AllowedHosts": "*",
"ConnectionStrings": {
- "DefaultConnection": "Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;"
+ "DefaultConnection": ""
},
"ApiKeys": {
- "Finnhub": "cuvhr0hr01qs9e81st2gcuvhr0hr01qs9e81st30",
+ "Finnhub": "",
"Bcr": {
- "Key": "D1782A51-A5FD-EF11-9445-00155D09E201",
- "Secret": "da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3"
+ "Key": "",
+ "Secret": ""
}
}
}
\ No newline at end of file