Brajmovech commited on
Commit
4e76482
·
1 Parent(s): 8a4e2a4

feat: add Recommended For You section with related stock tickers

Browse files

- Add /api/related/<ticker> backend endpoint using yfinance sector data
- Add horizontal scrollable recommendation cards below price chart
- Each card shows ticker, company name, sparkline chart, price and change
- Cards are clickable to trigger full risk analysis on the selected ticker
- Includes loading skeleton, scroll navigation, and light/dark theme support

Files changed (4) hide show
  1. app.py +96 -0
  2. static/app.js +192 -0
  3. static/style.css +309 -1
  4. templates/index.html +19 -2
app.py CHANGED
@@ -129,6 +129,34 @@ TIMEFRAME_TO_YFINANCE = {
129
  "5Y": ("5y", "1wk"),
130
  }
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  def _report_matches_symbol(report: dict, target: str) -> bool:
133
  target_upper = target.upper()
134
  meta = report.get("meta") or {}
@@ -484,6 +512,74 @@ def get_history(ticker):
484
  print(f"Error fetching chart history for {symbol}: {traceback.format_exc()}")
485
  return jsonify({"error": "An internal error occurred while fetching chart history."}), 500
486
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
  @app.route('/api/analyze', methods=['GET'])
488
  def analyze_ticker():
489
  """API endpoint to analyze a specific ticker."""
 
129
  "5Y": ("5y", "1wk"),
130
  }
131
 
132
+ SECTOR_PEERS = {
133
+ "Technology": ["AAPL", "MSFT", "GOOG", "NVDA", "META", "AMZN", "CRM", "ADBE", "INTC", "AMD", "AVGO", "ORCL", "CSCO", "IBM", "TSLA"],
134
+ "Financial Services": ["JPM", "BAC", "WFC", "GS", "MS", "BLK", "SCHW", "AXP", "V", "MA"],
135
+ "Healthcare": ["JNJ", "UNH", "PFE", "ABBV", "MRK", "LLY", "TMO", "ABT", "BMY", "AMGN"],
136
+ "Consumer Cyclical": ["AMZN", "TSLA", "HD", "NKE", "MCD", "SBUX", "TGT", "LOW", "BKNG", "CMG"],
137
+ "Communication Services": ["GOOG", "META", "NFLX", "DIS", "CMCSA", "T", "VZ", "TMUS", "SNAP", "PINS"],
138
+ "Energy": ["XOM", "CVX", "COP", "SLB", "EOG", "MPC", "PSX", "VLO", "OXY", "DVN"],
139
+ "Consumer Defensive": ["PG", "KO", "PEP", "WMT", "COST", "PM", "MO", "CL", "MDLZ", "GIS"],
140
+ "Industrials": ["CAT", "BA", "HON", "UPS", "RTX", "DE", "LMT", "GE", "MMM", "UNP"],
141
+ "Real Estate": ["AMT", "PLD", "CCI", "EQIX", "SPG", "PSA", "O", "WELL", "DLR", "AVB"],
142
+ "Utilities": ["NEE", "DUK", "SO", "D", "AEP", "SRE", "EXC", "XEL", "ED", "WEC"],
143
+ "Basic Materials": ["LIN", "APD", "SHW", "ECL", "FCX", "NEM", "DOW", "NUE", "VMC", "MLM"],
144
+ }
145
+
146
+
147
+ def _get_related_tickers(ticker, count=7):
148
+ """Return a list of related tickers based on the sector of the given ticker."""
149
+ fallback = ["AAPL", "MSFT", "GOOG", "AMZN", "NVDA", "META", "TSLA"]
150
+ try:
151
+ info = yf.Ticker(ticker).info
152
+ sector = info.get("sector", "")
153
+ peers = SECTOR_PEERS.get(sector, fallback)
154
+ related = [s for s in peers if s != ticker]
155
+ return related[:count]
156
+ except Exception:
157
+ return [s for s in fallback if s != ticker][:count]
158
+
159
+
160
  def _report_matches_symbol(report: dict, target: str) -> bool:
