Spaces:
Running
Running
Upload picks.html
Browse files- picks.html +177 -61
picks.html
CHANGED
|
@@ -219,6 +219,20 @@
|
|
| 219 |
.stat-grid{grid-template-columns:1fr}
|
| 220 |
.tab-bar{width:100%;overflow-x:auto}
|
| 221 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
</style>
|
| 223 |
</head>
|
| 224 |
<body>
|
|
@@ -244,7 +258,7 @@
|
|
| 244 |
<div class="page-header">
|
| 245 |
<div>
|
| 246 |
<div class="page-title">Stock & <span>Option Picks</span></div>
|
| 247 |
-
<div class="page-subtitle">
|
| 248 |
</div>
|
| 249 |
<div class="header-actions">
|
| 250 |
<div class="search-box">
|
|
@@ -265,7 +279,7 @@
|
|
| 265 |
<span class="last-updated" id="lastUpdatedLabel">Not yet fetched β press "Fetch Live Data" to load Yahoo Finance prices</span>
|
| 266 |
</div>
|
| 267 |
<div style="font-size:.72rem;color:var(--text2)">
|
| 268 |
-
Source:
|
| 269 |
</div>
|
| 270 |
</div>
|
| 271 |
|
|
@@ -380,7 +394,7 @@
|
|
| 380 |
|
| 381 |
<!-- Option Selling -->
|
| 382 |
<div class="sub-panel active" id="sub-optSell">
|
| 383 |
-
<div class="opt-data-note">β οΈ <strong>Prices:</strong> live from
|
| 384 |
<div class="filter-panel">
|
| 385 |
<div class="filter-header" onclick="toggleFilter(this)">
|
| 386 |
<div class="filter-title">βοΈ Option Selling Filters</div>
|
|
@@ -610,17 +624,28 @@
|
|
| 610 |
/* βββββββββββββββββββββββββββββββββββββββββββββ
|
| 611 |
CONSTANTS & CACHE CONFIG
|
| 612 |
βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 613 |
-
const COOLDOWN_MS
|
| 614 |
-
const CACHE_TTL_MS
|
| 615 |
-
const SPARK_DELAY
|
| 616 |
-
|
| 617 |
-
// ββ
|
| 618 |
-
//
|
| 619 |
-
|
| 620 |
-
const
|
| 621 |
-
const
|
| 622 |
-
|
| 623 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 624 |
|
| 625 |
// Active tab / sub-tab tracking
|
| 626 |
let activeTab = 'stocks';
|
|
@@ -759,42 +784,74 @@ function remainingCooldown(tab) {
|
|
| 759 |
function canRefresh(tab) { return remainingCooldown(tab) === 0; }
|
| 760 |
|
| 761 |
/* βββββββββββββββββββββββββββββββββββββββββββββ
|
| 762 |
-
|
| 763 |
-
|
|
|
|
|
|
|
| 764 |
βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 765 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 766 |
try {
|
| 767 |
-
const
|
| 768 |
-
|
| 769 |
-
});
|
| 770 |
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 771 |
-
const
|
| 772 |
-
//
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
|
|
|
| 778 |
} catch (e) {
|
| 779 |
-
console.warn(
|
| 780 |
return null;
|
| 781 |
}
|
| 782 |
}
|
| 783 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 784 |
async function fetchSparkline(ticker) {
|
| 785 |
if (sparkCache[ticker]) return sparkCache[ticker];
|
|
|
|
|
|
|
| 786 |
try {
|
| 787 |
-
const
|
| 788 |
-
|
| 789 |
-
}
|
|
|
|
| 790 |
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 791 |
-
const
|
| 792 |
-
|
|
|
|
|
|
|
| 793 |
if (closes.length < 2) return null;
|
| 794 |
sparkCache[ticker] = closes;
|
| 795 |
return closes;
|
| 796 |
} catch (e) {
|
| 797 |
-
console.warn(`[FinWise]
|
| 798 |
return null;
|
| 799 |
}
|
| 800 |
}
|
|
@@ -918,15 +975,16 @@ function getVisibleTickers() {
|
|
| 918 |
USER-TRIGGERED REFRESH (button click)
|
| 919 |
βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 920 |
async function userRefresh() {
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
if (rem > 0) {
|
| 925 |
-
// Already in cooldown β don't fetch, just show warning
|
| 926 |
-
flashCooldownWarning(rem);
|
| 927 |
return;
|
| 928 |
}
|
| 929 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 930 |
setCooldown(tab);
|
| 931 |
startCooldownDisplay(tab);
|
| 932 |
await fetchForActiveTab();
|
|
@@ -969,22 +1027,29 @@ function flashCooldownWarning(rem) {
|
|
| 969 |
STATUS BAR
|
| 970 |
βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 971 |
function updateStatusBar(state, tab) {
|
| 972 |
-
const badge
|
| 973 |
-
const label
|
| 974 |
-
const ts
|
| 975 |
-
const ago
|
| 976 |
|
| 977 |
const states = {
|
| 978 |
-
|
| 979 |
-
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
|
|
|
| 983 |
};
|
| 984 |
const s = states[state] || states.seed;
|
| 985 |
-
badge.className
|
| 986 |
-
badge.textContent
|
| 987 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 988 |
}
|
| 989 |
|
| 990 |
/* βββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -1399,29 +1464,80 @@ function updateStats() {
|
|
| 1399 |
}
|
| 1400 |
|
| 1401 |
/* βββββββββββββββββββββββββββββββββββββββββββββ
|
| 1402 |
-
|
| 1403 |
βββββββββββββββββββββββββββββββββββββββββββββ */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1404 |
document.addEventListener('DOMContentLoaded', () => {
|
| 1405 |
-
|
| 1406 |
-
renderStocks();
|
| 1407 |
-
renderOptSell();
|
| 1408 |
-
renderLeaps();
|
| 1409 |
-
renderETFHolds();
|
| 1410 |
-
renderETFOpts();
|
| 1411 |
updateStats();
|
| 1412 |
|
| 1413 |
-
// Restore cooldown display if tab is still in cooldown
|
| 1414 |
const tab = getVisibleDataKey();
|
| 1415 |
if (remainingCooldown(tab) > 0) startCooldownDisplay(tab);
|
| 1416 |
|
| 1417 |
-
// Show cached status if we already have data
|
| 1418 |
const cached = getCache(tab);
|
| 1419 |
if (cached) {
|
| 1420 |
mergePrices(tab, cached);
|
| 1421 |
renderStocks();
|
| 1422 |
updateStatusBar('cached', tab);
|
|
|
|
|
|
|
| 1423 |
}
|
| 1424 |
});
|
| 1425 |
</script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1426 |
</body>
|
| 1427 |
</html>
|
|
|
|
| 219 |
.stat-grid{grid-template-columns:1fr}
|
| 220 |
.tab-bar{width:100%;overflow-x:auto}
|
| 221 |
}
|
| 222 |
+
/* ββ API Key Modal ββ */
|
| 223 |
+
.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)}
|
| 224 |
+
.modal-backdrop.hidden{display:none}
|
| 225 |
+
.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)}
|
| 226 |
+
.modal-title{font-size:1.1rem;font-weight:800;margin-bottom:.4rem}
|
| 227 |
+
.modal-title span{color:var(--cyan)}
|
| 228 |
+
.modal-sub{font-size:.82rem;color:var(--text2);margin-bottom:1.25rem;line-height:1.65}
|
| 229 |
+
.modal-sub a{color:var(--cyan);text-decoration:none}
|
| 230 |
+
.modal-sub a:hover{text-decoration:underline}
|
| 231 |
+
.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}
|
| 232 |
+
.modal-input:focus{border-color:var(--cyan)}
|
| 233 |
+
.modal-actions{display:flex;gap:.75rem;margin-top:1rem;flex-wrap:wrap}
|
| 234 |
+
.modal-note{font-size:.72rem;color:var(--text2);margin-top:.85rem;line-height:1.55;padding:.65rem .75rem;background:var(--bg3);border-radius:8px}
|
| 235 |
+
.modal-note strong{color:var(--emerald)}
|
| 236 |
</style>
|
| 237 |
</head>
|
| 238 |
<body>
|
|
|
|
| 258 |
<div class="page-header">
|
| 259 |
<div>
|
| 260 |
<div class="page-title">Stock & <span>Option Picks</span></div>
|
| 261 |
+
<div class="page-subtitle">Real-time prices via Finnhub Β· Free API key Β· No backend needed Β· Static HF Space compatible</div>
|
| 262 |
</div>
|
| 263 |
<div class="header-actions">
|
| 264 |
<div class="search-box">
|
|
|
|
| 279 |
<span class="last-updated" id="lastUpdatedLabel">Not yet fetched β press "Fetch Live Data" to load Yahoo Finance prices</span>
|
| 280 |
</div>
|
| 281 |
<div style="font-size:.72rem;color:var(--text2)">
|
| 282 |
+
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>
|
| 283 |
</div>
|
| 284 |
</div>
|
| 285 |
|
|
|
|
| 394 |
|
| 395 |
<!-- Option Selling -->
|
| 396 |
<div class="sub-panel active" id="sub-optSell">
|
| 397 |
+
<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>
|
| 398 |
<div class="filter-panel">
|
| 399 |
<div class="filter-header" onclick="toggleFilter(this)">
|
| 400 |
<div class="filter-title">βοΈ Option Selling Filters</div>
|
|
|
|
| 624 |
/* βββββββββββββββββββββββββββββββββββββββββββββ
|
| 625 |
CONSTANTS & CACHE CONFIG
|
| 626 |
βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 627 |
+
const COOLDOWN_MS = 10 * 60 * 1000; // 10 min cooldown per tab
|
| 628 |
+
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 min cache freshness
|
| 629 |
+
const SPARK_DELAY = 400; // ms between sparkline requests
|
| 630 |
+
|
| 631 |
+
// ββ Finnhub API (free tier, CORS-enabled, no backend needed) βββββββββββββββββ
|
| 632 |
+
// Free key: https://finnhub.io/register (takes 30 seconds, no credit card)
|
| 633 |
+
// Free tier: 60 calls/min Β· real-time US quotes Β· works from browser
|
| 634 |
+
const FH_BASE = 'https://finnhub.io/api/v1';
|
| 635 |
+
const FH_QUOTE = `${FH_BASE}/quote`; // ?symbol=NVDA&token=KEY
|
| 636 |
+
const FH_CANDLE = `${FH_BASE}/stock/candle`; // sparkline
|
| 637 |
+
|
| 638 |
+
// ββ API key management ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 639 |
+
// Key is stored in localStorage so user only enters it once.
|
| 640 |
+
const KEY_STORAGE = 'fw_finnhub_key';
|
| 641 |
+
let FINNHUB_KEY = localStorage.getItem(KEY_STORAGE) || '';
|
| 642 |
+
|
| 643 |
+
function getKey() { return FINNHUB_KEY; }
|
| 644 |
+
function saveKey(k) {
|
| 645 |
+
FINNHUB_KEY = k.trim();
|
| 646 |
+
localStorage.setItem(KEY_STORAGE, FINNHUB_KEY);
|
| 647 |
+
}
|
| 648 |
+
function hasKey() { return FINNHUB_KEY.length > 0; }
|
| 649 |
|
| 650 |
// Active tab / sub-tab tracking
|
| 651 |
let activeTab = 'stocks';
|
|
|
|
| 784 |
function canRefresh(tab) { return remainingCooldown(tab) === 0; }
|
| 785 |
|
| 786 |
/* βββββββββββββββββββββββββββββββββββββββββββββ
|
| 787 |
+
FINNHUB FETCH HELPERS
|
| 788 |
+
β’ fetchQuotes: parallel with concurrency cap of 5
|
| 789 |
+
β’ fetchSparkline: sequential with SPARK_DELAY gap
|
| 790 |
+
β’ Both return null on error (falls back to seed data)
|
| 791 |
βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 792 |
+
|
| 793 |
+
// Concurrency-limited parallel fetch β avoids hammering 60/min limit
|
| 794 |
+
async function fetchAllQuotes(symbols) {
|
| 795 |
+
const key = getKey();
|
| 796 |
+
if (!key) return null;
|
| 797 |
+
|
| 798 |
+
const CONCURRENCY = 5;
|
| 799 |
+
const results = {};
|
| 800 |
+
const chunks = [];
|
| 801 |
+
for (let i = 0; i < symbols.length; i += CONCURRENCY)
|
| 802 |
+
chunks.push(symbols.slice(i, i + CONCURRENCY));
|
| 803 |
+
|
| 804 |
+
for (const chunk of chunks) {
|
| 805 |
+
const batch = await Promise.all(chunk.map(sym => fetchOneQuote(sym, key)));
|
| 806 |
+
batch.forEach((q, i) => { if (q) results[chunk[i]] = q; });
|
| 807 |
+
if (chunks.indexOf(chunk) < chunks.length - 1) await sleep(220); // ~5/220ms = safe under 60/min
|
| 808 |
+
}
|
| 809 |
+
return Object.keys(results).length ? results : null;
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
+
async function fetchOneQuote(symbol, key) {
|
| 813 |
try {
|
| 814 |
+
const url = `${FH_QUOTE}?symbol=${encodeURIComponent(symbol)}&token=${key}`;
|
| 815 |
+
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
|
|
|
| 816 |
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 817 |
+
const j = await res.json();
|
| 818 |
+
// Finnhub quote: { c: current, d: change, dp: %change, h: high, l: low, pc: prev close }
|
| 819 |
+
if (!j.c || j.c === 0) return null; // 0 = no data (market closed or invalid ticker)
|
| 820 |
+
return {
|
| 821 |
+
price: parseFloat(j.c.toFixed(2)),
|
| 822 |
+
chg1d: parseFloat((j.dp || 0).toFixed(2)),
|
| 823 |
+
vol: null, // not in Finnhub basic quote β volume via candles if needed
|
| 824 |
+
};
|
| 825 |
} catch (e) {
|
| 826 |
+
console.warn(`[FinWise] Finnhub quote failed (${symbol}):`, e.message);
|
| 827 |
return null;
|
| 828 |
}
|
| 829 |
}
|
| 830 |
|
| 831 |
+
// Alias used by existing orchestration code
|
| 832 |
+
async function fetchQuotes(symbols) {
|
| 833 |
+
return await fetchAllQuotes(symbols);
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
async function fetchSparkline(ticker) {
|
| 837 |
if (sparkCache[ticker]) return sparkCache[ticker];
|
| 838 |
+
const key = getKey();
|
| 839 |
+
if (!key) return null;
|
| 840 |
try {
|
| 841 |
+
const to = Math.floor(Date.now() / 1000);
|
| 842 |
+
const from = to - 8 * 24 * 60 * 60; // 8 calendar days back β ~5-6 trading days
|
| 843 |
+
const url = `${FH_CANDLE}?symbol=${ticker}&resolution=D&from=${from}&to=${to}&token=${key}`;
|
| 844 |
+
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
| 845 |
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 846 |
+
const j = await res.json();
|
| 847 |
+
// Finnhub candle: { c: [closes], s: "ok" }
|
| 848 |
+
if (j.s !== 'ok' || !j.c?.length) return null;
|
| 849 |
+
const closes = j.c.filter(v => v != null).slice(-7);
|
| 850 |
if (closes.length < 2) return null;
|
| 851 |
sparkCache[ticker] = closes;
|
| 852 |
return closes;
|
| 853 |
} catch (e) {
|
| 854 |
+
console.warn(`[FinWise] Finnhub candle failed (${ticker}):`, e.message);
|
| 855 |
return null;
|
| 856 |
}
|
| 857 |
}
|
|
|
|
| 975 |
USER-TRIGGERED REFRESH (button click)
|
| 976 |
βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 977 |
async function userRefresh() {
|
| 978 |
+
// Prompt for API key if not set
|
| 979 |
+
if (!hasKey()) {
|
| 980 |
+
showKeyModal();
|
|
|
|
|
|
|
|
|
|
| 981 |
return;
|
| 982 |
}
|
| 983 |
|
| 984 |
+
const tab = getVisibleDataKey();
|
| 985 |
+
const rem = remainingCooldown(tab);
|
| 986 |
+
if (rem > 0) { flashCooldownWarning(rem); return; }
|
| 987 |
+
|
| 988 |
setCooldown(tab);
|
| 989 |
startCooldownDisplay(tab);
|
| 990 |
await fetchForActiveTab();
|
|
|
|
| 1027 |
STATUS BAR
|
| 1028 |
βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 1029 |
function updateStatusBar(state, tab) {
|
| 1030 |
+
const badge = document.getElementById('dataSourceBadge');
|
| 1031 |
+
const label = document.getElementById('lastUpdatedLabel');
|
| 1032 |
+
const ts = localStorage.getItem(cacheTimeKey(tab));
|
| 1033 |
+
const ago = ts ? Math.round((Date.now() - parseInt(ts)) / 60000) : null;
|
| 1034 |
|
| 1035 |
const states = {
|
| 1036 |
+
nokey: { cls:'seed', icon:'π', text:'No API key set β click "Fetch Live Data" to enter your free Finnhub key (finnhub.io)' },
|
| 1037 |
+
loading: { cls:'seed', icon:'β³', text:'Fetching live quotes from Finnhubβ¦' },
|
| 1038 |
+
live: { cls:'live', icon:'π’', text:`Finnhub Β· ${ago === 0 ? 'just now' : ago + ' min ago'} Β· real-time US quotes` },
|
| 1039 |
+
cached: { cls:'cached', icon:'π¦', text:`Cached Β· ${ago} min ago Β· press Fetch to refresh` },
|
| 1040 |
+
error: { cls:'error', icon:'β', text:'Finnhub fetch failed. Check your API key is valid at finnhub.io β or try again in a moment.' },
|
| 1041 |
+
seed: { cls:'seed', icon:'π¦', text:'Seed data shown β press "Fetch Live Data" and enter your free Finnhub key' },
|
| 1042 |
};
|
| 1043 |
const s = states[state] || states.seed;
|
| 1044 |
+
badge.className = 'data-badge ' + s.cls;
|
| 1045 |
+
badge.textContent = `${s.icon} ${
|
| 1046 |
+
state==='live' ? 'Live (Finnhub)' :
|
| 1047 |
+
state==='cached' ? 'Cached' :
|
| 1048 |
+
state==='error' ? 'API Error' :
|
| 1049 |
+
state==='nokey' ? 'No API Key' :
|
| 1050 |
+
state==='loading' ? 'Loadingβ¦' : 'Seed Data'
|
| 1051 |
+
}`;
|
| 1052 |
+
label.textContent = s.text;
|
| 1053 |
}
|
| 1054 |
|
| 1055 |
/* βββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 1464 |
}
|
| 1465 |
|
| 1466 |
/* βββββββββββββββββββββββββββββββββββββββββββββ
|
| 1467 |
+
API KEY MODAL
|
| 1468 |
βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 1469 |
+
function showKeyModal() {
|
| 1470 |
+
document.getElementById('keyModal').classList.remove('hidden');
|
| 1471 |
+
document.getElementById('keyInput').value = FINNHUB_KEY || '';
|
| 1472 |
+
document.getElementById('keyInput').focus();
|
| 1473 |
+
}
|
| 1474 |
+
function hideKeyModal() {
|
| 1475 |
+
document.getElementById('keyModal').classList.add('hidden');
|
| 1476 |
+
}
|
| 1477 |
+
async function confirmKey() {
|
| 1478 |
+
const k = document.getElementById('keyInput').value.trim();
|
| 1479 |
+
if (!k) { document.getElementById('keyInput').style.borderColor='var(--rose)'; return; }
|
| 1480 |
+
saveKey(k);
|
| 1481 |
+
hideKeyModal();
|
| 1482 |
+
document.getElementById('refreshLabel').textContent = 'β¬οΈ Fetch Live Data';
|
| 1483 |
+
// Auto-trigger fetch now that we have a key
|
| 1484 |
+
const tab = getVisibleDataKey();
|
| 1485 |
+
setCooldown(tab);
|
| 1486 |
+
startCooldownDisplay(tab);
|
| 1487 |
+
await fetchForActiveTab();
|
| 1488 |
+
}
|
| 1489 |
+
function clearKey() {
|
| 1490 |
+
saveKey('');
|
| 1491 |
+
document.getElementById('keyInput').value = '';
|
| 1492 |
+
updateStatusBar('nokey', getVisibleDataKey());
|
| 1493 |
+
}
|
| 1494 |
+
// Allow Enter key in the input
|
| 1495 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 1496 |
+
document.getElementById('keyInput')?.addEventListener('keydown', e => {
|
| 1497 |
+
if (e.key === 'Enter') confirmKey();
|
| 1498 |
+
if (e.key === 'Escape') hideKeyModal();
|
| 1499 |
+
});
|
| 1500 |
+
});
|
| 1501 |
document.addEventListener('DOMContentLoaded', () => {
|
| 1502 |
+
renderStocks(); renderOptSell(); renderLeaps(); renderETFHolds(); renderETFOpts();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1503 |
updateStats();
|
| 1504 |
|
|
|
|
| 1505 |
const tab = getVisibleDataKey();
|
| 1506 |
if (remainingCooldown(tab) > 0) startCooldownDisplay(tab);
|
| 1507 |
|
|
|
|
| 1508 |
const cached = getCache(tab);
|
| 1509 |
if (cached) {
|
| 1510 |
mergePrices(tab, cached);
|
| 1511 |
renderStocks();
|
| 1512 |
updateStatusBar('cached', tab);
|
| 1513 |
+
} else {
|
| 1514 |
+
updateStatusBar(hasKey() ? 'seed' : 'nokey', tab);
|
| 1515 |
}
|
| 1516 |
});
|
| 1517 |
</script>
|
| 1518 |
+
|
| 1519 |
+
<!-- βββββββββββ API KEY MODAL βββββββββββ -->
|
| 1520 |
+
<div class="modal-backdrop hidden" id="keyModal" onclick="if(event.target===this)hideKeyModal()">
|
| 1521 |
+
<div class="modal-box">
|
| 1522 |
+
<div class="modal-title">π Enter your <span>Finnhub API Key</span></div>
|
| 1523 |
+
<div class="modal-sub">
|
| 1524 |
+
FinWise uses <a href="https://finnhub.io/register" target="_blank">Finnhub.io</a> for real-time stock quotes.
|
| 1525 |
+
The free tier gives you <strong>60 calls/minute</strong> with no credit card required.<br><br>
|
| 1526 |
+
<strong>Steps:</strong> 1) Go to <a href="https://finnhub.io/register" target="_blank">finnhub.io/register</a>
|
| 1527 |
+
β 2) Sign up free β 3) Copy your API key from the dashboard β 4) Paste it below.
|
| 1528 |
+
</div>
|
| 1529 |
+
<input class="modal-input" id="keyInput" type="text"
|
| 1530 |
+
placeholder="e.g. d0abc123xyz456..." autocomplete="off" spellcheck="false" />
|
| 1531 |
+
<div class="modal-actions">
|
| 1532 |
+
<button class="btn btn-primary" onclick="confirmKey()">β
Save & Fetch Data</button>
|
| 1533 |
+
<button class="btn btn-ghost" onclick="hideKeyModal()">Cancel</button>
|
| 1534 |
+
<button class="btn btn-ghost" onclick="clearKey()" style="margin-left:auto;color:var(--rose);border-color:var(--rose)">π Clear Key</button>
|
| 1535 |
+
</div>
|
| 1536 |
+
<div class="modal-note">
|
| 1537 |
+
<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.
|
| 1538 |
+
</div>
|
| 1539 |
+
</div>
|
| 1540 |
+
</div>
|
| 1541 |
+
|
| 1542 |
</body>
|
| 1543 |
</html>
|