WM01 / picks.html
AndyKandy26's picture
Update picks.html
c564672 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stock &amp; Option Picks — FinWise</title>
<link rel="stylesheet" href="shared.css" />
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root {
--cyan:#22d3ee; --emerald:#10b981; --violet:#8b5cf6;
--amber:#f59e0b; --rose:#f43f5e; --card:#1e2535;
--border:#2a3347; --bg3:#151c2c; --text2:#94a3b8;
--bg:#0f1623; --text:#e2e8f0;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex}
/* ── Sidebar ── */
.sidebar{width:240px;min-height:100vh;background:var(--bg3);border-right:1px solid var(--border);padding:1.5rem 1rem;display:flex;flex-direction:column;position:fixed;top:0;left:0;bottom:0;overflow-y:auto;z-index:100}
.sidebar-logo{font-size:1.4rem;font-weight:800;color:var(--cyan);letter-spacing:-.5px;margin-bottom:2rem;padding-left:.5rem}
.sidebar-logo span{color:var(--text)}
.nav-label{font-size:.65rem;font-weight:700;text-transform:uppercase;letter-spacing:1.2px;color:var(--text2);padding:.5rem .5rem .25rem;margin-top:.75rem}
.nav-item{display:flex;align-items:center;gap:.6rem;padding:.55rem .75rem;border-radius:8px;color:var(--text2);text-decoration:none;font-size:.875rem;font-weight:500;transition:all .15s}
.nav-item:hover{background:var(--card);color:var(--text)}
.nav-item.active{background:rgba(34,211,238,.1);color:var(--cyan)}
.nav-icon{font-size:1rem;width:20px;text-align:center}
.nav-badge{margin-left:auto;font-size:.6rem;font-weight:700;padding:2px 6px;border-radius:20px;text-transform:uppercase;letter-spacing:.5px}
.nav-badge.new{background:var(--emerald);color:#000}
/* ── Layout ── */
.main-content{margin-left:240px;padding:1.5rem;flex:1;min-width:0}
/* ── Page header ── */
.page-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:1.25rem;flex-wrap:wrap;gap:1rem}
.page-title{font-size:1.6rem;font-weight:800;letter-spacing:-.5px}
.page-title span{color:var(--cyan)}
.page-subtitle{color:var(--text2);font-size:.875rem;margin-top:.2rem}
.header-actions{display:flex;gap:.75rem;align-items:center;flex-wrap:wrap}
/* ── Data Status Bar ── */
.data-status-bar{
background:var(--card);border:1px solid var(--border);border-radius:10px;
padding:.65rem 1rem;margin-bottom:1.25rem;
display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.75rem;
}
.data-source-info{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}
.data-badge{display:inline-flex;align-items:center;gap:.3rem;padding:3px 10px;border-radius:20px;font-size:.72rem;font-weight:700}
.data-badge.live{background:rgba(16,185,129,.15);color:var(--emerald);border:1px solid rgba(16,185,129,.3)}
.data-badge.cached{background:rgba(245,158,11,.12);color:var(--amber);border:1px solid rgba(245,158,11,.3)}
.data-badge.seed{background:rgba(148,163,184,.1);color:var(--text2);border:1px solid var(--border)}
.data-badge.error{background:rgba(244,63,94,.12);color:var(--rose);border:1px solid rgba(244,63,94,.3)}
.last-updated{font-size:.75rem;color:var(--text2)}
.last-updated span{color:var(--cyan);font-weight:600}
/* ── Cooldown / Refresh button ── */
.btn{display:inline-flex;align-items:center;gap:.4rem;padding:.5rem 1rem;border-radius:8px;border:none;font-size:.85rem;font-weight:600;cursor:pointer;transition:all .15s}
.btn-primary{background:var(--cyan);color:#000}
.btn-primary:hover{opacity:.85}
.btn-primary:disabled{background:var(--border);color:var(--text2);cursor:not-allowed;opacity:1}
.btn-ghost{background:transparent;color:var(--text2);border:1px solid var(--border)}
.btn-ghost:hover{background:var(--card);color:var(--text)}
.cooldown-ring{
width:18px;height:18px;border-radius:50%;
border:2px solid var(--text2);border-top-color:var(--cyan);
animation:spin .8s linear infinite;display:none
}
.cooldown-ring.visible{display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
/* ── Stat grid ── */
.stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:1rem;margin-bottom:1.25rem}
.stat-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1rem 1.25rem;position:relative;overflow:hidden}
.stat-card::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:var(--accent,var(--cyan))}
.stat-label{font-size:.7rem;color:var(--text2);text-transform:uppercase;letter-spacing:.8px;font-weight:600}
.stat-value{font-size:1.55rem;font-weight:800;margin:.25rem 0 .1rem;font-variant-numeric:tabular-nums}
.stat-sub{font-size:.72rem;color:var(--text2)}
.stat-skeleton{height:1.55rem;background:linear-gradient(90deg,var(--border) 25%,var(--bg3) 50%,var(--border) 75%);background-size:200% 100%;animation:shimmer 1.4s infinite;border-radius:4px;width:70%;margin:.25rem 0}
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
/* ── Tab bar ── */
.tab-bar{display:flex;gap:.25rem;background:var(--bg3);border:1px solid var(--border);border-radius:12px;padding:.35rem;margin-bottom:1.25rem;width:fit-content;flex-wrap:wrap}
.tab-btn{padding:.5rem 1.25rem;border-radius:8px;border:none;background:transparent;color:var(--text2);font-size:.875rem;font-weight:600;cursor:pointer;transition:all .15s;white-space:nowrap}
.tab-btn.active{background:var(--card);color:var(--cyan);box-shadow:0 0 0 1px var(--border)}
.tab-btn:hover:not(.active){color:var(--text)}
/* ── Pill toggle ── */
.pill-toggle{display:flex;gap:.2rem;background:var(--bg3);border:1px solid var(--border);border-radius:20px;padding:.25rem;margin-bottom:1.25rem;width:fit-content}
.pill-btn{padding:.35rem 1rem;border-radius:16px;border:none;background:transparent;color:var(--text2);font-size:.8rem;font-weight:600;cursor:pointer;transition:all .15s}
.pill-btn.active{background:var(--cyan);color:#000}
/* ── Filter panel ── */
.filter-panel{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1.25rem;margin-bottom:1.25rem}
.filter-header{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none}
.filter-title{font-size:.875rem;font-weight:700;display:flex;align-items:center;gap:.5rem}
.filter-toggle-icon{transition:transform .2s;font-size:.75rem;color:var(--text2)}
.filter-toggle-icon.open{transform:rotate(180deg)}
.filter-body{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:1rem;margin-top:1rem;border-top:1px solid var(--border);padding-top:1rem}
.filter-body.collapsed{display:none}
.filter-group label{display:block;font-size:.72rem;font-weight:600;text-transform:uppercase;letter-spacing:.7px;color:var(--text2);margin-bottom:.4rem}
.filter-group input[type=range]{width:100%;accent-color:var(--cyan);cursor:pointer}
.range-val{font-size:.8rem;color:var(--cyan);font-weight:700;margin-top:.2rem}
.filter-group select{width:100%;background:var(--bg3);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:.4rem .6rem;font-size:.82rem}
.filter-checkboxes{display:flex;flex-direction:column;gap:.3rem}
.filter-checkboxes label{display:flex;align-items:center;gap:.4rem;font-size:.82rem;font-weight:500;text-transform:none;letter-spacing:0;color:var(--text);cursor:pointer}
.filter-checkboxes input[type=checkbox]{accent-color:var(--cyan)}
.search-box{display:flex;align-items:center;gap:.5rem;background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:.45rem .75rem}
.search-box input{background:transparent;border:none;outline:none;color:var(--text);font-size:.875rem;width:180px}
.search-box input::placeholder{color:var(--text2)}
/* ── Table ── */
.table-wrap{background:var(--card);border:1px solid var(--border);border-radius:12px;overflow:hidden;position:relative}
.table-loading-overlay{position:absolute;inset:0;background:rgba(15,22,35,.75);display:flex;align-items:center;justify-content:center;z-index:10;border-radius:12px;backdrop-filter:blur(2px)}
.table-loading-overlay.hidden{display:none}
.loading-spinner{width:36px;height:36px;border-radius:50%;border:3px solid var(--border);border-top-color:var(--cyan);animation:spin .7s linear infinite}
.loading-msg{color:var(--text2);font-size:.8rem;margin-top:.75rem;text-align:center}
.table-scroll{overflow-x:auto}
table{width:100%;border-collapse:collapse;font-size:.83rem}
thead tr{background:var(--bg3);border-bottom:1px solid var(--border)}
thead th{padding:.75rem 1rem;text-align:left;font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.7px;color:var(--text2);white-space:nowrap;cursor:pointer;user-select:none;transition:color .15s}
thead th:hover{color:var(--cyan)}
thead th .sort-icon{margin-left:.3rem;opacity:.4}
thead th.sorted .sort-icon{opacity:1;color:var(--cyan)}
tbody tr{border-bottom:1px solid var(--border);transition:background .12s}
tbody tr:last-child{border-bottom:none}
tbody tr:hover{background:rgba(255,255,255,.025)}
td{padding:.7rem 1rem;white-space:nowrap;vertical-align:middle}
td.live-updated{animation:flash .6s ease}
@keyframes flash{0%,100%{background:transparent}50%{background:rgba(34,211,238,.08)}}
/* ── Badges ── */
.badge{display:inline-block;padding:2px 8px;border-radius:20px;font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.5px}
.badge-cyan{background:rgba(34,211,238,.15);color:var(--cyan)}
.badge-emerald{background:rgba(16,185,129,.15);color:var(--emerald)}
.badge-amber{background:rgba(245,158,11,.15);color:var(--amber)}
.badge-rose{background:rgba(244,63,94,.15);color:var(--rose)}
.badge-violet{background:rgba(139,92,246,.15);color:var(--violet)}
.badge-gray{background:rgba(148,163,184,.12);color:var(--text2)}
.sector-tech{background:rgba(34,211,238,.15);color:var(--cyan)}
.sector-energy{background:rgba(245,158,11,.15);color:var(--amber)}
.sector-health{background:rgba(16,185,129,.15);color:var(--emerald)}
.sector-finance{background:rgba(139,92,246,.15);color:var(--violet)}
.sector-consumer{background:rgba(244,63,94,.12);color:var(--rose)}
.sector-industrial{background:rgba(148,163,184,.12);color:var(--text2)}
.sector-etf{background:rgba(34,211,238,.12);color:var(--cyan)}
/* ── Colors ── */
.up{color:var(--emerald);font-weight:600}
.down{color:var(--rose);font-weight:600}
.neutral{color:var(--text2)}
.stars{color:var(--amber);letter-spacing:1px;font-size:.85rem}
/* ── Progress ── */
.progress-bar{background:var(--bg3);border-radius:4px;height:6px;width:80px;overflow:hidden}
.progress-fill{height:100%;border-radius:4px}
.fill-cyan{background:var(--cyan)}
.fill-emerald{background:var(--emerald)}
.fill-amber{background:var(--amber)}
.fill-rose{background:var(--rose)}
/* ── Sparkline ── */
.sparkline-wrap{width:80px;height:32px;display:inline-block;position:relative}
.sparkline-wrap canvas{width:80px !important;height:32px !important}
.sparkline-placeholder{width:80px;height:32px;display:flex;align-items:center;justify-content:center}
.sparkline-dot{width:6px;height:6px;border-radius:50%;background:var(--border);animation:pulse 1.5s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:.4}50%{opacity:1}}
/* ── Ticker cell ── */
.ticker-cell{display:flex;align-items:flex-start;gap:.5rem;flex-direction:column}
.ticker-sym{font-size:.9rem;font-weight:800;font-family:'Courier New',monospace;color:var(--text)}
.ticker-name{font-size:.72rem;color:var(--text2);max-width:140px;white-space:normal;line-height:1.3}
/* ── Options specific ── */
.int-bar{display:flex;width:80px;height:8px;border-radius:4px;overflow:hidden}
.int-intrinsic{background:var(--emerald)}
.int-time{background:var(--violet)}
.int-label{font-size:.65rem;color:var(--text2);margin-top:2px}
.dte-warn{color:var(--rose);font-weight:700}
.dte-ok{color:var(--emerald);font-weight:600}
.prem-good{color:var(--emerald);font-weight:700}
.prem-bad{color:var(--rose);font-weight:600}
.delta-good{color:var(--emerald);font-weight:700}
.delta-warn{color:var(--amber);font-weight:600}
.exp-warn::after{content:' ⚠️'}
.risk-high{background:rgba(244,63,94,.15);color:var(--rose)}
.risk-vhigh{background:rgba(244,63,94,.25);color:#ff2048}
/* ── Option data note ── */
.opt-data-note{background:rgba(245,158,11,.06);border:1px solid rgba(245,158,11,.25);border-radius:8px;padding:.6rem 1rem;margin-bottom:1rem;font-size:.78rem;color:var(--amber);display:flex;align-items:center;gap:.5rem}
/* ── Empty state ── */
.empty-state{text-align:center;padding:3rem 1rem;color:var(--text2)}
.empty-state .icon{font-size:2.5rem;margin-bottom:.75rem}
/* ── Tab/sub panel visibility ── */
.tab-panel{display:none}
.tab-panel.active{display:block}
.sub-panel{display:none}
.sub-panel.active{display:block}
/* ── Mobile ── */
.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;background:var(--bg3);border-top:1px solid var(--border);z-index:200;padding:.4rem 0}
.bottom-nav-inner{display:flex;justify-content:space-around;align-items:center}
.bottom-nav-item{display:flex;flex-direction:column;align-items:center;gap:2px;color:var(--text2);text-decoration:none;font-size:.65rem;font-weight:600;padding:.3rem .5rem;border-radius:8px}
.bottom-nav-item .bn-icon{font-size:1.1rem}
.bottom-nav-item.active{color:var(--cyan)}
@media(max-width:768px){
.sidebar{display:none}
.main-content{margin-left:0;padding:1rem;padding-bottom:5rem}
.bottom-nav{display:block}
.sparkline-wrap{display:none}
.page-title{font-size:1.3rem}
.stat-grid{grid-template-columns:1fr 1fr}
.filter-body{grid-template-columns:1fr 1fr}
}
@media(max-width:480px){
.filter-body{grid-template-columns:1fr}
.stat-grid{grid-template-columns:1fr}
.tab-bar{width:100%;overflow-x:auto}
}
/* ── API Key Modal ── */
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:1000;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px)}
.modal-backdrop.hidden{display:none}
.modal-box{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:2rem;width:min(460px,92vw);box-shadow:0 24px 64px rgba(0,0,0,.5)}
.modal-title{font-size:1.1rem;font-weight:800;margin-bottom:.4rem}
.modal-title span{color:var(--cyan)}
.modal-sub{font-size:.82rem;color:var(--text2);margin-bottom:1.25rem;line-height:1.65}
.modal-sub a{color:var(--cyan);text-decoration:none}
.modal-sub a:hover{text-decoration:underline}
.modal-input{width:100%;background:var(--bg3);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:.65rem .85rem;font-size:.88rem;font-family:monospace;outline:none;transition:border-color .15s}
.modal-input:focus{border-color:var(--cyan)}
.modal-actions{display:flex;gap:.75rem;margin-top:1rem;flex-wrap:wrap}
.modal-note{font-size:.72rem;color:var(--text2);margin-top:.85rem;line-height:1.55;padding:.65rem .75rem;background:var(--bg3);border-radius:8px}
.modal-note strong{color:var(--emerald)}
</style>
</head>
<body>
<!-- ═══════════ SIDEBAR ═══════════ -->
<nav class="sidebar">
<div class="sidebar-logo">Fin<span>Wise</span></div>
<div class="nav-label">Main</div>
<a href="index.html" class="nav-item"><span class="nav-icon">🏠</span> Dashboard</a>
<a href="portfolio.html" class="nav-item"><span class="nav-icon">📊</span> Portfolio Builder</a>
<a href="risk.html" class="nav-item"><span class="nav-icon">🎯</span> Risk Analyzer</a>
<a href="tracker.html" class="nav-item"><span class="nav-icon">📈</span> Tracker</a>
<div class="nav-label">Tools</div>
<a href="calculators.html" class="nav-item"><span class="nav-icon">🧮</span> Calculators</a>
<a href="insights.html" class="nav-item"><span class="nav-icon">💡</span> Insights <span class="nav-badge">New</span></a>
<a href="picks.html" class="nav-item active"><span class="nav-icon">🏹</span> Stock &amp; Option Picks <span class="nav-badge new">New</span></a>
</nav>
<!-- ═══════════ MAIN ═══════════ -->
<main class="main-content">
<!-- Page Header -->
<div class="page-header">
<div>
<div class="page-title">Stock &amp; <span>Option Picks</span></div>
<div class="page-subtitle">Real-time prices via Finnhub · Free API key · No backend needed · Static HF Space compatible</div>
</div>
<div class="header-actions">
<div class="search-box">
<span>🔍</span>
<input type="text" id="globalSearch" placeholder="Search ticker…" oninput="onSearch()" />
</div>
<button class="btn btn-primary" id="refreshBtn" onclick="userRefresh()">
<span class="cooldown-ring" id="cooldownRing"></span>
<span id="refreshLabel">⬇️ Fetch Live Data</span>
</button>
</div>
</div>
<!-- Data Status Bar -->
<div class="data-status-bar">
<div class="data-source-info">
<span id="dataSourceBadge" class="data-badge seed">📦 Seed Data</span>
<span class="last-updated" id="lastUpdatedLabel">Not yet fetched — press "Fetch Live Data" to load Yahoo Finance prices</span>
</div>
<div style="font-size:.72rem;color:var(--text2)">
Source: Finnhub.io (free tier) · 60 calls/min · Real-time US quotes · <span style="cursor:pointer;color:var(--cyan)" onclick="showKeyModal()">🔑 Manage API key</span>
</div>
</div>
<!-- Stat Strip -->
<div class="stat-grid">
<div class="stat-card" style="--accent:var(--cyan)">
<div class="stat-label">Total Picks</div>
<div class="stat-value" style="color:var(--cyan)" id="statTotal">47</div>
<div class="stat-sub">across all tabs</div>
</div>
<div class="stat-card" style="--accent:var(--emerald)">
<div class="stat-label">Avg Premium %</div>
<div class="stat-value" style="color:var(--emerald)" id="statPrem"></div>
<div class="stat-sub">option selling tab</div>
</div>
<div class="stat-card" style="--accent:var(--violet)">
<div class="stat-label">Avg LEAP Delta</div>
<div class="stat-value" style="color:var(--violet)" id="statDelta"></div>
<div class="stat-sub">LEAP calls sub-tab</div>
</div>
<div class="stat-card" style="--accent:var(--amber)">
<div class="stat-label">Above Threshold</div>
<div class="stat-value" style="color:var(--amber)" id="statThresh"></div>
<div class="stat-sub">prem ≥ 0.7% or delta 60–80</div>
</div>
</div>
<!-- Main Tab Bar -->
<div class="tab-bar">
<button class="tab-btn active" onclick="switchTab('stocks',this)">📈 Stock Picks</button>
<button class="tab-btn" onclick="switchTab('options',this)">📋 Option Picks</button>
<button class="tab-btn" onclick="switchTab('etfs',this)">⚡ 3× ETF Picks</button>
</div>
<!-- ══════ TAB 1: STOCKS ══════ -->
<div class="tab-panel active" id="tab-stocks">
<div class="filter-panel">
<div class="filter-header" onclick="toggleFilter(this)">
<div class="filter-title">⚙️ Filters</div>
<span class="filter-toggle-icon open"></span>
</div>
<div class="filter-body">
<div class="filter-group">
<label>Max Price</label>
<input type="range" min="0" max="1000" value="1000" id="sPriceMax" oninput="$v('sPriceVal','$0–$'+this.value);renderStocks()">
<div class="range-val" id="sPriceVal">$0–$1000</div>
</div>
<div class="filter-group">
<label>Strategy</label>
<div class="filter-checkboxes">
<label><input type="checkbox" checked value="Momentum" class="s-strat" onchange="renderStocks()"> Momentum</label>
<label><input type="checkbox" checked value="Sector Rotation" class="s-strat" onchange="renderStocks()"> Sector Rotation</label>
<label><input type="checkbox" checked value="Hot News/Rumor" class="s-strat" onchange="renderStocks()"> Hot News/Rumor</label>
</div>
</div>
<div class="filter-group">
<label>Sector</label>
<select id="sSector" onchange="renderStocks()">
<option value="">All</option>
<option>Technology</option><option>Energy</option>
<option>Healthcare</option><option>Finance</option>
<option>Consumer</option><option>Industrial</option>
</select>
</div>
<div class="filter-group">
<label>Min Conviction</label>
<input type="range" min="1" max="5" value="1" id="sConvMin" oninput="$v('sConvVal',this.value+'★');renderStocks()">
<div class="range-val" id="sConvVal">1★</div>
</div>
<div class="filter-group">
<label>Signal Strength</label>
<select id="sSignal" onchange="renderStocks()">
<option value="">All</option>
<option>Strong</option><option>Moderate</option><option>Watch</option>
</select>
</div>
</div>
</div>
<div class="table-wrap">
<div class="table-loading-overlay hidden" id="stocksOverlay">
<div style="text-align:center">
<div class="loading-spinner"></div>
<div class="loading-msg" id="stocksLoadMsg">Fetching from Yahoo Finance…</div>
</div>
</div>
<div class="table-scroll">
<table id="stockTable">
<thead><tr>
<th onclick="sortTable('stocks','ticker')">Ticker <span class="sort-icon"></span></th>
<th onclick="sortTable('stocks','price')">Price <span class="sort-icon"></span></th>
<th onclick="sortTable('stocks','chg1d')">1D % <span class="sort-icon"></span></th>
<th onclick="sortTable('stocks','chg1w')">1W % <span class="sort-icon"></span></th>
<th onclick="sortTable('stocks','chg1m')">1M % <span class="sort-icon"></span></th>
<th>Strategy</th>
<th>Hold Window</th>
<th>Trend (5d)</th>
<th onclick="sortTable('stocks','conviction')">Conviction <span class="sort-icon"></span></th>
<th onclick="sortTable('stocks','signal')">Signal <span class="sort-icon"></span></th>
</tr></thead>
<tbody id="stockBody"></tbody>
</table>
</div>
</div>
</div>
<!-- ══════ TAB 2: OPTIONS ══════ -->
<div class="tab-panel" id="tab-options">
<div class="pill-toggle">
<button class="pill-btn active" onclick="switchPill('optSell',this)">Option Selling</button>
<button class="pill-btn" onclick="switchPill('leaps',this)">LEAP Calls</button>
</div>
<!-- Option Selling -->
<div class="sub-panel active" id="sub-optSell">
<div class="opt-data-note">⚠️ <strong>Prices:</strong> live from Finnhub. <strong>Premium / IV Rank:</strong> estimated from seed data scaled to live price — not real option chain data. Tradier API upgrade available on request.</div>
<div class="filter-panel">
<div class="filter-header" onclick="toggleFilter(this)">
<div class="filter-title">⚙️ Option Selling Filters</div>
<span class="filter-toggle-icon open"></span>
</div>
<div class="filter-body">
<div class="filter-group">
<label>Max DTE</label>
<input type="range" min="0" max="20" value="20" id="osDTE" oninput="$v('osDTEVal',this.value+'d');renderOptSell()">
<div class="range-val" id="osDTEVal">20d</div>
</div>
<div class="filter-group">
<label>Min Premium %</label>
<input type="range" min="0" max="3" step="0.1" value="0.7" id="osPremMin" oninput="$v('osPremVal',parseFloat(this.value).toFixed(1)+'%');renderOptSell()">
<div class="range-val" id="osPremVal">0.7%</div>
</div>
<div class="filter-group">
<label>Min IV Rank</label>
<input type="range" min="0" max="100" value="0" id="osIVR" oninput="$v('osIVRVal',this.value+'%');renderOptSell()">
<div class="range-val" id="osIVRVal">0%</div>
</div>
<div class="filter-group">
<label>Strategy Type</label>
<div class="filter-checkboxes">
<label><input type="checkbox" checked value="CSP" class="os-strat" onchange="renderOptSell()"> Cash-Secured Put</label>
<label><input type="checkbox" checked value="Covered Call" class="os-strat" onchange="renderOptSell()"> Covered Call</label>
<label><input type="checkbox" checked value="Strangle" class="os-strat" onchange="renderOptSell()"> Strangle</label>
<label><input type="checkbox" checked value="Iron Condor" class="os-strat" onchange="renderOptSell()"> Iron Condor</label>
</div>
</div>
<div class="filter-group">
<label>Max Stock Price</label>
<input type="range" min="0" max="1000" value="1000" id="osPrice" oninput="$v('osPriceVal','$'+this.value);renderOptSell()">
<div class="range-val" id="osPriceVal">$1000</div>
</div>
</div>
</div>
<div class="table-wrap">
<div class="table-loading-overlay hidden" id="optSellOverlay"></div>
<div class="table-scroll">
<table id="sellTable">
<thead><tr>
<th onclick="sortTable('optSell','ticker')">Ticker <span class="sort-icon"></span></th>
<th>Strategy</th><th>Strike</th><th>Expiry</th>
<th onclick="sortTable('optSell','dte')">DTE <span class="sort-icon"></span></th>
<th onclick="sortTable('optSell','premium')">Premium $ <span class="sort-icon"></span></th>
<th onclick="sortTable('optSell','premPct')">Prem % <span class="sort-icon"></span></th>
<th onclick="sortTable('optSell','ivr')">IV Rank <span class="sort-icon"></span></th>
<th>Max Risk</th><th>P/L</th>
</tr></thead>
<tbody id="sellBody"></tbody>
</table>
</div>
</div>
</div>
<!-- LEAP Calls -->
<div class="sub-panel" id="sub-leaps">
<div class="opt-data-note">⚠️ <strong>Underlying prices:</strong> live. <strong>Delta / cost / intrinsic split:</strong> estimated from seed — reflects typical LEAP structure at these strikes.</div>
<div class="filter-panel">
<div class="filter-header" onclick="toggleFilter(this)">
<div class="filter-title">⚙️ LEAP Filters</div>
<span class="filter-toggle-icon open"></span>
</div>
<div class="filter-body">
<div class="filter-group">
<label>Min Delta</label>
<input type="range" min="40" max="100" value="60" id="lDeltaMin" oninput="$v('lDeltaMinVal',this.value);renderLeaps()">
<div class="range-val" id="lDeltaMinVal">60</div>
</div>
<div class="filter-group">
<label>Max Delta</label>
<input type="range" min="40" max="100" value="80" id="lDeltaMax" oninput="$v('lDeltaMaxVal',this.value);renderLeaps()">
<div class="range-val" id="lDeltaMaxVal">80</div>
</div>
<div class="filter-group">
<label>Min DTE</label>
<input type="range" min="100" max="900" value="300" id="lDTEMin" oninput="$v('lDTEMinVal',this.value+'d (~'+Math.round(this.value/30)+'mo)');renderLeaps()">
<div class="range-val" id="lDTEMinVal">300d (~10mo)</div>
</div>
<div class="filter-group">
<label>Max Underlying Price</label>
<input type="range" min="0" max="1000" value="1000" id="lPrice" oninput="$v('lPriceVal','$'+this.value);renderLeaps()">
<div class="range-val" id="lPriceVal">$1000</div>
</div>
<div class="filter-group">
<label>Sector</label>
<select id="lSector" onchange="renderLeaps()">
<option value="">All</option>
<option>Technology</option><option>Energy</option>
<option>Healthcare</option><option>Finance</option><option>Consumer</option>
</select>
</div>
</div>
</div>
<div class="table-wrap">
<div class="table-scroll">
<table id="leapTable">
<thead><tr>
<th onclick="sortTable('leaps','ticker')">Ticker <span class="sort-icon"></span></th>
<th>Strike</th><th>Expiry</th>
<th onclick="sortTable('leaps','dte')">DTE <span class="sort-icon"></span></th>
<th onclick="sortTable('leaps','delta')">Delta <span class="sort-icon"></span></th>
<th onclick="sortTable('leaps','cost')">Cost/Contract <span class="sort-icon"></span></th>
<th>Underlying $</th><th>Intrinsic vs Time</th>
<th onclick="sortTable('leaps','conviction')">Conviction <span class="sort-icon"></span></th>
</tr></thead>
<tbody id="leapBody"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ══════ TAB 3: 3× ETFs ══════ -->
<div class="tab-panel" id="tab-etfs">
<div class="pill-toggle">
<button class="pill-btn active" onclick="switchPill('etfHold',this)">ETF Holds</button>
<button class="pill-btn" onclick="switchPill('etfOpts',this)">ETF Options</button>
</div>
<!-- ETF Holds -->
<div class="sub-panel active" id="sub-etfHold">
<div class="filter-panel">
<div class="filter-header" onclick="toggleFilter(this)">
<div class="filter-title">⚙️ ETF Filters</div>
<span class="filter-toggle-icon open"></span>
</div>
<div class="filter-body">
<div class="filter-group">
<label>Max ETF Price</label>
<input type="range" min="0" max="500" value="500" id="efPrice" oninput="$v('efPriceVal','$'+this.value);renderETFHolds()">
<div class="range-val" id="efPriceVal">$500</div>
</div>
<div class="filter-group">
<label>Underlying Sector</label>
<div class="filter-checkboxes">
<label><input type="checkbox" checked value="Tech" class="ef-sector" onchange="renderETFHolds()"> Technology</label>
<label><input type="checkbox" checked value="Semi" class="ef-sector" onchange="renderETFHolds()"> Semiconductors</label>
<label><input type="checkbox" checked value="Broad" class="ef-sector" onchange="renderETFHolds()"> Broad Market</label>
<label><input type="checkbox" checked value="Finance" class="ef-sector" onchange="renderETFHolds()"> Finance</label>
<label><input type="checkbox" checked value="Bio" class="ef-sector" onchange="renderETFHolds()"> Biotech</label>
<label><input type="checkbox" checked value="Defense" class="ef-sector" onchange="renderETFHolds()"> Defense</label>
</div>
</div>
<div class="filter-group">
<label>Momentum</label>
<select id="efMomentum" onchange="renderETFHolds()">
<option value="">All</option>
<option>Bullish</option><option>Bearish</option>
</select>
</div>
<div class="filter-group">
<label>Risk Level</label>
<select id="efRisk" onchange="renderETFHolds()">
<option value="">All</option>
<option>High</option><option>Very High</option>
</select>
</div>
</div>
</div>
<div class="table-wrap">
<div class="table-loading-overlay hidden" id="etfOverlay">
<div style="text-align:center"><div class="loading-spinner"></div><div class="loading-msg">Fetching ETF prices…</div></div>
</div>
<div class="table-scroll">
<table id="etfHoldTable">
<thead><tr>
<th onclick="sortTable('etfHold','ticker')">Ticker <span class="sort-icon"></span></th>
<th>Index</th>
<th onclick="sortTable('etfHold','price')">Price <span class="sort-icon"></span></th>
<th onclick="sortTable('etfHold','chg1d')">1D% <span class="sort-icon"></span></th>
<th onclick="sortTable('etfHold','chg1w')">1W% <span class="sort-icon"></span></th>
<th onclick="sortTable('etfHold','chg1m')">1M% <span class="sort-icon"></span></th>
<th>Avg Volume</th>
<th>Trend (5d)</th>
<th>Momentum</th><th>Strategy</th><th>Risk</th>
<th onclick="sortTable('etfHold','conviction')">Conviction <span class="sort-icon"></span></th>
</tr></thead>
<tbody id="etfHoldBody"></tbody>
</table>
</div>
</div>
</div>
<!-- ETF Options -->
<div class="sub-panel" id="sub-etfOpts">
<div class="opt-data-note">⚠️ ETF prices live. Premium/IV estimated. DTE filtered 0–20 days. Premium ≥ 0.7% highlighted.</div>
<div class="table-wrap">
<div class="table-scroll">
<table id="etfOptTable">
<thead><tr>
<th onclick="sortTable('etfOpts','ticker')">ETF <span class="sort-icon"></span></th>
<th>Strategy</th><th>Strike</th><th>Expiry</th>
<th onclick="sortTable('etfOpts','dte')">DTE <span class="sort-icon"></span></th>
<th onclick="sortTable('etfOpts','premium')">Premium $ <span class="sort-icon"></span></th>
<th onclick="sortTable('etfOpts','premPct')">Prem % <span class="sort-icon"></span></th>
<th onclick="sortTable('etfOpts','ivr')">IV Rank <span class="sort-icon"></span></th>
<th>Max Risk</th><th>P/L</th>
</tr></thead>
<tbody id="etfOptBody"></tbody>
</table>
</div>
</div>
</div>
</div>
</main>
<!-- Mobile Bottom Nav -->
<nav class="bottom-nav">
<div class="bottom-nav-inner">
<a href="index.html" class="bottom-nav-item"><span class="bn-icon">🏠</span>Home</a>
<a href="portfolio.html" class="bottom-nav-item"><span class="bn-icon">📊</span>Portfolio</a>
<a href="risk.html" class="bottom-nav-item"><span class="bn-icon">🎯</span>Risk</a>
<a href="calculators.html" class="bottom-nav-item"><span class="bn-icon">🧮</span>Calc</a>
<a href="picks.html" class="bottom-nav-item active"><span class="bn-icon">🏹</span>Picks</a>
</div>
</nav>
<!-- ═══════════════════════════════════════════
SCRIPT
═══════════════════════════════════════════ -->
<script>
'use strict';
/* ─────────────────────────────────────────────
CONSTANTS & CACHE CONFIG
───────────────────────────────────────────── */
const COOLDOWN_MS = 10 * 60 * 1000; // 10 min cooldown per tab
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 min cache freshness
const SPARK_DELAY = 400; // ms between sparkline requests
// ── Finnhub API (free tier, CORS-enabled, no backend needed) ─────────────────
// Free key: https://finnhub.io/register (takes 30 seconds, no credit card)
// Free tier: 60 calls/min · real-time US quotes · works from browser
const FH_BASE = 'https://finnhub.io/api/v1';
const FH_QUOTE = `${FH_BASE}/quote`; // ?symbol=NVDA&token=KEY
const FH_CANDLE = `${FH_BASE}/stock/candle`; // sparkline
// ── API key management ────────────────────────────────────────────────────────
// Key is stored in localStorage so user only enters it once.
const KEY_STORAGE = 'fw_finnhub_key';
let FINNHUB_KEY = localStorage.getItem(KEY_STORAGE) || '';
function getKey() { return FINNHUB_KEY; }
function saveKey(k) {
FINNHUB_KEY = k.trim();
localStorage.setItem(KEY_STORAGE, FINNHUB_KEY);
}
function hasKey() { return FINNHUB_KEY.length > 0; }
// Active tab / sub-tab tracking
let activeTab = 'stocks';
let activeOptSub = 'optSell';
let activeEtfSub = 'etfHold';
let countdownTimer = null;
let sparklineJobs = {}; // cancel pending sparkline jobs on tab switch
/* ─────────────────────────────────────────────
TICKERS PER TAB
───────────────────────────────────────────── */
const TICKERS = {
stocks: ['NVDA','AMZN','AAPL','META','TSLA','MSFT','GOOGL','WMT','JPM','XOM','AMD','MCD','LLY','GE','SMCI','VST','AXON','RKLB'],
optSell: ['NVDA','AMZN','META','TSLA','SPY','QQQ','AAPL','AMD','XOM','LLY','MSFT','GS'],
leaps: ['NVDA','AAPL','MSFT','AMZN','META','LLY','AMD','JPM','XOM','GOOGL'],
etfHold: ['TQQQ','SOXL','UPRO','FNGU','LABU','TNA','TECL','SPXL','FAS','NAIL','DFEN','UDOW'],
etfOpts: ['TQQQ','SOXL','SPXL','TECL','UDOW','FNGU'],
};
/* ─────────────────────────────────────────────
SEED DATA (fallback + fields that YF doesn't provide)
───────────────────────────────────────────── */
const SEED = {
stocks: [
{ ticker:'NVDA', name:'NVIDIA Corp', sector:'Technology', price:875.24, chg1d:2.4, chg1w:7.8, chg1m:22.1, strategy:'Momentum', window:'4–8 mo', conviction:5, signal:'Strong' },
{ ticker:'AMZN', name:'Amazon.com Inc', sector:'Consumer', price:192.45, chg1d:0.8, chg1w:3.2, chg1m:11.4, strategy:'Momentum', window:'3–6 mo', conviction:4, signal:'Strong' },
{ ticker:'AAPL', name:'Apple Inc', sector:'Technology', price:211.30, chg1d:0.4, chg1w:1.9, chg1m:6.2, strategy:'Sector Rotation', window:'4–8 mo', conviction:4, signal:'Moderate' },
{ ticker:'META', name:'Meta Platforms', sector:'Technology', price:504.10, chg1d:3.1, chg1w:9.4, chg1m:28.7, strategy:'Momentum', window:'4–8 mo', conviction:5, signal:'Strong' },
{ ticker:'TSLA', name:'Tesla Inc', sector:'Consumer', price:248.80, chg1d:-1.3, chg1w:-4.2, chg1m:5.6, strategy:'Hot News/Rumor', window:'2–4 mo', conviction:3, signal:'Moderate' },
{ ticker:'MSFT', name:'Microsoft Corp', sector:'Technology', price:420.55, chg1d:0.6, chg1w:2.3, chg1m:8.8, strategy:'Sector Rotation', window:'6–12 mo', conviction:5, signal:'Strong' },
{ ticker:'GOOGL',name:'Alphabet Inc', sector:'Technology', price:176.80, chg1d:1.2, chg1w:4.1, chg1m:14.3, strategy:'Momentum', window:'4–8 mo', conviction:4, signal:'Strong' },
{ ticker:'WMT', name:'Walmart Inc', sector:'Consumer', price:68.40, chg1d:0.3, chg1w:1.1, chg1m:4.5, strategy:'Sector Rotation', window:'6–12 mo', conviction:3, signal:'Moderate' },
{ ticker:'JPM', name:'JPMorgan Chase', sector:'Finance', price:212.70, chg1d:-0.5, chg1w:-1.8, chg1m:3.2, strategy:'Sector Rotation', window:'3–6 mo', conviction:3, signal:'Watch' },
{ ticker:'XOM', name:'Exxon Mobil', sector:'Energy', price:118.25, chg1d:1.8, chg1w:5.2, chg1m:9.4, strategy:'Hot News/Rumor', window:'2–4 mo', conviction:4, signal:'Strong' },
{ ticker:'AMD', name:'Advanced Micro Devices', sector:'Technology', price:168.40, chg1d:2.2, chg1w:8.1, chg1m:18.5, strategy:'Momentum', window:'4–8 mo', conviction:5, signal:'Strong' },
{ ticker:'MCD', name:"McDonald's Corp", sector:'Consumer', price:291.60, chg1d:0.2, chg1w:0.9, chg1m:3.1, strategy:'Sector Rotation', window:'6–12 mo', conviction:3, signal:'Watch' },
{ ticker:'LLY', name:'Eli Lilly', sector:'Healthcare', price:768.90, chg1d:1.4, chg1w:4.8, chg1m:16.2, strategy:'Hot News/Rumor', window:'3–6 mo', conviction:5, signal:'Strong' },
{ ticker:'GE', name:'GE Aerospace', sector:'Industrial', price:164.30, chg1d:1.6, chg1w:5.1, chg1m:12.8, strategy:'Momentum', window:'4–8 mo', conviction:4, signal:'Strong' },
{ ticker:'SMCI', name:'Super Micro Computer', sector:'Technology', price:742.60, chg1d:4.1, chg1w:12.3, chg1m:35.8, strategy:'Hot News/Rumor', window:'2–4 mo', conviction:4, signal:'Strong' },
{ ticker:'VST', name:'Vistra Corp', sector:'Energy', price:94.20, chg1d:2.9, chg1w:8.7, chg1m:24.1, strategy:'Momentum', window:'3–6 mo', conviction:4, signal:'Strong' },
{ ticker:'AXON', name:'Axon Enterprise', sector:'Industrial', price:310.50, chg1d:1.1, chg1w:3.7, chg1m:10.2, strategy:'Momentum', window:'4–8 mo', conviction:3, signal:'Moderate' },
{ ticker:'RKLB', name:'Rocket Lab USA', sector:'Industrial', price:7.84, chg1d:3.7, chg1w:11.2, chg1m:28.9, strategy:'Hot News/Rumor', window:'2–4 mo', conviction:3, signal:'Moderate' },
],
optSell: [
{ ticker:'NVDA', name:'NVIDIA', sector:'Technology', strategy:'CSP', strike:'$840 PUT', expiry:'May 17', dte:7, premium:7.20, premPct:0.86, ivr:68, maxRisk:84000, underlyingPrice:875 },
{ ticker:'AMZN', name:'Amazon', sector:'Consumer', strategy:'Covered Call', strike:'$195 CALL', expiry:'May 17', dte:7, premium:1.90, premPct:0.99, ivr:44, maxRisk:19200, underlyingPrice:192 },
{ ticker:'META', name:'Meta', sector:'Technology', strategy:'Strangle', strike:'$490/520', expiry:'May 24', dte:14, premium:6.80, premPct:1.38, ivr:72, maxRisk:49000, underlyingPrice:504 },
{ ticker:'TSLA', name:'Tesla', sector:'Consumer', strategy:'Iron Condor', strike:'$235/$245/$255/$265', expiry:'May 10', dte:0, premium:2.10, premPct:0.85, ivr:88, maxRisk:8000, underlyingPrice:248 },
{ ticker:'SPY', name:'S&P 500 ETF', sector:'Broad', strategy:'CSP', strike:'$515 PUT', expiry:'May 17', dte:7, premium:2.80, premPct:0.54, ivr:18, maxRisk:51500, underlyingPrice:522 },
{ ticker:'QQQ', name:'Invesco QQQ', sector:'Tech', strategy:'Covered Call', strike:'$445 CALL', expiry:'May 17', dte:7, premium:3.10, premPct:0.71, ivr:24, maxRisk:44000, underlyingPrice:439 },
{ ticker:'AAPL', name:'Apple', sector:'Technology', strategy:'CSP', strike:'$205 PUT', expiry:'May 24', dte:14, premium:1.60, premPct:0.78, ivr:32, maxRisk:20500, underlyingPrice:211 },
{ ticker:'AMD', name:'AMD', sector:'Technology', strategy:'Strangle', strike:'$160/$175', expiry:'May 17', dte:7, premium:2.40, premPct:1.43, ivr:78, maxRisk:16000, underlyingPrice:168 },
{ ticker:'XOM', name:'ExxonMobil', sector:'Energy', strategy:'Covered Call', strike:'$120 CALL', expiry:'May 31', dte:21, premium:0.92, premPct:0.78, ivr:41, maxRisk:11825, underlyingPrice:118 },
{ ticker:'LLY', name:'Eli Lilly', sector:'Healthcare', strategy:'CSP', strike:'$740 PUT', expiry:'May 17', dte:7, premium:7.80, premPct:1.05, ivr:55, maxRisk:74000, underlyingPrice:768 },
{ ticker:'MSFT', name:'Microsoft', sector:'Technology', strategy:'Iron Condor', strike:'$410/$415/$425/$430', expiry:'May 17', dte:7, premium:1.85, premPct:0.44, ivr:22, maxRisk:31500, underlyingPrice:420 },
{ ticker:'GS', name:'Goldman Sachs',sector:'Finance', strategy:'CSP', strike:'$450 PUT', expiry:'May 24', dte:14, premium:4.20, premPct:0.93, ivr:46, maxRisk:45000, underlyingPrice:462 },
],
leaps: [
{ ticker:'NVDA', name:'NVIDIA', sector:'Technology', strike:'$700C', expiry:'Jan 2027', dte:608, delta:78, cost:22800, underlyingPrice:875, intrinsicPct:75, conviction:5 },
{ ticker:'AAPL', name:'Apple', sector:'Technology', strike:'$190C', expiry:'Jan 2027', dte:608, delta:74, cost:3200, underlyingPrice:211, intrinsicPct:72, conviction:4 },
{ ticker:'MSFT', name:'Microsoft', sector:'Technology', strike:'$370C', expiry:'Jan 2027', dte:608, delta:71, cost:5800, underlyingPrice:420, intrinsicPct:68, conviction:5 },
{ ticker:'AMZN', name:'Amazon', sector:'Consumer', strike:'$170C', expiry:'Jan 2027', dte:608, delta:76, cost:2650, underlyingPrice:192, intrinsicPct:64, conviction:4 },
{ ticker:'META', name:'Meta', sector:'Technology', strike:'$440C', expiry:'Jan 2027', dte:608, delta:68, cost:8400, underlyingPrice:504, intrinsicPct:70, conviction:5 },
{ ticker:'LLY', name:'Eli Lilly', sector:'Healthcare', strike:'$680C', expiry:'Jan 2027', dte:608, delta:63, cost:12600, underlyingPrice:768, intrinsicPct:65, conviction:5 },
{ ticker:'AMD', name:'AMD', sector:'Technology', strike:'$140C', expiry:'Jan 2026', dte:244, delta:72, cost:3500, underlyingPrice:168, intrinsicPct:60, conviction:4 },
{ ticker:'JPM', name:'JPMorgan', sector:'Finance', strike:'$190C', expiry:'Jan 2027', dte:608, delta:65, cost:2900, underlyingPrice:212, intrinsicPct:58, conviction:3 },
{ ticker:'XOM', name:'ExxonMobil', sector:'Energy', strike:'$105C', expiry:'Jan 2027', dte:608, delta:61, cost:1640, underlyingPrice:118, intrinsicPct:62, conviction:3 },
{ ticker:'GOOGL', name:'Alphabet', sector:'Technology', strike:'$155C', expiry:'Jan 2027', dte:608, delta:70, cost:2880, underlyingPrice:176, intrinsicPct:67, conviction:4 },
],
etfHolds: [
{ ticker:'TQQQ', name:'ProShares UltraPro QQQ', index:'NASDAQ-100', price:68.40, chg1d:2.1, chg1w:6.4, chg1m:18.2, volume:'98.4M', momentum:'Bullish', strategy:'Momentum', risk:'Very High', conviction:5, sector:'Tech' },
{ ticker:'SOXL', name:'Direxion Semi Bull 3×', index:'PHLX Semiconductor', price:42.80, chg1d:3.8, chg1w:11.2, chg1m:28.4, volume:'42.1M', momentum:'Bullish', strategy:'Sector Play', risk:'Very High', conviction:5, sector:'Semi' },
{ ticker:'UPRO', name:'ProShares UltraPro S&P500', index:'S&P 500', price:86.20, chg1d:1.4, chg1w:4.1, chg1m:12.6, volume:'12.7M', momentum:'Bullish', strategy:'Momentum', risk:'Very High', conviction:4, sector:'Broad' },
{ ticker:'FNGU', name:'MicroSectors FANG+ 3×', index:'NYSE FANG+', price:318.50, chg1d:3.2, chg1w:9.7, chg1m:24.1, volume:'3.8M', momentum:'Bullish', strategy:'Momentum', risk:'Very High', conviction:5, sector:'Tech' },
{ ticker:'LABU', name:'Direxion Biotech Bull 3×', index:'S&P Biotech', price:12.40, chg1d:-1.8, chg1w:-5.2, chg1m:-8.4, volume:'18.3M', momentum:'Bearish', strategy:'Short-Term Trade',risk:'Very High', conviction:2, sector:'Bio' },
{ ticker:'TNA', name:'Direxion Small Cap Bull 3×', index:'Russell 2000', price:38.60, chg1d:1.2, chg1w:3.8, chg1m:9.4, volume:'8.9M', momentum:'Bullish', strategy:'Sector Play', risk:'Very High', conviction:3, sector:'Broad' },
{ ticker:'TECL', name:'Direxion Technology Bull 3×', index:'Russell 1000 Tech', price:74.80, chg1d:2.4, chg1w:7.1, chg1m:19.8, volume:'6.2M', momentum:'Bullish', strategy:'Momentum', risk:'Very High', conviction:5, sector:'Tech' },
{ ticker:'SPXL', name:'Direxion S&P500 Bull 3×', index:'S&P 500', price:148.20, chg1d:1.5, chg1w:4.3, chg1m:13.2, volume:'4.8M', momentum:'Bullish', strategy:'Momentum', risk:'Very High', conviction:4, sector:'Broad' },
{ ticker:'FAS', name:'Direxion Financials Bull 3×', index:'Russell 1000 Finance',price:118.40, chg1d:-0.8, chg1w:-2.4, chg1m:4.1, volume:'3.1M', momentum:'Bearish', strategy:'Short-Term Trade',risk:'High', conviction:3, sector:'Finance' },
{ ticker:'NAIL', name:'Direxion Homebuilders 3×', index:'DJUSHB', price:84.60, chg1d:1.9, chg1w:5.7, chg1m:14.3, volume:'1.8M', momentum:'Bullish', strategy:'Sector Play', risk:'High', conviction:3, sector:'Defense' },
{ ticker:'DFEN', name:'Direxion Defense Bull 3×', index:'DJUSDS', price:52.80, chg1d:2.2, chg1w:6.8, chg1m:15.6, volume:'1.2M', momentum:'Bullish', strategy:'Sector Play', risk:'High', conviction:4, sector:'Defense' },
{ ticker:'UDOW', name:'ProShares UltraPro Dow30', index:'DJIA', price:94.30, chg1d:1.1, chg1w:3.4, chg1m:10.2, volume:'2.4M', momentum:'Bullish', strategy:'Momentum', risk:'Very High', conviction:3, sector:'Broad' },
],
etfOpts: [
{ ticker:'TQQQ', name:'ProShares TQQQ', sector:'Tech ETF', strategy:'CSP', strike:'$64 PUT', expiry:'May 17', dte:7, premium:0.92, premPct:1.38, ivr:62, maxRisk:6400, underlyingPrice:68 },
{ ticker:'SOXL', name:'Direxion SOXL', sector:'Semi ETF', strategy:'Covered Call',strike:'$44 CALL', expiry:'May 17', dte:7, premium:0.64, premPct:1.51, ivr:84, maxRisk:4280, underlyingPrice:42 },
{ ticker:'SPXL', name:'Direxion SPXL', sector:'Broad ETF', strategy:'CSP', strike:'$142 PUT', expiry:'May 17', dte:7, premium:1.48, premPct:1.04, ivr:38, maxRisk:14200, underlyingPrice:148 },
{ ticker:'TECL', name:'Direxion TECL', sector:'Tech ETF', strategy:'Strangle', strike:'$70/$78', expiry:'May 24', dte:14, premium:1.85, premPct:2.47, ivr:74, maxRisk:7000, underlyingPrice:74 },
{ ticker:'UDOW', name:'ProShares UDOW', sector:'Broad ETF', strategy:'Iron Condor', strike:'$90/$92/$97/$99',expiry:'May 17',dte:7,premium:0.72,premPct:0.77,ivr:42,maxRisk:9200, underlyingPrice:94 },
{ ticker:'FNGU', name:'MicroSectors FNGU',sector:'Tech ETF', strategy:'CSP', strike:'$305 PUT', expiry:'May 17', dte:7, premium:4.20, premPct:1.38, ivr:76, maxRisk:30500, underlyingPrice:318 },
],
};
/* Working copies — merged with live prices */
let D = JSON.parse(JSON.stringify(SEED));
/* Per-ticker sparkline data cache */
const sparkCache = {};
/* Sort state */
const sortState = {};
/* ─────────────────────────────────────────────
CACHE HELPERS (localStorage, per-tab)
───────────────────────────────────────────── */
function cacheKey(tab) { return `fw_prices_${tab}`; }
function cacheTimeKey(tab) { return `fw_ptime_${tab}`; }
function cdKey(tab) { return `fw_cd_${tab}`; }
function getCache(tab) {
try {
const t = localStorage.getItem(cacheTimeKey(tab));
if (!t || Date.now() - parseInt(t) > CACHE_TTL_MS) return null;
const raw = localStorage.getItem(cacheKey(tab));
return raw ? JSON.parse(raw) : null;
} catch { return null; }
}
function setCache(tab, data) {
try {
localStorage.setItem(cacheKey(tab), JSON.stringify(data));
localStorage.setItem(cacheTimeKey(tab), Date.now().toString());
} catch {}
}
function getCooldown(tab) {
try { const t = localStorage.getItem(cdKey(tab)); return t ? parseInt(t) : 0; }
catch { return 0; }
}
function setCooldown(tab) {
try { localStorage.setItem(cdKey(tab), Date.now().toString()); }
catch {}
}
function remainingCooldown(tab) {
const elapsed = Date.now() - getCooldown(tab);
return Math.max(0, COOLDOWN_MS - elapsed);
}
function canRefresh(tab) { return remainingCooldown(tab) === 0; }
/* ─────────────────────────────────────────────
FINNHUB FETCH HELPERS
• fetchQuotes: parallel with concurrency cap of 5
• fetchSparkline: sequential with SPARK_DELAY gap
• Both return null on error (falls back to seed data)
───────────────────────────────────────────── */
// Concurrency-limited parallel fetch — avoids hammering 60/min limit
async function fetchAllQuotes(symbols) {
const key = getKey();
if (!key) return null;
const CONCURRENCY = 5;
const results = {};
const chunks = [];
for (let i = 0; i < symbols.length; i += CONCURRENCY)
chunks.push(symbols.slice(i, i + CONCURRENCY));
for (const chunk of chunks) {
const batch = await Promise.all(chunk.map(sym => fetchOneQuote(sym, key)));
batch.forEach((q, i) => { if (q) results[chunk[i]] = q; });
if (chunks.indexOf(chunk) < chunks.length - 1) await sleep(220); // ~5/220ms = safe under 60/min
}
return Object.keys(results).length ? results : null;
}
async function fetchOneQuote(symbol, key) {
try {
const url = `${FH_QUOTE}?symbol=${encodeURIComponent(symbol)}&token=${key}`;
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const j = await res.json();
// Finnhub quote: { c: current, d: change, dp: %change, h: high, l: low, pc: prev close }
if (!j.c || j.c === 0) return null; // 0 = no data (market closed or invalid ticker)
return {
price: parseFloat(j.c.toFixed(2)),
chg1d: parseFloat((j.dp || 0).toFixed(2)),
vol: null, // not in Finnhub basic quote — volume via candles if needed
};
} catch (e) {
console.warn(`[FinWise] Finnhub quote failed (${symbol}):`, e.message);
return null;
}
}
// Alias used by existing orchestration code
async function fetchQuotes(symbols) {
return await fetchAllQuotes(symbols);
}
async function fetchSparkline(ticker) {
if (sparkCache[ticker]) return sparkCache[ticker];
const key = getKey();
if (!key) return null;
try {
const to = Math.floor(Date.now() / 1000);
const from = to - 8 * 24 * 60 * 60; // 8 calendar days back → ~5-6 trading days
const url = `${FH_CANDLE}?symbol=${ticker}&resolution=D&from=${from}&to=${to}&token=${key}`;
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const j = await res.json();
// Finnhub candle: { c: [closes], s: "ok" }
if (j.s !== 'ok' || !j.c?.length) return null;
const closes = j.c.filter(v => v != null).slice(-7);
if (closes.length < 2) return null;
sparkCache[ticker] = closes;
return closes;
} catch (e) {
console.warn(`[FinWise] Finnhub candle failed (${ticker}):`, e.message);
return null;
}
}
/* ─────────────────────────────────────────────
MERGE LIVE PRICES INTO WORKING DATA
───────────────────────────────────────────── */
function mergePrices(tab, priceMap) {
if (!priceMap) return;
const dataKey = {
stocks: 'stocks', optSell: 'optSell', leaps: 'leaps',
etfHold: 'etfHolds', etfOpts: 'etfOpts'
}[tab];
if (!dataKey) return;
D[dataKey] = D[dataKey].map(row => {
const live = priceMap[row.ticker];
if (!live) return row;
const updated = { ...row };
if (live.price !== null) {
const seedRow = SEED[dataKey].find(s => s.ticker === row.ticker) || row;
const ratio = live.price / (seedRow.price || live.price);
// Update price
if ('price' in row) updated.price = live.price;
if ('underlyingPrice' in row) updated.underlyingPrice = live.price;
// Update 1d % from live
if (live.chg1d !== null) updated.chg1d = parseFloat(live.chg1d.toFixed(2));
// Scale premium/maxRisk proportionally for options
if ('premium' in row && 'underlyingPrice' in row) {
updated.premium = parseFloat((seedRow.premium * ratio).toFixed(2));
updated.maxRisk = Math.round(seedRow.maxRisk * ratio);
// premPct stays same (it's a ratio, ratio×ratio cancels)
}
// Scale LEAP cost proportionally
if ('cost' in row) {
updated.cost = Math.round(seedRow.cost * ratio);
}
// Volume
if (live.vol && 'volume' in row) {
updated.volume = formatVol(live.vol);
}
}
return updated;
});
}
/* ─────────────────────────────────────────────
MAIN FETCH ORCHESTRATOR
Only fetches tickers for the currently visible tab/sub-tab
───────────────────────────────────────────── */
async function fetchForActiveTab() {
const tab = getVisibleDataKey();
showOverlay(tab, true);
updateStatusBar('loading', tab);
// Check cache first
const cached = getCache(tab);
if (cached) {
mergePrices(tab, cached);
renderActive();
showOverlay(tab, false);
updateStatusBar('cached', tab);
return;
}
const symbols = getVisibleTickers();
const priceMap = await fetchQuotes(symbols);
if (priceMap) {
setCache(tab, priceMap);
setCooldown(tab);
mergePrices(tab, priceMap);
updateStatusBar('live', tab);
} else {
updateStatusBar('error', tab);
}
renderActive();
showOverlay(tab, false);
// Fetch sparklines lazily — one per SPARK_DELAY ms, skip if tab changes
fetchSparklazyForTab(tab, symbols);
}
/* Fetch sparklines with delay & tab-guard */
async function fetchSparklazyForTab(tab, symbols) {
const jobId = Date.now();
sparklineJobs[tab] = jobId;
for (const ticker of symbols) {
if (sparklineJobs[tab] !== jobId) break; // tab switched — abort
if (sparkCache[ticker]) continue; // already have it
await sleep(SPARK_DELAY);
if (sparklineJobs[tab] !== jobId) break;
const data = await fetchSparkline(ticker);
if (data) {
// Re-render only the specific sparkline canvas if it exists
const canvasEl = document.getElementById('sp-' + ticker);
if (canvasEl) drawSparklineOnCanvas(canvasEl, data);
}
}
}
function getVisibleDataKey() {
if (activeTab === 'stocks') return 'stocks';
if (activeTab === 'options') return activeOptSub === 'leaps' ? 'leaps' : 'optSell';
if (activeTab === 'etfs') return activeEtfSub === 'etfOpts' ? 'etfOpts' : 'etfHold';
return 'stocks';
}
function getVisibleTickers() {
return TICKERS[getVisibleDataKey()] || [];
}
/* ─────────────────────────────────────────────
USER-TRIGGERED REFRESH (button click)
───────────────────────────────────────────── */
async function userRefresh() {
// Prompt for API key if not set
if (!hasKey()) {
showKeyModal();
return;
}
const tab = getVisibleDataKey();
const rem = remainingCooldown(tab);
if (rem > 0) { flashCooldownWarning(rem); return; }
setCooldown(tab);
startCooldownDisplay(tab);
await fetchForActiveTab();
}
function startCooldownDisplay(tab) {
clearInterval(countdownTimer);
const btn = document.getElementById('refreshBtn');
const label = document.getElementById('refreshLabel');
const ring = document.getElementById('cooldownRing');
btn.disabled = true;
ring.classList.add('visible');
countdownTimer = setInterval(() => {
const rem = remainingCooldown(tab);
if (rem <= 0) {
clearInterval(countdownTimer);
btn.disabled = false;
ring.classList.remove('visible');
label.textContent = '⬇️ Fetch Live Data';
return;
}
const m = Math.floor(rem / 60000);
const s = Math.floor((rem % 60000) / 1000);
label.textContent = `⏳ Next refresh in ${m}m ${s}s`;
}, 1000);
}
function flashCooldownWarning(rem) {
const m = Math.floor(rem / 60000);
const s = Math.floor((rem % 60000) / 1000);
const label = document.getElementById('refreshLabel');
const orig = label.textContent;
label.textContent = `⛔ Wait ${m}m ${s}s`;
setTimeout(() => { label.textContent = orig; }, 2000);
}
/* ─────────────────────────────────────────────
STATUS BAR
───────────────────────────────────────────── */
function updateStatusBar(state, tab) {
const badge = document.getElementById('dataSourceBadge');
const label = document.getElementById('lastUpdatedLabel');
const ts = localStorage.getItem(cacheTimeKey(tab));
const ago = ts ? Math.round((Date.now() - parseInt(ts)) / 60000) : null;
const states = {
nokey: { cls:'seed', icon:'🔑', text:'No API key set — click "Fetch Live Data" to enter your free Finnhub key (finnhub.io)' },
loading: { cls:'seed', icon:'⏳', text:'Fetching live quotes from Finnhub…' },
live: { cls:'live', icon:'🟢', text:`Finnhub · ${ago === 0 ? 'just now' : ago + ' min ago'} · real-time US quotes` },
cached: { cls:'cached', icon:'📦', text:`Cached · ${ago} min ago · press Fetch to refresh` },
error: { cls:'error', icon:'❌', text:'Finnhub fetch failed. Check your API key is valid at finnhub.io — or try again in a moment.' },
seed: { cls:'seed', icon:'📦', text:'Seed data shown — press "Fetch Live Data" and enter your free Finnhub key' },
};
const s = states[state] || states.seed;
badge.className = 'data-badge ' + s.cls;
badge.textContent = `${s.icon} ${
state==='live' ? 'Live (Finnhub)' :
state==='cached' ? 'Cached' :
state==='error' ? 'API Error' :
state==='nokey' ? 'No API Key' :
state==='loading' ? 'Loading…' : 'Seed Data'
}`;
label.textContent = s.text;
}
/* ─────────────────────────────────────────────
OVERLAY HELPERS
───────────────────────────────────────────── */
function showOverlay(tab, show) {
const ids = { stocks:'stocksOverlay', optSell:'optSellOverlay', etfHold:'etfOverlay' };
const el = document.getElementById(ids[tab]);
if (el) el.classList.toggle('hidden', !show);
}
/* ─────────────────────────────────────────────
HELPERS
───────────────────────────────────────────── */
const $v = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
const sleep = ms => new Promise(r => setTimeout(r, ms));
function onSearch() {
renderStocks(); renderOptSell(); renderLeaps(); renderETFHolds(); renderETFOpts();
}
function globalQ() { return (document.getElementById('globalSearch')?.value || '').trim().toUpperCase(); }
function sectorClass(s) {
return ({Technology:'sector-tech',Energy:'sector-energy',Healthcare:'sector-health',
Finance:'sector-finance',Consumer:'sector-consumer',Industrial:'sector-industrial',
Broad:'sector-etf',Tech:'sector-tech',Semi:'sector-tech',Bio:'sector-health',
Defense:'sector-industrial','Tech ETF':'sector-tech','Semi ETF':'sector-tech',
'Broad ETF':'sector-etf','Finance ETF':'sector-finance'})[s] || 'sector-industrial';
}
function stars(n) {
return '<span class="stars">' + '★'.repeat(n) + '<span style="opacity:.2">' + '★'.repeat(5-n) + '</span></span>';
}
function chgCell(v) {
if (v == null) return '<span class="neutral">—</span>';
if (v > 0) return `<span class="up">▲ ${v.toFixed(2)}%</span>`;
if (v < 0) return `<span class="down">▼ ${Math.abs(v).toFixed(2)}%</span>`;
return '<span class="neutral">0.00%</span>';
}
function fmt$(n) { return n == null ? '—' : '$' + n.toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2}); }
function formatVol(n) {
if (!n) return '—';
if (typeof n === 'string') return n;
if (n >= 1e9) return (n/1e9).toFixed(1) + 'B';
if (n >= 1e6) return (n/1e6).toFixed(1) + 'M';
if (n >= 1e3) return (n/1e3).toFixed(0) + 'K';
return n.toString();
}
function signalBadge(s) {
const m = {Strong:'badge-cyan', Moderate:'badge-amber', Watch:'badge-rose'};
return `<span class="badge ${m[s]||'badge-gray'}">${s}</span>`;
}
function stratBadge(s) {
const m = {Momentum:'badge-cyan','Sector Rotation':'badge-violet','Hot News/Rumor':'badge-amber',
CSP:'badge-emerald','Covered Call':'badge-cyan',Strangle:'badge-amber','Iron Condor':'badge-violet'};
return `<span class="badge ${m[s]||'badge-gray'}">${s}</span>`;
}
function stratBadgeETF(s) {
const m = {Momentum:'badge-cyan','Sector Play':'badge-violet','Short-Term Trade':'badge-amber'};
return `<span class="badge ${m[s]||'badge-gray'}">${s}</span>`;
}
function riskBadge(r) {
return r==='Very High'
? `<span class="badge risk-vhigh">Very High ⚡</span>`
: `<span class="badge risk-high">High ⚠️</span>`;
}
function premPctCell(p) {
return p >= 0.7
? `<span class="prem-good">✅ ${p.toFixed(2)}%</span>`
: `<span class="prem-bad">${p.toFixed(2)}%</span>`;
}
function plCell(p) {
return p >= 0.7
? `<span class="badge badge-emerald">▲ On Target</span>`
: `<span class="badge badge-rose">▼ Below Min</span>`;
}
function deltaCell(d) {
return (d >= 60 && d <= 80)
? `<span class="delta-good">✅ ${d}</span>`
: `<span class="delta-warn">⚠️ ${d}</span>`;
}
function dteCell(d) {
return d > 20 ? `<span class="dte-warn">${d}d ⚠️</span>` : `<span class="dte-ok">${d}d</span>`;
}
function intBar(pct) {
const ip = Math.round(pct);
return `<div class="int-bar"><div class="int-intrinsic" style="width:${ip}%"></div><div class="int-time" style="width:${100-ip}%"></div></div>
<div class="int-label"><span style="color:var(--emerald)">■</span>${ip}% int <span style="color:var(--violet)">■</span>${100-ip}% time</div>`;
}
function momentumArrow(m) {
return m === 'Bullish' ? `<span class="up">▲ Bullish</span>` : `<span class="down">▼ Bearish</span>`;
}
/* Sparkline canvas HTML — uses ticker as id so lazy loader can find it */
function sparkHTML(ticker) {
const cached = sparkCache[ticker];
if (cached) {
// Will be drawn after insertAdjacentHTML
return `<div class="sparkline-wrap"><canvas id="sp-${ticker}"></canvas></div>`;
}
return `<div class="sparkline-placeholder"><div class="sparkline-dot"></div></div>`;
}
function drawSparklineOnCanvas(canvas, data) {
// Destroy existing chart on this canvas if any
const existing = Chart.getChart(canvas);
if (existing) existing.destroy();
const last = data[data.length-1], first = data[0];
const color = last >= first ? '#10b981' : '#f43f5e';
new Chart(canvas, {
type: 'line',
data: {
labels: data.map((_,i) => i),
datasets: [{ data, borderColor:color, borderWidth:1.5, pointRadius:0, tension:0.4, fill:true, backgroundColor:color+'22' }]
},
options: {
responsive:false, animation:false,
plugins:{ legend:{display:false}, tooltip:{enabled:false} },
scales:{ x:{display:false}, y:{display:false} }
}
});
}
function drawAllSparklines(tickers) {
tickers.forEach(ticker => {
const data = sparkCache[ticker];
const canvas = document.getElementById('sp-' + ticker);
if (data && canvas) {
// Replace placeholder with canvas if needed
const wrap = canvas.parentElement;
if (wrap && wrap.classList.contains('sparkline-placeholder')) {
wrap.outerHTML = `<div class="sparkline-wrap"><canvas id="sp-${ticker}"></canvas></div>`;
const freshCanvas = document.getElementById('sp-' + ticker);
if (freshCanvas) drawSparklineOnCanvas(freshCanvas, data);
} else {
drawSparklineOnCanvas(canvas, data);
}
}
});
}
/* ─────────────────────────────────────────────
SORTING
───────────────────────────────────────────── */
function sortTable(ds, key) {
if (!sortState[ds] || sortState[ds].key !== key) sortState[ds] = { key, asc: true };
else sortState[ds].asc = !sortState[ds].asc;
const asc = sortState[ds].asc;
const dataKey = { stocks:'stocks', optSell:'optSell', leaps:'leaps', etfHold:'etfHolds', etfOpts:'etfOpts' }[ds];
D[dataKey].sort((a,b) => {
const av = a[key], bv = b[key];
if (typeof av === 'string') return asc ? av.localeCompare(bv) : bv.localeCompare(av);
return asc ? av - bv : bv - av;
});
const fn = { stocks:renderStocks, optSell:renderOptSell, leaps:renderLeaps, etfHold:renderETFHolds, etfOpts:renderETFOpts }[ds];
if (fn) fn();
}
/* ─────────────────────────────────────────────
TAB / PILL SWITCHING
───────────────────────────────────────────── */
function switchTab(id, btn) {
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.getElementById('tab-' + id).classList.add('active');
btn.classList.add('active');
activeTab = id;
// Check if active tab has cooldown running
const tab = getVisibleDataKey();
const rem = remainingCooldown(tab);
if (rem > 0) startCooldownDisplay(tab);
else {
clearInterval(countdownTimer);
document.getElementById('refreshBtn').disabled = false;
document.getElementById('cooldownRing').classList.remove('visible');
document.getElementById('refreshLabel').textContent = '⬇️ Fetch Live Data';
}
// Show cached status if we have it
const cached = getCache(tab);
updateStatusBar(cached ? 'cached' : 'seed', tab);
updateStats();
}
function switchPill(id, btn) {
const parent = btn.closest('.tab-panel');
parent.querySelectorAll('.sub-panel').forEach(p => p.classList.remove('active'));
parent.querySelectorAll('.pill-btn').forEach(b => b.classList.remove('active'));
document.getElementById('sub-' + id).classList.add('active');
btn.classList.add('active');
if (activeTab === 'options') activeOptSub = id;
if (activeTab === 'etfs') activeEtfSub = id;
updateStats();
}
function toggleFilter(header) {
const body = header.nextElementSibling;
const icon = header.querySelector('.filter-toggle-icon');
body.classList.toggle('collapsed');
icon.classList.toggle('open');
}
/* ─────────────────────────────────────────────
RENDER FUNCTIONS
───────────────────────────────────────────── */
function renderActive() {
if (activeTab === 'stocks') renderStocks();
if (activeTab === 'options') { renderOptSell(); renderLeaps(); }
if (activeTab === 'etfs') { renderETFHolds(); renderETFOpts(); }
updateStats();
}
function renderStocks() {
const priceMax = +document.getElementById('sPriceMax').value;
const strats = [...document.querySelectorAll('.s-strat:checked')].map(e => e.value);
const sector = document.getElementById('sSector').value;
const convMin = +document.getElementById('sConvMin').value;
const signal = document.getElementById('sSignal').value;
const q = globalQ();
const rows = D.stocks.filter(s =>
s.price <= priceMax &&
strats.includes(s.strategy) &&
(!sector || s.sector === sector) &&
s.conviction >= convMin &&
(!signal || s.signal === signal) &&
(!q || s.ticker.includes(q) || s.name.toUpperCase().includes(q))
);
const tbody = document.getElementById('stockBody');
if (!rows.length) { tbody.innerHTML = emptyRow(10); return; }
tbody.innerHTML = rows.map(s => `<tr>
<td><div class="ticker-cell">
<span class="ticker-sym">${s.ticker}</span>
<span class="badge ${sectorClass(s.sector)}">${s.sector}</span>
<span class="ticker-name">${s.name}</span>
</div></td>
<td style="font-family:monospace;font-weight:700">${fmt$(s.price)}</td>
<td>${chgCell(s.chg1d)}</td>
<td>${chgCell(s.chg1w)}</td>
<td>${chgCell(s.chg1m)}</td>
<td>${stratBadge(s.strategy)}</td>
<td style="color:var(--text2);font-size:.8rem">${s.window}</td>
<td>${sparkHTML(s.ticker)}</td>
<td>${stars(s.conviction)}</td>
<td>${signalBadge(s.signal)}</td>
</tr>`).join('');
// Draw any already-cached sparklines immediately
rows.forEach(s => {
if (sparkCache[s.ticker]) {
const canvas = document.getElementById('sp-' + s.ticker);
if (canvas) drawSparklineOnCanvas(canvas, sparkCache[s.ticker]);
}
});
updateStats();
}
function renderOptSell() {
const dteMax = +document.getElementById('osDTE').value;
const premMin = parseFloat(document.getElementById('osPremMin').value);
const ivrMin = +document.getElementById('osIVR').value;
const strats = [...document.querySelectorAll('.os-strat:checked')].map(e => e.value);
const priceMax = +document.getElementById('osPrice').value;
const q = globalQ();
const rows = D.optSell.filter(o =>
o.dte <= dteMax && o.premPct >= premMin && o.ivr >= ivrMin &&
strats.includes(o.strategy) && o.underlyingPrice <= priceMax &&
(!q || o.ticker.includes(q) || o.name.toUpperCase().includes(q))
);
const tbody = document.getElementById('sellBody');
if (!rows.length) { tbody.innerHTML = emptyRow(10); return; }
tbody.innerHTML = rows.map(o => `<tr>
<td><div class="ticker-cell">
<span class="ticker-sym">${o.ticker}</span>
<span class="badge ${sectorClass(o.sector)}">${o.sector}</span>
</div></td>
<td>${stratBadge(o.strategy)}</td>
<td style="font-family:monospace;font-weight:700;color:var(--amber)">${o.strike}</td>
<td style="color:var(--text2)">${o.expiry}</td>
<td>${dteCell(o.dte)}</td>
<td style="font-family:monospace;font-weight:700">${fmt$(o.premium)}</td>
<td>${premPctCell(o.premPct)}</td>
<td><span class="badge badge-violet">${o.ivr}%</span></td>
<td style="font-family:monospace;color:var(--rose)">$${o.maxRisk.toLocaleString()}</td>
<td>${plCell(o.premPct)}</td>
</tr>`).join('');
}
function renderLeaps() {
const dMin = +document.getElementById('lDeltaMin').value;
const dMax = +document.getElementById('lDeltaMax').value;
const dteMin = +document.getElementById('lDTEMin').value;
const priceMax = +document.getElementById('lPrice').value;
const sector = document.getElementById('lSector').value;
const q = globalQ();
const rows = D.leaps.filter(l =>
l.dte >= dteMin && l.underlyingPrice <= priceMax &&
(!sector || l.sector === sector) &&
(!q || l.ticker.includes(q) || l.name.toUpperCase().includes(q))
);
const tbody = document.getElementById('leapBody');
if (!rows.length) { tbody.innerHTML = emptyRow(9); return; }
tbody.innerHTML = rows.map(l => `<tr>
<td><div class="ticker-cell">
<span class="ticker-sym">${l.ticker}</span>
<span class="badge ${sectorClass(l.sector)}">${l.sector}</span>
<span class="ticker-name">${l.name}</span>
</div></td>
<td style="font-family:monospace;font-weight:700;color:var(--cyan)">${l.strike}</td>
<td>${l.dte < 300 ? `<span class="dte-warn">${l.expiry} ⚠️</span>` : l.expiry}</td>
<td>${l.dte >= 300 ? `<span class="dte-ok">${l.dte}d</span>` : `<span class="dte-warn">${l.dte}d ⚠️</span>`}</td>
<td>${deltaCell(l.delta)}</td>
<td style="font-family:monospace;font-weight:700">$${l.cost.toLocaleString()}</td>
<td style="font-family:monospace">${fmt$(l.underlyingPrice)}</td>
<td>${intBar(l.intrinsicPct)}</td>
<td>${stars(l.conviction)}</td>
</tr>`).join('');
}
function renderETFHolds() {
const priceMax = +document.getElementById('efPrice').value;
const sectors = [...document.querySelectorAll('.ef-sector:checked')].map(e => e.value);
const momentum = document.getElementById('efMomentum').value;
const risk = document.getElementById('efRisk').value;
const q = globalQ();
const rows = D.etfHolds.filter(e =>
e.price <= priceMax && sectors.includes(e.sector) &&
(!momentum || e.momentum === momentum) &&
(!risk || e.risk === risk) &&
(!q || e.ticker.includes(q) || e.name.toUpperCase().includes(q))
);
const tbody = document.getElementById('etfHoldBody');
if (!rows.length) { tbody.innerHTML = emptyRow(12); return; }
tbody.innerHTML = rows.map(e => `<tr>
<td><div class="ticker-cell">
<span class="ticker-sym">${e.ticker}</span>
<span class="ticker-name">${e.name}</span>
</div></td>
<td style="color:var(--text2);font-size:.8rem">${e.index}</td>
<td style="font-family:monospace;font-weight:700">${fmt$(e.price)}</td>
<td>${chgCell(e.chg1d)}</td>
<td>${chgCell(e.chg1w)}</td>
<td>${chgCell(e.chg1m)}</td>
<td style="color:var(--text2);font-size:.8rem">${formatVol(e.volume)}</td>
<td>${sparkHTML(e.ticker)}</td>
<td>${momentumArrow(e.momentum)}</td>
<td>${stratBadgeETF(e.strategy)}</td>
<td>${riskBadge(e.risk)}</td>
<td>${stars(e.conviction)}</td>
</tr>`).join('');
rows.forEach(e => {
if (sparkCache[e.ticker]) {
const canvas = document.getElementById('sp-' + e.ticker);
if (canvas) drawSparklineOnCanvas(canvas, sparkCache[e.ticker]);
}
});
}
function renderETFOpts() {
const q = globalQ();
const rows = D.etfOpts.filter(o =>
o.dte <= 20 &&
(!q || o.ticker.includes(q) || o.name.toUpperCase().includes(q))
);
const tbody = document.getElementById('etfOptBody');
if (!rows.length) { tbody.innerHTML = emptyRow(10); return; }
tbody.innerHTML = rows.map(o => `<tr>
<td><div class="ticker-cell">
<span class="ticker-sym">${o.ticker}</span>
<span class="badge ${sectorClass(o.sector)}">${o.sector}</span>
</div></td>
<td>${stratBadge(o.strategy)}</td>
<td style="font-family:monospace;font-weight:700;color:var(--amber)">${o.strike}</td>
<td style="color:var(--text2)">${o.expiry}</td>
<td>${dteCell(o.dte)}</td>
<td style="font-family:monospace;font-weight:700">${fmt$(o.premium)}</td>
<td>${premPctCell(o.premPct)}</td>
<td><span class="badge badge-violet">${o.ivr}%</span></td>
<td style="font-family:monospace;color:var(--rose)">$${o.maxRisk.toLocaleString()}</td>
<td>${plCell(o.premPct)}</td>
</tr>`).join('');
}
function emptyRow(cols) {
return `<tr><td colspan="${cols}"><div class="empty-state">
<div class="icon">🔍</div><p>No results match your filters.</p>
</div></td></tr>`;
}
/* ─────────────────────────────────────────────
STAT STRIP
───────────────────────────────────────────── */
function updateStats() {
$v('statTotal', D.stocks.length + D.optSell.length + D.leaps.length + D.etfHolds.length + D.etfOpts.length);
const avgPrem = D.optSell.reduce((a,o) => a + o.premPct, 0) / D.optSell.length;
$v('statPrem', avgPrem.toFixed(2) + '%');
const avgDelta = D.leaps.reduce((a,l) => a + l.delta, 0) / D.leaps.length;
$v('statDelta', avgDelta.toFixed(1));
const above = [...D.optSell.filter(o => o.premPct >= 0.7), ...D.leaps.filter(l => l.delta >= 60 && l.delta <= 80), ...D.etfOpts.filter(o => o.premPct >= 0.7)].length;
const total = D.optSell.length + D.leaps.length + D.etfOpts.length;
$v('statThresh', Math.round(above / total * 100) + '%');
}
/* ─────────────────────────────────────────────
API KEY MODAL
───────────────────────────────────────────── */
function showKeyModal() {
document.getElementById('keyModal').classList.remove('hidden');
document.getElementById('keyInput').value = FINNHUB_KEY || '';
document.getElementById('keyInput').focus();
}
function hideKeyModal() {
document.getElementById('keyModal').classList.add('hidden');
}
async function confirmKey() {
const k = document.getElementById('keyInput').value.trim();
if (!k) { document.getElementById('keyInput').style.borderColor='var(--rose)'; return; }
saveKey(k);
hideKeyModal();
document.getElementById('refreshLabel').textContent = '⬇️ Fetch Live Data';
// Auto-trigger fetch now that we have a key
const tab = getVisibleDataKey();
setCooldown(tab);
startCooldownDisplay(tab);
await fetchForActiveTab();
}
function clearKey() {
saveKey('');
document.getElementById('keyInput').value = '';
updateStatusBar('nokey', getVisibleDataKey());
}
// Allow Enter key in the input
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('keyInput')?.addEventListener('keydown', e => {
if (e.key === 'Enter') confirmKey();
if (e.key === 'Escape') hideKeyModal();
});
});
document.addEventListener('DOMContentLoaded', () => {
renderStocks(); renderOptSell(); renderLeaps(); renderETFHolds(); renderETFOpts();
updateStats();
const tab = getVisibleDataKey();
if (remainingCooldown(tab) > 0) startCooldownDisplay(tab);
const cached = getCache(tab);
if (cached) {
mergePrices(tab, cached);
renderStocks();
updateStatusBar('cached', tab);
} else {
updateStatusBar(hasKey() ? 'seed' : 'nokey', tab);
}
});
</script>
<!-- ═══════════ API KEY MODAL ═══════════ -->
<div class="modal-backdrop hidden" id="keyModal" onclick="if(event.target===this)hideKeyModal()">
<div class="modal-box">
<div class="modal-title">🔑 Enter your <span>Finnhub API Key</span></div>
<div class="modal-sub">
FinWise uses <a href="https://finnhub.io/register" target="_blank">Finnhub.io</a> for real-time stock quotes.
The free tier gives you <strong>60 calls/minute</strong> with no credit card required.<br><br>
<strong>Steps:</strong> 1) Go to <a href="https://finnhub.io/register" target="_blank">finnhub.io/register</a>
→ 2) Sign up free → 3) Copy your API key from the dashboard → 4) Paste it below.
</div>
<input class="modal-input" id="keyInput" type="text"
placeholder="e.g. d0abc123xyz456..." autocomplete="off" spellcheck="false" />
<div class="modal-actions">
<button class="btn btn-primary" onclick="confirmKey()">✅ Save &amp; Fetch Data</button>
<button class="btn btn-ghost" onclick="hideKeyModal()">Cancel</button>
<button class="btn btn-ghost" onclick="clearKey()" style="margin-left:auto;color:var(--rose);border-color:var(--rose)">🗑 Clear Key</button>
</div>
<div class="modal-note">
<strong>🔒 Privacy:</strong> Your key is stored only in your browser's localStorage — never sent to any server other than Finnhub directly. You can clear it anytime with the button above.
</div>
</div>
</div>
</body>
</html>