161
  target_upper = target.upper()
162
  meta = report.get("meta") or {}
 
512
  print(f"Error fetching chart history for {symbol}: {traceback.format_exc()}")
513
  return jsonify({"error": "An internal error occurred while fetching chart history."}), 500
514
 
515
+
516
+ @app.route('/api/related/<ticker>', methods=['GET'])
517
+ def get_related(ticker):
518
+ """Return related stock tickers with mini price data for a Recommended for you section."""
519
+ symbol = str(ticker or "").strip().upper()
520
+ if not symbol:
521
+ return jsonify({"error": "Ticker parameter is required"}), 400
522
+
523
+ print(f"API Request for Related Tickers: {symbol}")
524
+
525
+ def _normalize_related_frame(frame, sym):
526
+ if frame is None or frame.empty:
527
+ return frame
528
+ if isinstance(frame.columns, pd.MultiIndex):
529
+ try:
530
+ if sym in frame.columns.get_level_values(-1):
531
+ frame = frame.xs(sym, axis=1, level=-1, drop_level=True)
532
+ else:
533
+ frame.columns = [str(col[0]) for col in frame.columns]
534
+ except Exception:
535
+ frame.columns = [str(col[0]) if isinstance(col, tuple) else str(col) for col in frame.columns]
536
+ return frame
537
+
538
+ try:
539
+ related_symbols = _get_related_tickers(symbol)
540
+ results = []
541
+ for sym in related_symbols:
542
+ try:
543
+ frame = yf.download(
544
+ sym,
545
+ period="5d",
546
+ interval="1d",
547
+ progress=False,
548
+ auto_adjust=False,
549
+ actions=False,
550
+ threads=False,
551
+ )
552
+ frame = _normalize_related_frame(frame, sym)
553
+ if frame is None or frame.empty or "Close" not in frame.columns:
554
+ continue
555
+ close_series = pd.to_numeric(frame["Close"], errors="coerce")
556
+ closes = [float(x) for x in close_series if np.isfinite(x)]
557
+ if len(closes) < 2:
558
+ continue
559
+ current_price = closes[-1]
560
+ previous_close = closes[-2]
561
+ price_change = current_price - previous_close
562
+ price_change_pct = (price_change / previous_close * 100) if previous_close else 0.0
563
+ try:
564
+ name = yf.Ticker(sym).info.get("shortName", sym)
565
+ except Exception:
566
+ name = sym
567
+ results.append({
568
+ "symbol": sym,
569
+ "name": name,
570
+ "current_price": round(current_price, 2),
571
+ "price_change": round(price_change, 2),
572
+ "price_change_pct": round(price_change_pct, 4),
573
+ "sparkline": closes,
574
+ })
575
+ except Exception:
576
+ continue
577
+ return jsonify({"ticker": symbol, "related": results})
578
+ except Exception:
579
+ print(f"Error in /api/related/{symbol}: {traceback.format_exc()}")
580
+ return jsonify({"error": "An internal error occurred"}), 500
581
+
582
+
583
  @app.route('/api/analyze', methods=['GET'])
584
  def analyze_ticker():
585
  """API endpoint to analyze a specific ticker."""
