Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Stock & 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 & 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 & <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> | |
| ; | |
| /* ───────────────────────────────────────────── | |
| 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 & 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> |