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 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 aiMeta = el('span', 'text-xs text-neutral font-mono ml-auto', 'actualizado hace 2m')
502
- const aiHeader = el('div', 'flex-row gap-8 mb-4 flex-wrap')
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: #555b6e;
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 280px;
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: var(--blue);
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
- .outcomes-row { flex-direction: column; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  }