Spaces:
Running
Running
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
- app.py +96 -0
- static/app.js +192 -0
- static/style.css +309 -1
- 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=
|
| 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=
|
| 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">‹</button>
|
| 204 |
+
<button type="button" class="rec-nav-btn" id="rec-next" aria-label="Scroll right">›</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>
|