Spaces:
Sleeping
Sleeping
Jose Salazar commited on
Commit Β·
af1e914
1
Parent(s): e9d469d
Ajustes en distribucion de mobile y desktop, validacion de contrast ratio de texto-fondos
Browse files- backend/src/signals/finnhub.client.js +74 -0
- frontend/index.html +5 -0
- frontend/src/app.js +15 -5
- frontend/src/style.css +103 -4
backend/src/signals/finnhub.client.js
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Cliente para la API de Finnhub β noticias financieras.
|
| 3 |
+
*
|
| 4 |
+
* Exporta:
|
| 5 |
+
* - fetchFinancialNews(limit): obtiene headlines de Finnhub.
|
| 6 |
+
* - filterNewsByRelevance(news, question): filtra noticias por relevancia
|
| 7 |
+
* con el texto de la pregunta del mercado.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
import { config } from '../config.js';
|
| 11 |
+
import { logger } from '../utils/logger.js';
|
| 12 |
+
|
| 13 |
+
const FINNHUB_BASE = 'https://finnhub.io/api/v1';
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* Obtiene noticias financieras generales de Finnhub.
|
| 17 |
+
* @param {number} limit - MΓ‘ximo de noticias a devolver.
|
| 18 |
+
* @returns {Promise<Array<{headline:string, source:string, url:string, summary:string, datetime:number}>>}
|
| 19 |
+
*/
|
| 20 |
+
export async function fetchFinancialNews(limit = 30) {
|
| 21 |
+
if (!config.FINNHUB_API_KEY) {
|
| 22 |
+
logger.warn('FINNHUB_API_KEY not set; skipping news fetch');
|
| 23 |
+
return [];
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const url = `${FINNHUB_BASE}/news?category=general&token=${config.FINNHUB_API_KEY}`;
|
| 27 |
+
const res = await fetch(url);
|
| 28 |
+
if (!res.ok) {
|
| 29 |
+
throw new Error(`Finnhub returned ${res.status}`);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const data = await res.json();
|
| 33 |
+
if (!Array.isArray(data)) {
|
| 34 |
+
return [];
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// Finnhub devuelve: { category, datetime, headline, id, image, related, source, summary, url }
|
| 38 |
+
return data.slice(0, limit).map((item) => ({
|
| 39 |
+
headline: item.headline || '',
|
| 40 |
+
source: item.source || '',
|
| 41 |
+
url: item.url || '',
|
| 42 |
+
summary: item.summary || '',
|
| 43 |
+
datetime: item.datetime || 0,
|
| 44 |
+
}));
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* Filtra noticias por relevancia respecto a la pregunta del mercado.
|
| 49 |
+
* Usa coincidencia de palabras clave extraΓdas de la pregunta.
|
| 50 |
+
* @param {Array} news - Lista de noticias.
|
| 51 |
+
* @param {string} question - Pregunta del mercado.
|
| 52 |
+
* @returns {Array} Noticias relevantes.
|
| 53 |
+
*/
|
| 54 |
+
export function filterNewsByRelevance(news, question) {
|
| 55 |
+
if (!news || !news.length || !question) return [];
|
| 56 |
+
|
| 57 |
+
// Extrae palabras clave: elimina stopwords en espaΓ±ol/inglΓ©s y puntuaciΓ³n
|
| 58 |
+
const stopwords = new Set([
|
| 59 |
+
'a','an','the','and','or','but','in','on','at','to','for','of','with','by','is','are','was','were','be','been','being','have','has','had','do','does','did','will','would','could','should','may','might','can','shall','this','that','these','those','i','you','he','she','it','we','they','me','him','her','us','them','my','your','his','its','our','their','mine','yours','hers','ours','theirs','el','la','los','las','un','una','unos','unas','y','o','pero','en','de','con','por','para','es','son','fue','fueron','ser','estar','ha','han','habΓa','hace','hizo','hacer','este','ese','esta','esa','estos','esas','aquellos','aquel','aquella','yo','tΓΊ','Γ©l','ella','nosotros','vosotros','ellos','ellas','me','te','le','les','nos','os','mi','tu','su','nuestro','vuestra','suyo',
|
| 60 |
+
]);
|
| 61 |
+
|
| 62 |
+
const keywords = question
|
| 63 |
+
.toLowerCase()
|
| 64 |
+
.replace(/[^a-zÑéΓΓ³ΓΊΓΌΓ±0-9\s]/g, ' ')
|
| 65 |
+
.split(/\s+/)
|
| 66 |
+
.filter((w) => w.length > 2 && !stopwords.has(w));
|
| 67 |
+
|
| 68 |
+
if (!keywords.length) return news.slice(0, 5);
|
| 69 |
+
|
| 70 |
+
return news.filter((item) => {
|
| 71 |
+
const text = `${item.headline} ${item.summary}`.toLowerCase();
|
| 72 |
+
return keywords.some((kw) => text.includes(kw));
|
| 73 |
+
});
|
| 74 |
+
}
|
frontend/index.html
CHANGED
|
@@ -122,6 +122,11 @@
|
|
| 122 |
Mapa global de predicciones
|
| 123 |
<span class="panel-subtitle">Β· tamaΓ±o = volumen Β· color = seΓ±al IA</span>
|
| 124 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
<span class="panel-toggle">βΌ</span>
|
| 126 |
</div>
|
| 127 |
<div class="panel-body">
|
|
|
|
| 122 |
Mapa global de predicciones
|
| 123 |
<span class="panel-subtitle">Β· tamaΓ±o = volumen Β· color = seΓ±al IA</span>
|
| 124 |
</div>
|
| 125 |
+
<div class="map-legend desktop-only">
|
| 126 |
+
<div class="legend-item"><div class="legend-dot green"></div>alcista</div>
|
| 127 |
+
<div class="legend-item"><div class="legend-dot red"></div>bajista</div>
|
| 128 |
+
<div class="legend-item"><div class="legend-dot gray"></div>neutral</div>
|
| 129 |
+
</div>
|
| 130 |
<span class="panel-toggle">βΌ</span>
|
| 131 |
</div>
|
| 132 |
<div class="panel-body">
|
frontend/src/app.js
CHANGED
|
@@ -498,9 +498,8 @@ function buildDetailDOM(m, sig, prefix = '') {
|
|
| 498 |
// ββ AI box ββ
|
| 499 |
const aiBadge = el('span', `signal-badge ${getSignalBadgeClass(sig.signal)}`)
|
| 500 |
aiBadge.textContent = `${translateSignal(sig.signal).toUpperCase()} Β· ${Math.round(sig.confidence * 100)}%`
|
| 501 |
-
const
|
| 502 |
-
|
| 503 |
-
aiHeader.append(el('div', 'ai-label', 'AnΓ‘lisis IA Β· HuggingFace Qwen3-8B'), aiBadge, aiMeta)
|
| 504 |
|
| 505 |
// Texto IA construido con nodos DOM β ninguna cadena externa toca innerHTML
|
| 506 |
const aiText = el('div', 'ai-text')
|
|
@@ -532,7 +531,6 @@ function buildDetailDOM(m, sig, prefix = '') {
|
|
| 532 |
simAmount,
|
| 533 |
simYes,
|
| 534 |
simNo,
|
| 535 |
-
el('span', 'sim-disclaimer', 'Simulado Β· sin trading real'),
|
| 536 |
)
|
| 537 |
|
| 538 |
simYes.addEventListener('click', () => simulator.openPosition(m.id, 'YES', simAmount.value))
|
|
@@ -606,11 +604,23 @@ function selectMarket(marketId) {
|
|
| 606 |
wrapper.appendChild(content)
|
| 607 |
card.after(wrapper)
|
| 608 |
|
| 609 |
-
// Charts require the canvas to be in the DOM before rendering
|
|
|
|
| 610 |
requestAnimationFrame(() => {
|
| 611 |
charts.renderDetailChart(chartId, m.yesPrice)
|
| 612 |
charts.renderSparkline(sparkYesId, m.yesPrice, 'yes')
|
| 613 |
charts.renderSparkline(sparkNoId, m.noPrice, 'no')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
})
|
| 615 |
} else {
|
| 616 |
state.activeMarketId = marketId
|
|
|
|
| 498 |
// ββ AI box ββ
|
| 499 |
const aiBadge = el('span', `signal-badge ${getSignalBadgeClass(sig.signal)}`)
|
| 500 |
aiBadge.textContent = `${translateSignal(sig.signal).toUpperCase()} Β· ${Math.round(sig.confidence * 100)}%`
|
| 501 |
+
const aiHeader = el('div', 'flex-between mb-4')
|
| 502 |
+
aiHeader.append(el('div', 'ai-label', 'AnΓ‘lisis IA'), aiBadge)
|
|
|
|
| 503 |
|
| 504 |
// Texto IA construido con nodos DOM β ninguna cadena externa toca innerHTML
|
| 505 |
const aiText = el('div', 'ai-text')
|
|
|
|
| 531 |
simAmount,
|
| 532 |
simYes,
|
| 533 |
simNo,
|
|
|
|
| 534 |
)
|
| 535 |
|
| 536 |
simYes.addEventListener('click', () => simulator.openPosition(m.id, 'YES', simAmount.value))
|
|
|
|
| 604 |
wrapper.appendChild(content)
|
| 605 |
card.after(wrapper)
|
| 606 |
|
| 607 |
+
// Charts require the canvas to be in the DOM before rendering.
|
| 608 |
+
// Scroll after paint so the layout height is settled.
|
| 609 |
requestAnimationFrame(() => {
|
| 610 |
charts.renderDetailChart(chartId, m.yesPrice)
|
| 611 |
charts.renderSparkline(sparkYesId, m.yesPrice, 'yes')
|
| 612 |
charts.renderSparkline(sparkNoId, m.noPrice, 'no')
|
| 613 |
+
|
| 614 |
+
// Scroll .main so the detail wrapper's top sits just below the sticky signals header,
|
| 615 |
+
// pushing the clicked card out of the viewport
|
| 616 |
+
const main = document.querySelector('.main')
|
| 617 |
+
const stickyHeader = document.querySelector('#panel-signals .panel-header')
|
| 618 |
+
if (main) {
|
| 619 |
+
const stickyH = stickyHeader ? stickyHeader.offsetHeight : 0
|
| 620 |
+
const mainRect = main.getBoundingClientRect()
|
| 621 |
+
const wrapperTop = wrapper.getBoundingClientRect().top - mainRect.top + main.scrollTop - stickyH
|
| 622 |
+
main.scrollTo({ top: wrapperTop, behavior: 'smooth' })
|
| 623 |
+
}
|
| 624 |
})
|
| 625 |
} else {
|
| 626 |
state.activeMarketId = marketId
|
frontend/src/style.css
CHANGED
|
@@ -9,7 +9,7 @@
|
|
| 9 |
--border2: rgba(255,255,255,0.13);
|
| 10 |
--text: #e8eaf0;
|
| 11 |
--text2: #8b90a0;
|
| 12 |
-
--text3: #
|
| 13 |
--green: #22d37a;
|
| 14 |
--green2: #0d6e3a;
|
| 15 |
--green3: #052a17;
|
|
@@ -366,7 +366,7 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
|
|
| 366 |
/* βββ Dashboard layout βββ */
|
| 367 |
.dashboard-grid {
|
| 368 |
display: grid;
|
| 369 |
-
grid-template-columns: 1fr
|
| 370 |
grid-template-rows: 1fr minmax(280px, 40%);
|
| 371 |
gap: var(--panel-gap);
|
| 372 |
height: 100%;
|
|
@@ -456,6 +456,26 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
|
|
| 456 |
.desktop-only { display: initial; }
|
| 457 |
.mobile-only { display: none; }
|
| 458 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
/* Panel mapa */
|
| 460 |
.map-panel {
|
| 461 |
grid-row: 1 / 2;
|
|
@@ -742,6 +762,7 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
|
|
| 742 |
gap: 16px;
|
| 743 |
align-items: center;
|
| 744 |
flex-wrap: wrap;
|
|
|
|
| 745 |
}
|
| 746 |
|
| 747 |
.sim-label {
|
|
@@ -1183,7 +1204,7 @@ td {
|
|
| 1183 |
}
|
| 1184 |
|
| 1185 |
.modal-submit {
|
| 1186 |
-
background:
|
| 1187 |
border: none;
|
| 1188 |
color: #fff;
|
| 1189 |
font-family: var(--font-sans);
|
|
@@ -1419,6 +1440,12 @@ td {
|
|
| 1419 |
flex: 1;
|
| 1420 |
}
|
| 1421 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1422 |
/* βββ Lazy loading sentinel βββ */
|
| 1423 |
.signals-sentinel {
|
| 1424 |
padding: 12px;
|
|
@@ -1456,7 +1483,67 @@ td {
|
|
| 1456 |
grid-template-rows: auto 1fr;
|
| 1457 |
}
|
| 1458 |
.sidebar { display: none; }
|
| 1459 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1460 |
|
| 1461 |
.desktop-only { display: none !important; }
|
| 1462 |
.mobile-only { display: initial; }
|
|
@@ -1534,6 +1621,17 @@ td {
|
|
| 1534 |
display: none;
|
| 1535 |
}
|
| 1536 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1537 |
/* Dashboard grid scrolls instead of stretching to fill viewport */
|
| 1538 |
.dashboard-grid {
|
| 1539 |
height: auto;
|
|
@@ -1560,6 +1658,7 @@ td {
|
|
| 1560 |
border-color: var(--blue2);
|
| 1561 |
}
|
| 1562 |
|
|
|
|
| 1563 |
#btn-auth-mobile {
|
| 1564 |
display: flex;
|
| 1565 |
}
|
|
|
|
| 9 |
--border2: rgba(255,255,255,0.13);
|
| 10 |
--text: #e8eaf0;
|
| 11 |
--text2: #8b90a0;
|
| 12 |
+
--text3: #7e8494;
|
| 13 |
--green: #22d37a;
|
| 14 |
--green2: #0d6e3a;
|
| 15 |
--green3: #052a17;
|
|
|
|
| 366 |
/* βββ Dashboard layout βββ */
|
| 367 |
.dashboard-grid {
|
| 368 |
display: grid;
|
| 369 |
+
grid-template-columns: 1fr 336px;
|
| 370 |
grid-template-rows: 1fr minmax(280px, 40%);
|
| 371 |
gap: var(--panel-gap);
|
| 372 |
height: 100%;
|
|
|
|
| 456 |
.desktop-only { display: initial; }
|
| 457 |
.mobile-only { display: none; }
|
| 458 |
|
| 459 |
+
/* ID-level rules beat class rules β ensure circle buttons and auth dot stay off in desktop */
|
| 460 |
+
#btn-notif,
|
| 461 |
+
#btn-auth-mobile {
|
| 462 |
+
display: none;
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
/* Hide topbar legend in all views β desktop uses the map panel legend instead */
|
| 466 |
+
.topbar-stats .legend {
|
| 467 |
+
display: none;
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
/* Map panel legend (desktop only) */
|
| 471 |
+
.map-legend {
|
| 472 |
+
display: flex;
|
| 473 |
+
align-items: center;
|
| 474 |
+
gap: 12px;
|
| 475 |
+
margin-left: auto;
|
| 476 |
+
margin-right: 8px;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
/* Panel mapa */
|
| 480 |
.map-panel {
|
| 481 |
grid-row: 1 / 2;
|
|
|
|
| 762 |
gap: 16px;
|
| 763 |
align-items: center;
|
| 764 |
flex-wrap: wrap;
|
| 765 |
+
justify-content: flex-end;
|
| 766 |
}
|
| 767 |
|
| 768 |
.sim-label {
|
|
|
|
| 1204 |
}
|
| 1205 |
|
| 1206 |
.modal-submit {
|
| 1207 |
+
background: #2563eb;
|
| 1208 |
border: none;
|
| 1209 |
color: #fff;
|
| 1210 |
font-family: var(--font-sans);
|
|
|
|
| 1440 |
flex: 1;
|
| 1441 |
}
|
| 1442 |
|
| 1443 |
+
/* Duplicate stats are only needed for the mobile infinite-scroll animation */
|
| 1444 |
+
.stats-track .legend.end ~ .stat,
|
| 1445 |
+
.stats-track .legend.end ~ .legend {
|
| 1446 |
+
display: none;
|
| 1447 |
+
}
|
| 1448 |
+
|
| 1449 |
/* βββ Lazy loading sentinel βββ */
|
| 1450 |
.signals-sentinel {
|
| 1451 |
padding: 12px;
|
|
|
|
| 1483 |
grid-template-rows: auto 1fr;
|
| 1484 |
}
|
| 1485 |
.sidebar { display: none; }
|
| 1486 |
+
|
| 1487 |
+
/* ββ Detail: header stacks, metrics row below ββ */
|
| 1488 |
+
.detail-header {
|
| 1489 |
+
flex-direction: column;
|
| 1490 |
+
gap: 8px;
|
| 1491 |
+
margin-bottom: 8px;
|
| 1492 |
+
}
|
| 1493 |
+
|
| 1494 |
+
.detail-metrics {
|
| 1495 |
+
width: 100%;
|
| 1496 |
+
justify-content: flex-start;
|
| 1497 |
+
}
|
| 1498 |
+
|
| 1499 |
+
.metric {
|
| 1500 |
+
text-align: left;
|
| 1501 |
+
}
|
| 1502 |
+
|
| 1503 |
+
/* ββ Outcomes: Yes/No side-by-side, chart full-width below ββ */
|
| 1504 |
+
.outcomes-row {
|
| 1505 |
+
display: grid;
|
| 1506 |
+
grid-template-columns: 1fr 1fr;
|
| 1507 |
+
gap: 10px;
|
| 1508 |
+
margin-bottom: 10px;
|
| 1509 |
+
}
|
| 1510 |
+
|
| 1511 |
+
.outcomes-row .chart-container {
|
| 1512 |
+
grid-column: 1 / -1;
|
| 1513 |
+
height: 130px;
|
| 1514 |
+
max-height: 130px;
|
| 1515 |
+
min-height: 0;
|
| 1516 |
+
}
|
| 1517 |
+
|
| 1518 |
+
/* ββ Simulator: label full-width, input+buttons in one row, disclaimer below ββ */
|
| 1519 |
+
.sim-row {
|
| 1520 |
+
display: grid;
|
| 1521 |
+
grid-template-columns: 1fr auto auto;
|
| 1522 |
+
gap: 8px;
|
| 1523 |
+
align-items: center;
|
| 1524 |
+
}
|
| 1525 |
+
|
| 1526 |
+
.sim-row .sim-label {
|
| 1527 |
+
grid-column: 1 / -1;
|
| 1528 |
+
font-size: 0.875rem;
|
| 1529 |
+
}
|
| 1530 |
+
|
| 1531 |
+
.sim-row .sim-input {
|
| 1532 |
+
width: 100%;
|
| 1533 |
+
min-width: 0;
|
| 1534 |
+
}
|
| 1535 |
+
|
| 1536 |
+
.sim-row .sim-btn-yes,
|
| 1537 |
+
.sim-row .sim-btn-no {
|
| 1538 |
+
padding: 8px 12px;
|
| 1539 |
+
font-size: 0.875rem;
|
| 1540 |
+
white-space: nowrap;
|
| 1541 |
+
}
|
| 1542 |
+
|
| 1543 |
+
.sim-row .sim-disclaimer {
|
| 1544 |
+
grid-column: 1 / -1;
|
| 1545 |
+
font-size: 0.75rem;
|
| 1546 |
+
}
|
| 1547 |
|
| 1548 |
.desktop-only { display: none !important; }
|
| 1549 |
.mobile-only { display: initial; }
|
|
|
|
| 1621 |
display: none;
|
| 1622 |
}
|
| 1623 |
|
| 1624 |
+
/* Restore duplicate stats (and their legend) for the scroll animation */
|
| 1625 |
+
.stats-track .legend.end ~ .stat,
|
| 1626 |
+
.stats-track .legend.end ~ .legend {
|
| 1627 |
+
display: flex;
|
| 1628 |
+
}
|
| 1629 |
+
|
| 1630 |
+
/* Restore the primary legend in the scroll track */
|
| 1631 |
+
.topbar-stats .legend.end {
|
| 1632 |
+
display: flex;
|
| 1633 |
+
}
|
| 1634 |
+
|
| 1635 |
/* Dashboard grid scrolls instead of stretching to fill viewport */
|
| 1636 |
.dashboard-grid {
|
| 1637 |
height: auto;
|
|
|
|
| 1658 |
border-color: var(--blue2);
|
| 1659 |
}
|
| 1660 |
|
| 1661 |
+
#btn-notif,
|
| 1662 |
#btn-auth-mobile {
|
| 1663 |
display: flex;
|
| 1664 |
}
|