static/app.js CHANGED
@@ -766,6 +766,8 @@ document.addEventListener('DOMContentLoaded', () => {
766
  _showSkeleton();
767
  if (!keepDashboardVisible) {
768
  dashboard.classList.add('hidden');
 
 
769
  }
770
 
771
  try {
@@ -817,6 +819,7 @@ document.addEventListener('DOMContentLoaded', () => {
817
  latestAnalyzeTimeframe = getActiveTimeframe();
818
  updateDashboard(data);
819
  await refreshChartForTimeframe(currentTicker, getActiveTimeframe(), false);
 
820
 
821
  } catch (error) {
822
  clearTimeout(timeoutId);
@@ -1548,3 +1551,192 @@ document.addEventListener('DOMContentLoaded', () => {
1548
  }
1549
  }
1550
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
766
  _showSkeleton();
767
  if (!keepDashboardVisible) {
768
  dashboard.classList.add('hidden');
769
+ var recSec = document.getElementById('recommended-section');
770
+ if (recSec) recSec.classList.add('hidden');
771
  }
772
 
773
  try {
 
819
  latestAnalyzeTimeframe = getActiveTimeframe();
820
  updateDashboard(data);
821
  await refreshChartForTimeframe(currentTicker, getActiveTimeframe(), false);
822
+ if (typeof window._irisLoadRecommendations === 'function') { window._irisLoadRecommendations(currentTicker); }
823
 
824
  } catch (error) {
825
  clearTimeout(timeoutId);
 
1551
  }
1552
  }
1553
  });
1554
+
1555
+ /* ─────────────────────────────────────────────────────
1556
+ Recommended For You – Stock Recommendations
1557
+ ───────────────────────────────────────────────────── */
1558
+
1559
+ (function initRecommendations() {
1560
+ 'use strict';
1561
+
1562
+ const recSection = document.getElementById('recommended-section');
1563
+ const recScroll = document.getElementById('rec-scroll');
1564
+ const recSubtitle = document.getElementById('rec-subtitle');
1565
+ const recPrevBtn = document.getElementById('rec-prev');
1566
+ const recNextBtn = document.getElementById('rec-next');
1567
+
1568
+ if (!recSection || !recScroll) return;
1569
+
1570
+ function drawSparkline(canvas, dataPoints, isPositive) {
1571
+ if (!canvas || !dataPoints || dataPoints.length < 2) return;
1572
+ const ctx = canvas.getContext('2d');
1573
+ const dpr = window.devicePixelRatio || 1;
1574
+ const rect = canvas.getBoundingClientRect();
1575
+ if (rect.width === 0 || rect.height === 0) return;
1576
+
1577
+ canvas.width = rect.width * dpr;
1578
+ canvas.height = rect.height * dpr;
1579
+ canvas.style.width = rect.width + 'px';
1580
+ canvas.style.height = rect.height + 'px';
1581
+ ctx.scale(dpr, dpr);
1582
+
1583
+ const w = rect.width;
1584
+ const h = rect.height;
1585
+ const min = Math.min(...dataPoints);
1586
+ const max = Math.max(...dataPoints);
1587
+ const range = max - min || 1;
1588
+ const pad = 2;
1589
+
1590
+ const color = isPositive ? '#22c55e' : '#ef4444';
1591
+
1592
+ ctx.beginPath();
1593
+ dataPoints.forEach(function (v, i) {
1594
+ var x = (i / (dataPoints.length - 1)) * w;
1595
+ var y = pad + ((max - v) / range) * (h - pad * 2);
1596
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
1597
+ });
1598
+ ctx.lineTo(w, h);
1599
+ ctx.lineTo(0, h);
1600
+ ctx.closePath();
1601
+ var grad = ctx.createLinearGradient(0, 0, 0, h);
1602
+ grad.addColorStop(0, isPositive ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)');
1603
+ grad.addColorStop(1, 'rgba(0,0,0,0)');
1604
+ ctx.fillStyle = grad;
1605
+ ctx.fill();
1606
+
1607
+ ctx.beginPath();
1608
+ dataPoints.forEach(function (v, i) {
1609
+ var x = (i / (dataPoints.length - 1)) * w;
1610
+ var y = pad + ((max - v) / range) * (h - pad * 2);
1611
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
1612
+ });
1613
+ ctx.strokeStyle = color;
1614
+ ctx.lineWidth = 1.5;
1615
+ ctx.lineJoin = 'round';
1616
+ ctx.lineCap = 'round';
1617
+ ctx.stroke();
1618
+ }
1619
+
1620
+ function showRecSkeleton() {
1621
+ recScroll.innerHTML = '';
1622
+ for (var i = 0; i < 5; i++) {
1623
+ var skel = document.createElement('div');
1624
+ skel.className = 'rec-card-skeleton';
1625
+ skel.innerHTML =
1626
+ '<div class="rec-skel-line w60"></div>' +
1627
+ '<div class="rec-skel-line w40"></div>' +
1628
+ '<div class="rec-skel-block"></div>' +
1629
+ '<div class="rec-skel-price"></div>';
1630
+ recScroll.appendChild(skel);
1631
+ }
1632
+ recSection.classList.remove('hidden');
1633
+ }
1634
+
1635
+ function buildRecCard(item) {
1636
+ var pctVal = item.price_change_pct || 0;
1637
+ var isPos = pctVal > 0;
1638
+ var isNeg = pctVal < 0;
1639
+ var dirClass = isPos ? 'rec-positive' : isNeg ? 'rec-negative' : '';
1640
+ var badgeCls = isPos ? 'rec-badge-positive' : isNeg ? 'rec-badge-negative' : 'rec-badge-neutral';
1641
+ var chCls = isPos ? 'rec-ch-positive' : isNeg ? 'rec-ch-negative' : 'rec-ch-neutral';
1642
+ var sign = isPos ? '+' : '';
1643
+
1644
+ var card = document.createElement('div');
1645
+ card.className = 'rec-card ' + dirClass;
1646
+ card.setAttribute('role', 'button');
1647
+ card.setAttribute('tabindex', '0');
1648
+ card.setAttribute('aria-label', 'Analyze ' + item.symbol);
1649
+
1650
+ card.innerHTML =
1651
+ '<div class="rec-card-top">' +
1652
+ '<span class="rec-ticker">' + item.symbol + '</span>' +
1653
+ '<span class="rec-change-badge ' + badgeCls + '">' + sign + pctVal.toFixed(2) + '%</span>' +
1654
+ '</div>' +
1655
+ '<div class="rec-name">' + (item.name || item.symbol) + '</div>' +
1656
+ '<div class="rec-sparkline"><canvas></canvas></div>' +
1657
+ '<div class="rec-price-row">' +
1658
+ '<span class="rec-price">$' + (item.current_price || 0).toFixed(2) + '</span>' +
1659
+ '<span class="rec-price-change ' + chCls + '">' + sign + (item.price_change || 0).toFixed(2) + '</span>' +
1660
+ '</div>';
1661
+
1662
+ function triggerAnalysis() {
1663
+ var tickerInput = document.getElementById('ticker-input');
1664
+ var form = document.getElementById('analyze-form');
1665
+ if (tickerInput && form) {
1666
+ tickerInput.value = item.symbol;
1667
+ tickerInput.dispatchEvent(new Event('input', { bubbles: true }));
1668
+ setTimeout(function () {
1669
+ form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
1670
+ }, 150);
1671
+ }
1672
+ }
1673
+ card.addEventListener('click', triggerAnalysis);
1674
+ card.addEventListener('keydown', function (e) {
1675
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); triggerAnalysis(); }
1676
+ });
1677
+
1678
+ return card;
1679
+ }
1680
+
1681
+ function fetchRecommendations(ticker) {
1682
+ if (!ticker) return;
1683
+ showRecSkeleton();
1684
+ recSubtitle.textContent = 'Related stocks based on ' + ticker + '\'s sector';
1685
+
1686
+ fetch('/api/related/' + encodeURIComponent(ticker))
1687
+ .then(function (res) {
1688
+ if (!res.ok) throw new Error('HTTP ' + res.status);
1689
+ return res.json();
1690
+ })
1691
+ .then(function (data) {
1692
+ if (!data.related || data.related.length === 0) {
1693
+ recSection.classList.add('hidden');
1694
+ return;
1695
+ }
1696
+
1697
+ recScroll.innerHTML = '';
1698
+
1699
+ data.related.forEach(function (item) {
1700
+ var card = buildRecCard(item);
1701
+ recScroll.appendChild(card);
1702
+
1703
+ setTimeout(function () {
1704
+ var canvas = card.querySelector('.rec-sparkline canvas');
1705
+ if (canvas && item.sparkline && item.sparkline.length >= 2) {
1706
+ drawSparkline(canvas, item.sparkline, (item.price_change_pct || 0) >= 0);
1707
+ }
1708
+ }, 50);
1709
+ });
1710
+
1711
+ recSection.classList.remove('hidden');
1712
+ updateRecNav();
1713
+ })
1714
+ .catch(function (err) {
1715
+ console.warn('Recommendations fetch failed:', err);
1716
+ recSection.classList.add('hidden');
1717
+ });
1718
+ }
1719
+
1720
+ function updateRecNav() {
1721
+ if (!recPrevBtn || !recNextBtn) return;
1722
+ recPrevBtn.disabled = recScroll.scrollLeft <= 5;
1723
+ recNextBtn.disabled = recScroll.scrollLeft + recScroll.clientWidth >= recScroll.scrollWidth - 5;
1724
+ }
1725
+
1726
+ if (recPrevBtn) {
1727
+ recPrevBtn.addEventListener('click', function () {
1728
+ recScroll.scrollBy({ left: -200, behavior: 'smooth' });
1729
+ setTimeout(updateRecNav, 400);
1730
+ });
1731
+ }
1732
+ if (recNextBtn) {
1733
+ recNextBtn.addEventListener('click', function () {
1734
+ recScroll.scrollBy({ left: 200, behavior: 'smooth' });
1735
+ setTimeout(updateRecNav, 400);
1736
+ });
1737
+ }
1738
+ recScroll.addEventListener('scroll', updateRecNav);
1739
+
1740
+ window._irisLoadRecommendations = fetchRecommendations;
1741
+
1742
+ })();
static/style.css CHANGED
@@ -1622,4 +1622,312 @@ footer p {
1622
  white-space: nowrap;
1623
  overflow: hidden;
1624
  text-overflow: ellipsis;
1625
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1622
  white-space: nowrap;
1623
  overflow: hidden;
1624
  text-overflow: ellipsis;
1625
+ }
1626
+
1627
+ /* ─────────────────────────────────────────────────────
1628
+ Recommended For You – Stock Recommendations
1629
+ ───────────────────────────────────────────────────── */
1630
+
1631
+ .recommended-section {
1632
+ margin-top: 20px;
1633
+ margin-bottom: 24px;
1634
+ }
1635
+
1636
+ .recommended-header {
1637
+ display: flex;
1638
+ align-items: center;
1639
+ justify-content: space-between;
1640
+ margin-bottom: 14px;
1641
+ padding: 0 2px;
1642
+ }
1643
+
1644
+ .rec-title {
1645
+ font-size: 15px;
1646
+ font-weight: 600;
1647
+ color: var(--text-primary, #e8e8ec);
1648
+ letter-spacing: -0.2px;
1649
+ margin: 0;
1650
+ text-transform: none;
1651
+ }
1652
+
1653
+ .rec-subtitle {
1654
+ font-size: 12px;
1655
+ color: var(--text-muted, #5e5e72);
1656
+ font-weight: 400;
1657
+ display: block;
1658
+ margin-top: 2px;
1659
+ }
1660
+
1661
+ .rec-nav {
1662
+ display: flex;
1663
+ gap: 6px;
1664
+ }
1665
+
1666
+ .rec-nav-btn {
1667
+ width: 32px;
1668
+ height: 32px;
1669
+ border-radius: 50%;
1670
+ background: rgba(255, 255, 255, 0.04);
1671
+ border: 1px solid rgba(255, 255, 255, 0.08);
1672
+ color: var(--text-secondary, #9a9aad);
1673
+ cursor: pointer;
1674
+ display: flex;
1675
+ align-items: center;
1676
+ justify-content: center;
1677
+ font-size: 16px;
1678
+ line-height: 1;
1679
+ font-family: inherit;
1680
+ transition: all 0.15s ease;
1681
+ padding: 0;
1682
+ }
1683
+
1684
+ .rec-nav-btn:hover:not(:disabled) {
1685
+ background: rgba(255, 255, 255, 0.07);
1686
+ border-color: rgba(255, 255, 255, 0.14);
1687
+ color: var(--text-primary, #e8e8ec);
1688
+ }
1689
+
1690
+ .rec-nav-btn:disabled {
1691
+ opacity: 0.3;
1692
+ cursor: default;
1693
+ }
1694
+
1695
+ .rec-scroll-wrapper {
1696
+ position: relative;
1697
+ }
1698
+
1699
+ .rec-scroll {
1700
+ display: flex;
1701
+ gap: 12px;
1702
+ overflow-x: auto;
1703
+ scroll-behavior: smooth;
1704
+ scroll-snap-type: x mandatory;
1705
+ padding-bottom: 4px;
1706
+ -ms-overflow-style: none;
1707
+ scrollbar-width: none;
1708
+ }
1709
+
1710
+ .rec-scroll::-webkit-scrollbar {
1711
+ display: none;
1712
+ }
1713
+
1714
+ .rec-card {
1715
+ flex: 0 0 180px;
1716
+ min-width: 180px;
1717
+ scroll-snap-align: start;
1718
+ background: rgba(255, 255, 255, 0.03);
1719
+ border: 1px solid rgba(255, 255, 255, 0.06);
1720
+ border-radius: 12px;
1721
+ padding: 14px 16px 12px;
1722
+ cursor: pointer;
1723
+ transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
1724
+ position: relative;
1725
+ overflow: hidden;
1726
+ }
1727
+
1728
+ .rec-card::before {
1729
+ content: '';
1730
+ position: absolute;
1731
+ top: 0;
1732
+ left: 0;
1733
+ right: 0;
1734
+ height: 2px;
1735
+ background: transparent;
1736
+ transition: background 0.2s ease;
1737
+ }
1738
+
1739
+ .rec-card:hover {
1740
+ background: rgba(255, 255, 255, 0.06);
1741
+ border-color: rgba(255, 255, 255, 0.12);
1742
+ transform: translateY(-2px);
1743
+ }
1744
+
1745
+ .rec-card:hover::before {
1746
+ background: var(--accent, #3b82f6);
1747
+ }
1748
+
1749
+ .rec-card.rec-positive:hover::before {
1750
+ background: #22c55e;
1751
+ }
1752
+
1753
+ .rec-card.rec-negative:hover::before {
1754
+ background: #ef4444;
1755
+ }
1756
+
1757
+ .rec-card-top {
1758
+ display: flex;
1759
+ align-items: center;
1760
+ justify-content: space-between;
1761
+ margin-bottom: 2px;
1762
+ }
1763
+
1764
+ .rec-ticker {
1765
+ font-size: 14px;
1766
+ font-weight: 700;
1767
+ color: var(--text-primary, #e8e8ec);
1768
+ letter-spacing: -0.3px;
1769
+ }
1770
+
1771
+ .rec-change-badge {
1772
+ font-size: 10px;
1773
+ font-weight: 600;
1774
+ padding: 2px 7px;
1775
+ border-radius: 4px;
1776
+ letter-spacing: 0.2px;
1777
+ line-height: 1.4;
1778
+ }
1779
+
1780
+ .rec-change-badge.rec-badge-positive {
1781
+ background: rgba(34, 197, 94, 0.1);
1782
+ color: #22c55e;
1783
+ }
1784
+
1785
+ .rec-change-badge.rec-badge-negative {
1786
+ background: rgba(239, 68, 68, 0.1);
1787
+ color: #ef4444;
1788
+ }
1789
+
1790
+ .rec-change-badge.rec-badge-neutral {
1791
+ background: rgba(245, 158, 11, 0.1);
1792
+ color: #f59e0b;
1793
+ }
1794
+
1795
+ .rec-name {
1796
+ font-size: 11px;
1797
+ color: var(--text-muted, #5e5e72);
1798
+ margin-bottom: 10px;
1799
+ white-space: nowrap;
1800
+ overflow: hidden;
1801
+ text-overflow: ellipsis;
1802
+ }
1803
+
1804
+ .rec-sparkline {
1805
+ width: 100%;
1806
+ height: 40px;
1807
+ margin-bottom: 10px;
1808
+ position: relative;
1809
+ }
1810
+
1811
+ .rec-sparkline canvas {
1812
+ display: block;
1813
+ width: 100%;
1814
+ height: 100%;
1815
+ }
1816
+
1817
+ .rec-price-row {
1818
+ display: flex;
1819
+ align-items: baseline;
1820
+ justify-content: space-between;
1821
+ }
1822
+
1823
+ .rec-price {
1824
+ font-size: 15px;
1825
+ font-weight: 600;
1826
+ color: var(--text-primary, #e8e8ec);
1827
+ }
1828
+
1829
+ .rec-price-change {
1830
+ font-size: 11px;
1831
+ font-weight: 500;
1832
+ }
1833
+
1834
+ .rec-price-change.rec-ch-positive {
1835
+ color: #22c55e;
1836
+ }
1837
+
1838
+ .rec-price-change.rec-ch-negative {
1839
+ color: #ef4444;
1840
+ }
1841
+
1842
+ .rec-price-change.rec-ch-neutral {
1843
+ color: #f59e0b;
1844
+ }
1845
+
1846
+ .rec-card-skeleton {
1847
+ flex: 0 0 180px;
1848
+ min-width: 180px;
1849
+ background: rgba(255, 255, 255, 0.03);
1850
+ border: 1px solid rgba(255, 255, 255, 0.06);
1851
+ border-radius: 12px;
1852
+ padding: 14px 16px 12px;
1853
+ pointer-events: none;
1854
+ }
1855
+
1856
+ .rec-skel-line {
1857
+ height: 12px;
1858
+ border-radius: 4px;
1859
+ background: rgba(255, 255, 255, 0.05);
1860
+ animation: rec-skeleton-pulse 1.5s ease-in-out infinite;
1861
+ }
1862
+
1863
+ .rec-skel-line.w60 { width: 60%; }
1864
+ .rec-skel-line.w40 { width: 40%; margin-top: 6px; }
1865
+ .rec-skel-block {
1866
+ width: 100%;
1867
+ height: 40px;
1868
+ border-radius: 6px;
1869
+ margin: 10px 0;
1870
+ background: rgba(255, 255, 255, 0.03);
1871
+ animation: rec-skeleton-pulse 1.5s ease-in-out infinite;
1872
+ animation-delay: 0.3s;
1873
+ }
1874
+ .rec-skel-price {
1875
+ height: 14px;
1876
+ width: 50%;
1877
+ border-radius: 4px;
1878
+ background: rgba(255, 255, 255, 0.05);
1879
+ animation: rec-skeleton-pulse 1.5s ease-in-out infinite;
1880
+ animation-delay: 0.6s;
1881
+ }
1882
+
1883
+ @keyframes rec-skeleton-pulse {
1884
+ 0%, 100% { opacity: 1; }
1885
+ 50% { opacity: 0.4; }
1886
+ }
1887
+
1888
+ [data-theme="light"] .rec-card {
1889
+ background: rgba(0, 0, 0, 0.02);
1890
+ border-color: rgba(0, 0, 0, 0.08);
1891
+ }
1892
+
1893
+ [data-theme="light"] .rec-card:hover {
1894
+ background: rgba(0, 0, 0, 0.04);
1895
+ border-color: rgba(0, 0, 0, 0.12);
1896
+ }
1897
+
1898
+ [data-theme="light"] .rec-nav-btn {
1899
+ background: rgba(0, 0, 0, 0.03);
1900
+ border-color: rgba(0, 0, 0, 0.1);
1901
+ }
1902
+
1903
+ [data-theme="light"] .rec-nav-btn:hover:not(:disabled) {
1904
+ background: rgba(0, 0, 0, 0.06);
1905
+ }
1906
+
1907
+ [data-theme="light"] .rec-card-skeleton {
1908
+ background: rgba(0, 0, 0, 0.02);
1909
+ border-color: rgba(0, 0, 0, 0.08);
1910
+ }
1911
+
1912
+ [data-theme="light"] .rec-skel-line,
1913
+ [data-theme="light"] .rec-skel-price {
1914
+ background: rgba(0, 0, 0, 0.06);
1915
+ }
1916
+
1917
+ [data-theme="light"] .rec-skel-block {
1918
+ background: rgba(0, 0, 0, 0.03);
1919
+ }
1920
+
1921
+ @media (max-width: 640px) {
1922
+ .rec-card {
1923
+ flex: 0 0 156px;
1924
+ min-width: 156px;
1925
+ padding: 12px 14px 10px;
1926
+ }
1927
+ .rec-card-skeleton {
1928
+ flex: 0 0 156px;
1929
+ min-width: 156px;
1930
+ }
1931
+ .rec-ticker { font-size: 13px; }
1932
+ .rec-price { font-size: 14px; }
1933
+ }
templates/index.html CHANGED
@@ -14,7 +14,7 @@
14
  })();
15
  </script>
16
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
17
- <link rel="stylesheet" href="/static/style.css?v=7">
18
  <link rel="icon" type="image/svg+xml"
19
  href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22 fill=%22%233b82f6%22>IA</text></svg>">
20
  </head>
@@ -191,6 +191,23 @@
191
  <div id="chart-placeholder" class="chart-placeholder">Loading chart...</div>
192
  </div>
193
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  </section>
195
  </main>
196
 
@@ -221,7 +238,7 @@
221
  <script
222
  src="https://unpkg.com/lightweight-charts@4.1.1/dist/lightweight-charts.standalone.production.js?v=4"></script>
223
  <script src="/static/tickerValidation.js?v=1"></script>
224
- <script src="/static/app.js?v=19"></script>
225
  </body>
226
 
227
  </html>
 
14
  })();
15
  </script>
16
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
17
+ <link rel="stylesheet" href="/static/style.css?v=8">
18
  <link rel="icon" type="image/svg+xml"
19
  href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22 fill=%22%233b82f6%22>IA</text></svg>">
20
  </head>
 
191
  <div id="chart-placeholder" class="chart-placeholder">Loading chart...</div>
192
  </div>
193
  </div>
194
+
195
+ <!-- Recommended For You -->
196
+ <div id="recommended-section" class="recommended-section hidden">
197
+ <div class="recommended-header">
198
+ <div>
199
+ <h3 class="rec-title">Recommended for you</h3>
200
+ <span class="rec-subtitle" id="rec-subtitle">Related stocks in the same sector</span>
201
+ </div>
202
+ <div class="rec-nav">
203
+ <button type="button" class="rec-nav-btn" id="rec-prev" disabled aria-label="Scroll left">&#8249;</button>
204
+ <button type="button" class="rec-nav-btn" id="rec-next" aria-label="Scroll right">&#8250;</button>
205
+ </div>
206
+ </div>
207
+ <div class="rec-scroll-wrapper">
208
+ <div class="rec-scroll" id="rec-scroll"></div>
209
+ </div>
210
+ </div>
211
  </section>
212
  </main>
213
 
 
238
  <script
239
  src="https://unpkg.com/lightweight-charts@4.1.1/dist/lightweight-charts.standalone.production.js?v=4"></script>
240
  <script src="/static/tickerValidation.js?v=1"></script>
241
+ <script src="/static/app.js?v=20"></script>
242
  </body>
243
 
244
  </html>