โ† ะะฐะทะฐะด
/** * Signals Tab โ€” Variant 3 (Hybrid: table + detail panel) * Fetches live signals + outcome stats */ const SIG_API = window.location.origin const SIG_REFRESH_MS = 30_000 // Clear any stale/zombie notifications on page load if (navigator.serviceWorker) { navigator.serviceWorker.ready.then(reg => { reg.getNotifications().then(nots => nots.forEach(n => n.close())) }) } const ALL_SIG_TYPES = ['volume_spike', 'oi_cvd', 'oi_divergence', 'oi_funding_squeeze', 'liq_sweep', 'channel'] const SIG_TYPE_LABELS = { volume_spike:'Vol Spike', oi_cvd:'OI+CVD', oi_divergence:'OI Div', oi_funding_squeeze:'Fund Squeeze', liq_sweep:'Liq Sweep', channel:'Channel' } const sigState = { signals: [], selected: null, outcomes: [], typeFilter: new Set(ALL_SIG_TYPES), // multi-select, all enabled by default dirFilter: '', search: '', minRatio: 3, // user-configurable: show volume spikes >= Nx refreshTimer: null, active: false, seenIds: new Set(), // track notified signal IDs firstLoad: true, // skip notifications on first load } // Pre-load signals on page load (for chart markers, before Signals tab is opened) // Always fetch ALL types โ€” type filtering is purely client-side (single source of truth) // Restore saved type filter for display only try { const saved = localStorage.getItem('sig_type_filter') if (saved) { const arr = JSON.parse(saved); if (Array.isArray(arr)) sigState.typeFilter = new Set(arr) } } catch {} fetch(`${SIG_API}/api/signals/live?limit=500&hours=24`) .then(r => r.json()) .then(d => { if (d?.success && d.data) sigState.signals = d.data }) .catch(() => {}) const sigEl = (id) => document.getElementById(id) // ---- Multi-select type dropdown ---- function _initSigTypeDropdown() { const btn = sigEl('sigTypeBtn') const dropdown = sigEl('sigTypeDropdown') if (!btn || !dropdown) return // Restore from localStorage try { const saved = localStorage.getItem('sig_type_filter') if (saved) { const arr = JSON.parse(saved) if (Array.isArray(arr)) { sigState.typeFilter = new Set(arr) dropdown.querySelectorAll('input[type="checkbox"]').forEach(cb => { cb.checked = sigState.typeFilter.has(cb.value) }) } } } catch {} // Toggle dropdown btn.addEventListener('click', (e) => { e.stopPropagation() dropdown.classList.toggle('open') }) // Close on outside click document.addEventListener('click', (e) => { if (!dropdown.contains(e.target) && e.target !== btn) dropdown.classList.remove('open') }) // Checkbox change dropdown.addEventListener('change', (e) => { if (e.target.type !== 'checkbox') return if (e.target.checked) { sigState.typeFilter.add(e.target.value) } else { sigState.typeFilter.delete(e.target.value) } localStorage.setItem('sig_type_filter', JSON.stringify([...sigState.typeFilter])) _updateSigTypeBtn() // Rerender table + markers (data already in sigState.signals, no need to refetch) renderSignals() if (typeof refreshSignalMarkers === 'function') refreshSignalMarkers() }) _updateSigTypeBtn() } function _updateSigTypeBtn() { const btn = sigEl('sigTypeBtn') if (!btn) return const n = sigState.typeFilter.size if (n === 0) { btn.textContent = 'No Types \u25be' } else if (n === ALL_SIG_TYPES.length) { btn.textContent = 'All Types \u25be' } else if (n <= 2) { btn.textContent = [...sigState.typeFilter].map(t => SIG_TYPE_LABELS[t] || t).join(', ') + ' \u25be' } else { btn.textContent = n + ' Types \u25be' } } // ---- Init / Stop ---- function initSignals() { if (sigState.active) { loadSignals(); return } sigState.active = true const dirF = sigEl('sigDirFilter') const searchF = sigEl('sigSearch') // Multi-select type filter _initSigTypeDropdown() if (dirF) dirF.onchange = () => { sigState.dirFilter = dirF.value; loadSignals() } if (searchF) searchF.oninput = () => { sigState.search = searchF.value; renderSignals() } // Load signal settings from settings panel (unified) if (typeof settingsPanel !== 'undefined') { sigState.minRatio = settingsPanel.get('signalMinRatio') || 3 settingsPanel.onChange((key, val) => { if (key === 'signalMinRatio') { sigState.minRatio = val renderSignals() } else if (key === 'signalMinConfidence') { renderSignals() } else if (key === 'signalWatchlistOnly' || key === '__watchlist') { renderSignals() } }) } else { const saved = localStorage.getItem('sig_settings') if (saved) { try { const s = JSON.parse(saved); if (s.minRatio) sigState.minRatio = s.minRatio } catch {} } } loadSignals() sigState.refreshTimer = setInterval(loadSignals, SIG_REFRESH_MS) } function stopSignals() { sigState.active = false if (sigState.refreshTimer) { clearInterval(sigState.refreshTimer) sigState.refreshTimer = null } } // ---- Fetch ---- async function loadSignals() { try { const params = new URLSearchParams({ limit: '500', hours: '24' }) // Always fetch ALL types โ€” type filtering is client-side only // Direction filter stays server-side (simple, no sync issues) if (sigState.dirFilter) params.set('direction', sigState.dirFilter) const [liveRes, summaryRes, outcomesRes] = await Promise.allSettled([ fetch(`${SIG_API}/api/signals/live?${params}`).then(r => r.json()), fetch(`${SIG_API}/api/signals/summary`).then(r => r.json()), fetch(`${SIG_API}/api/signals/outcomes`).then(r => r.json()), ]) if (liveRes.status === 'fulfilled' && liveRes.value?.success) { const newSignals = liveRes.value.data || [] notifyNewSignals(newSignals) sigState.signals = newSignals // Refresh markers on mini-charts + modal to match current filter if (typeof refreshSignalMarkers === 'function') refreshSignalMarkers() } if (outcomesRes.status === 'fulfilled' && outcomesRes.value?.success) sigState.outcomes = outcomesRes.value.stats || [] if (summaryRes.status === 'fulfilled' && summaryRes.value?.success) renderSummary(summaryRes.value) renderSignals() renderOutcomeStats() } catch (err) { console.error('[Signals] Load error:', err) } } // ---- Summary bar ---- function renderSummary(data) { const el = sigEl('sigSummary') if (!el) return const types = data.types || [] const byType = data.by_type || {} el.innerHTML = types.filter(t => (byType[t.id] || 0) > 0).map(t => { const count = byType[t.id] || 0 return `<span class="sig-summary-item"> <span class="sig-summary-dot" style="background:${t.color}"></span> ${t.icon} <span class="sig-summary-count">${count}</span> </span>` }).join('') + `<span class="sig-summary-item" style="margin-left:8px;">Total: <span class="sig-summary-count">${data.last_1h || 0}</span>/1h</span>` } // ---- Outcome Stats ---- function renderOutcomeStats() { const container = sigEl('sigOutcomeStats') if (!container) return const stats = sigState.outcomes if (!stats || stats.length === 0) { container.innerHTML = '<span style="color:var(--text-muted); font-size:11px;">Outcome tracking: collecting data...</span>' return } container.innerHTML = stats.map(s => { const wr = s.total > 0 ? ((s.wins / s.total) * 100).toFixed(0) : 'โ€”' const wrColor = wr >= 55 ? '#22c55e' : wr >= 45 ? '#f59e0b' : '#ef4444' const avgPnl = s.avg_pnl != null ? (s.avg_pnl > 0 ? '+' : '') + s.avg_pnl.toFixed(2) + '%' : 'โ€”' const pnlColor = s.avg_pnl > 0 ? '#22c55e' : '#ef4444' const icon = { volume_spike: '๐Ÿ“Š', oi_cvd: '๐Ÿ”ฎ', oi_divergence: '๐Ÿ”€', oi_funding_squeeze: 'โšก', liq_sweep: '๐ŸŽฏ' }[s.type] || 'โ€ข' return `<div class="sig-outcome-card"> <div class="sig-outcome-type">${icon} ${formatTypeShort(s.type)}</div> <div class="sig-outcome-row"> <span>WR</span><span style="color:${wrColor}; font-weight:600;">${wr}%</span> </div> <div class="sig-outcome-row"> <span>Avg P&L</span><span style="color:${pnlColor}; font-weight:600;">${avgPnl}</span> </div> <div class="sig-outcome-row"> <span>Signals</span><span>${s.total}</span> </div> </div>` }).join('') } // ---- Table ---- function renderSignals() { const tbody = sigEl('sigTbody') if (!tbody) return let list = [...sigState.signals] // Filter by selected signal types (multi-select) if (sigState.typeFilter.size > 0 && sigState.typeFilter.size < ALL_SIG_TYPES.length) { list = list.filter(s => sigState.typeFilter.has(s.type)) } else if (sigState.typeFilter.size === 0) { list = [] // nothing selected } // Filter volume spikes by min ratio if (sigState.minRatio > 0) { list = list.filter(s => { if (s.type !== 'volume_spike') return true const ratio = s.metadata?.ratio || 0 return ratio >= sigState.minRatio }) } // Filter by min confidence const minConf = typeof settingsPanel !== 'undefined' ? (settingsPanel.get('signalMinConfidence') || 50) : 50 if (minConf > 30) { list = list.filter(s => (s.confidence || 0) >= minConf) } // Filter by watchlist if enabled if (typeof settingsPanel !== 'undefined' && settingsPanel.get('signalWatchlistOnly')) { list = list.filter(s => settingsPanel.wlHas(s.symbol)) } // Filter liq_sweep by level type and wick ratio settings if (typeof settingsPanel !== 'undefined') { list = list.filter(s => { if (s.type !== 'liq_sweep') return true const m = s.metadata || {} // Level type filter const src = m.levelSource if (src === 'swing' && !settingsPanel.get('sweepLevelSwing')) return false if (src === 'wall' && !settingsPanel.get('sweepLevelWall')) return false if (src === 'round' && !settingsPanel.get('sweepLevelRound')) return false // Wick ratio filter const minWick = (settingsPanel.get('sweepMinWickPct') || 60) / 100 if ((m.wickRatio || 0) < minWick) return false return true }) } // Filter channel signals by timeframe settings if (typeof settingsPanel !== 'undefined') { list = list.filter(s => { if (s.type !== 'channel') return true const tf = s.metadata?.interval if (!tf) return true if (tf === '5m' && !settingsPanel.get('channelTf5m')) return false if (tf === '15m' && !settingsPanel.get('channelTf15m')) return false if (tf === '1h' && !settingsPanel.get('channelTf1h')) return false return true }) } if (sigState.search) { const q = sigState.search.toUpperCase() list = list.filter(s => s.symbol.includes(q)) } if (list.length === 0) { tbody.innerHTML = `<tr><td colspan="7" style="text-align:center; padding:40px; color:var(--text-muted);">No signals yet โ€” scanner runs every 60s</td></tr>` return } tbody.innerHTML = list.map(s => { const isActive = sigState.selected?.id === s.id ? ' sig-active' : '' const confColor = s.confidence >= 80 ? '#22c55e' : s.confidence >= 60 ? '#f59e0b' : '#ef4444' const typeLabel = formatType(s.type, s.metadata) return `<tr class="${isActive}" data-sig-id="${escAttr(s.id)}" onclick="selectSignal('${escAttr(s.id)}')"> <td class="sig-time">${formatTime(s.created_at)}</td> <td><span class="sig-type-badge ${s.type}">${typeLabel}</span></td> <td class="sig-symbol">${s.symbol.replace('USDT', '')}</td> <td><span class="sig-dir ${s.direction}">${s.direction === 'LONG' ? 'โ–ฒ L' : 'โ–ผ S'}</span></td> <td style="font-variant-numeric:tabular-nums;">${formatPrice(s.price)}</td> <td> <span class="sig-conf-text" style="color:${confColor}">${Math.round(s.confidence)}%</span> <span class="sig-conf-bar" style="width:${Math.round(s.confidence) * 0.5}px; background:${confColor};"></span> </td> <td class="sig-desc-col" style="color:var(--text-muted); font-size:11px;">${s.description || ''}</td> </tr>` }).join('') } // ---- Detail Panel ---- function selectSignal(id) { const s = sigState.signals.find(x => x.id === id) if (!s) return sigState.selected = s const tbody = sigEl('sigTbody') if (tbody) { tbody.querySelectorAll('tr').forEach(r => r.classList.remove('sig-active')) const row = tbody.querySelector(`tr[data-sig-id="${id}"]`) if (row) row.classList.add('sig-active') } const panel = sigEl('sigDetail') if (!panel) return // Mobile: show detail as overlay if (window.innerWidth <= 900) { panel.classList.add('mobile-open') } const meta = s.metadata || {} const confColor = s.confidence >= 80 ? '#22c55e' : s.confidence >= 60 ? '#f59e0b' : '#ef4444' const metaItems = [] // Volume spike metadata if (meta.ratio !== undefined) metaItems.push({ key: 'Volume Ratio', val: `${meta.ratio}x avg`, color: meta.ratio >= 5 ? '#22c55e' : '#3b82f6' }) if (meta.currentVol !== undefined) metaItems.push({ key: 'Current Vol (5m)', val: fmtVol(meta.currentVol) }) if (meta.avgVol !== undefined) metaItems.push({ key: 'Avg Vol (SMA20)', val: fmtVol(meta.avgVol) }) if (meta.candleChange !== undefined) metaItems.push({ key: 'Candle Chg', val: `${meta.candleChange > 0 ? '+' : ''}${meta.candleChange}%`, color: meta.candleChange > 0 ? '#22c55e' : '#ef4444' }) if (meta.change24h !== undefined) metaItems.push({ key: 'Change 24h', val: `${meta.change24h > 0 ? '+' : ''}${Number(meta.change24h).toFixed(2)}%`, color: meta.change24h > 0 ? '#22c55e' : '#ef4444' }) if (meta.oiChangePct !== undefined) metaItems.push({ key: 'OI Change', val: `${meta.oiChangePct > 0 ? '+' : ''}${meta.oiChangePct}%`, color: meta.oiChangePct > 0 ? '#3b82f6' : '#ef4444' }) if (meta.oiValue !== undefined) metaItems.push({ key: 'OI Value', val: fmtVol(meta.oiValue) }) if (meta.buySellRatio !== undefined) metaItems.push({ key: 'Buy/Sell', val: `${meta.buySellRatio}x`, color: meta.buySellRatio > 1 ? '#22c55e' : '#ef4444' }) if (meta.subType) metaItems.push({ key: 'Pattern', val: { oi_longs: 'Longs Accumulating', oi_shorts: 'Shorts Accumulating', oi_squeeze: 'Short Squeeze', oi_liquidation: 'Long Liquidation', oi_divergence: 'OI Divergence', oi_funding_squeeze: 'Funding Squeeze' }[meta.subType] || meta.subType }) if (meta.oiTrendPct !== undefined) metaItems.push({ key: 'OI Trend', val: `${meta.oiTrendPct > 0 ? '+' : ''}${meta.oiTrendPct}%`, color: meta.oiTrendPct > 0 ? '#3b82f6' : '#ef4444' }) if (meta.fundingPct !== undefined && s.type !== 'oi_cvd') metaItems.push({ key: 'Funding', val: `${meta.fundingPct > 0 ? '+' : ''}${meta.fundingPct}%`, color: meta.fundingPct > 0 ? '#22c55e' : '#ef4444' }) // Liq Sweep metadata if (meta.sweptLevel !== undefined) metaItems.push({ key: 'Swept Level', val: formatPrice(meta.sweptLevel), color: '#ef4444' }) if (meta.levelType) metaItems.push({ key: 'Level Type', val: meta.levelType.replace('_', ' '), color: meta.levelSource === 'swing' ? '#3b82f6' : meta.levelSource === 'wall' ? '#f59e0b' : '#94a3b8' }) if (meta.wickRatio !== undefined) metaItems.push({ key: 'Wick Ratio', val: `${(meta.wickRatio * 100).toFixed(0)}%`, color: meta.wickRatio >= 0.8 ? '#22c55e' : '#f59e0b' }) if (meta.sweepDepthPct !== undefined) metaItems.push({ key: 'Sweep Depth', val: `${meta.sweepDepthPct.toFixed(2)}%` }) if (meta.levelsSwept !== undefined && meta.levelsSwept > 1) metaItems.push({ key: 'Levels Swept', val: meta.levelsSwept, color: '#f59e0b' }) if (meta.bodyRatio !== undefined) metaItems.push({ key: 'Body Ratio', val: `${(meta.bodyRatio * 100).toFixed(0)}%` }) if (meta.volumeRatio !== undefined && meta.volumeRatio !== null) metaItems.push({ key: 'Volume Ratio', val: `${meta.volumeRatio}x`, color: meta.volumeRatio >= 2 ? '#22c55e' : '#94a3b8' }) if (meta.wallNotional !== undefined && meta.wallNotional !== null) metaItems.push({ key: 'Wall Size', val: fmtVol(meta.wallNotional) }) // Channel signal metadata if (s.type === 'channel') { const subLabels = { channel_bounce: 'โ†ฉ๏ธ Bounce', channel_reversal: '๐Ÿ”„ Reversal', channel_acceleration: '๐Ÿš€ Acceleration' } const slopeLabels = { up: '๐Ÿ“ˆ Ascending', down: '๐Ÿ“‰ Descending', flat: 'โžก๏ธ Flat' } if (meta.subType) metaItems.push({ key: 'Pattern', val: subLabels[meta.subType] || meta.subType, color: '#06b6d4' }) if (meta.interval) metaItems.push({ key: 'Timeframe', val: meta.interval.toUpperCase(), color: '#94a3b8' }) if (meta.slopeDir) metaItems.push({ key: 'Channel', val: slopeLabels[meta.slopeDir] || meta.slopeDir }) if (meta.slopePct !== undefined) metaItems.push({ key: 'Slope', val: `${meta.slopePct > 0 ? '+' : ''}${meta.slopePct.toFixed(3)}%/candle`, color: meta.slopePct > 0 ? '#22c55e' : meta.slopePct < 0 ? '#ef4444' : '#94a3b8' }) if (meta.r2 !== undefined) metaItems.push({ key: 'Rยฒ (fit)', val: meta.r2.toFixed(3), color: meta.r2 >= 0.85 ? '#22c55e' : meta.r2 >= 0.7 ? '#f59e0b' : '#ef4444' }) if (meta.period) metaItems.push({ key: 'Period', val: `${meta.period} candles` }) if (meta.bandWidthPct !== undefined) metaItems.push({ key: 'Band Width', val: `${meta.bandWidthPct.toFixed(2)}%` }) if (meta.touchCount !== undefined && meta.touchCount > 0) metaItems.push({ key: 'Touch #', val: `${meta.touchCount}${meta.touchCount >= 4 ? ' โš ๏ธ' : meta.touchCount >= 2 ? ' โœ“' : ''}`, color: meta.touchCount >= 4 ? '#f59e0b' : meta.touchCount >= 2 ? '#22c55e' : '#94a3b8' }) if (meta.wickRejection) metaItems.push({ key: 'Wick Rejection', val: 'โœ“ Yes', color: '#22c55e' }) if (meta.penetrationPct > 0) metaItems.push({ key: 'Penetration', val: `${meta.penetrationPct.toFixed(2)}%` }) if (meta.volumeRatio !== undefined) metaItems.push({ key: 'Volume', val: `${meta.volumeRatio}x avg`, color: meta.volumeRatio >= 3 ? '#22c55e' : '#94a3b8' }) if (meta.confluence > 1) metaItems.push({ key: 'Confluence', val: `${'โ˜…'.repeat(meta.confluence)} ${meta.timeframes?.join(',')}`, color: '#f59e0b' }) if (meta.channelUpper && meta.channelLower) metaItems.push({ key: 'Channel Range', val: `${formatPrice(meta.channelLower)} โ€” ${formatPrice(meta.channelUpper)}` }) } // Market context metadata (new enriched fields) if (meta.volume24h !== undefined && s.type !== 'channel') metaItems.push({ key: 'Volume 24h', val: fmtVol(meta.volume24h) }) if (meta.natr !== undefined && meta.natr !== null) metaItems.push({ key: 'NATR', val: `${meta.natr}%`, color: meta.natr >= 2 ? '#f59e0b' : meta.natr >= 1 ? '#22c55e' : '#94a3b8' }) if (meta.trades24h !== undefined && meta.trades24h > 0) metaItems.push({ key: 'Trades 24h', val: meta.trades24h >= 1e6 ? (meta.trades24h / 1e6).toFixed(1) + 'M' : meta.trades24h >= 1e3 ? (meta.trades24h / 1e3).toFixed(0) + 'K' : meta.trades24h.toString() }) if (meta.fundingRate !== undefined && meta.fundingRate !== null) metaItems.push({ key: 'Funding', val: `${meta.fundingRate > 0 ? '+' : ''}${meta.fundingRate}%`, color: meta.fundingRate > 0.01 ? '#22c55e' : meta.fundingRate < -0.01 ? '#ef4444' : '#94a3b8' }) if (meta.pricePosition !== undefined) metaItems.push({ key: '24h Range', val: `${meta.pricePosition}%`, color: meta.pricePosition >= 80 ? '#22c55e' : meta.pricePosition <= 20 ? '#ef4444' : '#94a3b8' }) if (meta.marketRank !== undefined) metaItems.push({ key: 'Vol Rank', val: `#${meta.marketRank}` }) panel.innerHTML = ` <button class="sig-detail-back" onclick="document.getElementById('sigDetail').classList.remove('mobile-open')">โ† Back</button> <div class="sig-detail-header"> <span class="sig-type-badge ${s.type}" style="font-size:13px;">${formatType(s.type, s.metadata)}</span> <span class="sig-detail-symbol">${s.symbol.replace('USDT', '')}</span> <span class="sig-dir ${s.direction}" style="font-size:13px;">${s.direction}</span> </div> <div class="sig-detail-section"> <div class="sig-detail-label">Price at Signal</div> <div class="sig-detail-price">${formatPrice(s.price)} USDT</div> </div> <div class="sig-detail-section"> <div class="sig-detail-label">Confidence</div> <div style="display:flex; align-items:center; gap:8px;"> <div style="flex:1; height:6px; background:rgba(255,255,255,0.06); border-radius:3px; overflow:hidden;"> <div style="width:${Math.round(s.confidence)}%; height:100%; background:${confColor}; border-radius:3px;"></div> </div> <span style="font-size:14px; font-weight:600; color:${confColor}">${Math.round(s.confidence)}%</span> </div> </div> <div class="sig-detail-section"> <div class="sig-detail-label">Description</div> <div class="sig-detail-value">${s.description || 'โ€”'}</div> </div> <div class="sig-detail-section"> <div class="sig-detail-label">Details</div> <div class="sig-detail-meta"> ${metaItems.map(m => ` <div class="sig-detail-meta-item"> <div class="sig-detail-meta-key">${m.key}</div> <div class="sig-detail-meta-val" ${m.color ? `style="color:${m.color}"` : ''}>${m.val}</div> </div> `).join('')} </div> </div> <div class="sig-detail-section"> <div class="sig-detail-label">Signal Time</div> <div class="sig-detail-value">${new Date(ensureUTC(s.created_at)).toLocaleString([], { timeZone: 'America/Vancouver' })}</div> </div> <button class="sig-detail-btn" onclick="openSignalChart('${escAttr(s.symbol)}')">Open Chart</button> ` } // ---- Open Chart (modal overlay, stays on Signals tab) ---- // Store pending signal marker for modal chart window._pendingSignalMarker = null function openSignalChart(symbol) { if (typeof openCoinModal !== 'function') return // Find signal data for marker const sig = sigState.selected if (sig && sig.symbol === symbol) { window._pendingSignalMarker = { time: Math.floor(new Date(ensureUTC(sig.created_at)).getTime() / 1000), price: sig.price, direction: sig.direction, type: sig.type, description: sig.description, } } openCoinModal(symbol) } // ---- Formatters ---- function formatType(type, metadata) { const map = { volume_spike: '๐Ÿ“Š Vol Spike', oi_cvd: '๐Ÿ”ฎ OI+CVD', oi_divergence: '๐Ÿ”€ OI Diver', oi_funding_squeeze: 'โšก Fund Squeeze', liq_sweep: '๐ŸŽฏ Liq Sweep', } if (type === 'channel' && metadata) { const subIcons = { channel_bounce: 'โ†ฉ๏ธ', channel_reversal: '๐Ÿ”„', channel_acceleration: '๐Ÿš€' } const subShort = { channel_bounce: 'Bounce', channel_reversal: 'Reversal', channel_acceleration: 'Accel' } const icon = subIcons[metadata.subType] || '๐Ÿ“' const sub = subShort[metadata.subType] || 'Ch' const tf = metadata.interval ? metadata.interval.toUpperCase() : '' return `${icon} ${sub} ${tf}` } return map[type] || type } function formatTypeShort(type) { const map = { volume_spike: 'Vol Spike', oi_cvd: 'OI+CVD', oi_divergence: 'OI Diver', oi_funding_squeeze: 'Fund Squeeze', liq_sweep: 'Liq Sweep' } return map[type] || type } function ensureUTC(iso) { if (!iso) return iso // DB stores "2026-04-18 07:20:00" (UTC, no T, no Z) โ€” normalize to ISO let s = iso.includes('T') ? iso : iso.replace(' ', 'T') if (!s.endsWith('Z') && !s.includes('+')) s += 'Z' return s } function formatTime(iso) { const d = new Date(ensureUTC(iso)) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'America/Vancouver' }) } function formatPrice(p) { if (!p || p === 0) return 'โ€”' if (p >= 1000) return p.toFixed(1) if (p >= 100) return p.toFixed(2) if (p >= 1) return p.toFixed(3) if (p >= 0.01) return p.toFixed(4) if (p >= 0.001) return p.toFixed(5) return p.toFixed(6) } // ---- Web Push subscription (server-sent, works even when browser closed) ---- function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4) const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') const rawData = atob(base64) const outputArray = new Uint8Array(rawData.length) for (let i = 0; i < rawData.length; i++) outputArray[i] = rawData.charCodeAt(i) return outputArray } async function subscribeToPush() { if (!('PushManager' in window)) { console.warn('[Push] PushManager not supported') return false } try { // Get VAPID public key from server const res = await fetch(`${SIG_API}/api/push/vapid-key`) const data = await res.json() if (!data.success || !data.key) { console.warn('[Push] Server push not configured') return false } const reg = await navigator.serviceWorker.ready // Check existing subscription let sub = await reg.pushManager.getSubscription() if (!sub) { const permission = await Notification.requestPermission() if (permission !== 'granted') { console.warn('[Push] Permission denied') return false } sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(data.key) }) console.log('[Push] New push subscription created') } // Send subscription + current filter preferences to server const sp = typeof settingsPanel !== 'undefined' ? settingsPanel : null const filters = { minConfidence: sp ? (sp.get('signalMinConfidence') || 50) : 50, minRatio: sp ? (sp.get('signalMinRatio') || 2) : 2, watchlistOnly: sp ? sp.get('signalWatchlistOnly') : false, watchlist: sp?.watchlist ? [...sp.watchlist] : [], types: sp ? (sp.get('signalTypes') || []) : [], channelTimeframes: sp ? ['5m', '15m', '1h'].filter(tf => sp.get('channelTf' + tf)) : ['5m', '15m', '1h'], } await fetch(`${SIG_API}/api/push/subscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subscription: sub.toJSON(), filters }) }) console.log('[Push] Subscribed to server push notifications') return true } catch (e) { console.error('[Push] Subscribe error:', e) return false } } async function unsubscribeFromPush() { try { const reg = await navigator.serviceWorker.ready const sub = await reg.pushManager.getSubscription() if (sub) { await fetch(`${SIG_API}/api/push/unsubscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ endpoint: sub.endpoint }) }) await sub.unsubscribe() console.log('[Push] Unsubscribed from push notifications') } } catch (e) { console.error('[Push] Unsubscribe error:', e) } } // Auto-subscribe on load if push is enabled (separate from in-tab notifications) ;(async () => { const sp = typeof settingsPanel !== 'undefined' ? settingsPanel : null if (sp?.get('signalPush')) { await subscribeToPush() } })() function fmtVol(v) { if (!v) return 'โ€”' if (v >= 1e9) return '$' + (v / 1e9).toFixed(1) + 'B' if (v >= 1e6) return '$' + (v / 1e6).toFixed(1) + 'M' if (v >= 1e3) return '$' + (v / 1e3).toFixed(0) + 'K' return '$' + v.toFixed(0) } // ---- In-tab signal tracking + sound (push notifications are now server-sent) ---- function notifyNewSignals(newList) { const sp = typeof settingsPanel !== 'undefined' ? settingsPanel : null // Always track seen IDs to keep UI in sync if (sigState.firstLoad) { sigState.firstLoad = false newList.forEach(s => sigState.seenIds.add(s.id)) return } const fresh = newList.filter(s => !sigState.seenIds.has(s.id)) fresh.forEach(s => sigState.seenIds.add(s.id)) // Cap seenIds to last 1000 if (sigState.seenIds.size > 1000) { const arr = [...sigState.seenIds] sigState.seenIds = new Set(arr.slice(-500)) } if (fresh.length === 0) return // Play sound if enabled (push notification is handled by SW from server push) if (sp?.get('signalSound') && sp?.get('signalNotifications')) { try { if (!window._sigAudioCtx) window._sigAudioCtx = new (window.AudioContext || window.webkitAudioContext)() const ac = window._sigAudioCtx if (ac.state === 'suspended') ac.resume() const osc = ac.createOscillator() const gain = ac.createGain() osc.connect(gain) gain.connect(ac.destination) osc.frequency.value = 880 gain.gain.value = 0.15 osc.start() gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.3) osc.stop(ac.currentTime + 0.3) } catch (e) {} } } // ---- Fetch signal by ID and set pending marker ---- async function setSignalMarkerAndOpen(symbol, signalId) { console.log(`[Signal] Opening ${symbol} with signalId=${signalId}`) try { const res = await fetch(`${SIG_API}/api/signals/live?limit=100&hours=4`) const data = await res.json() if (data.success) { // Try by ID first, then fallback to symbol match let sig = signalId ? (data.data || []).find(s => String(s.id) === String(signalId)) : null if (!sig) sig = (data.data || []).find(s => s.symbol === symbol) if (sig) { console.log(`[Signal] Marker set for ${sig.symbol} @ ${sig.price}, dir=${sig.direction}`) window._pendingSignalMarker = { time: Math.floor(new Date(ensureUTC(sig.created_at)).getTime() / 1000), price: sig.price, direction: sig.direction, type: sig.type, description: sig.description, } } else { console.log(`[Signal] No signal found for ${symbol} (id=${signalId})`) } } } catch (e) { console.error('[Signal] marker fetch error:', e) } if (typeof openCoinModal === 'function') openCoinModal(symbol) } // ---- Listen for SW messages (notification click โ†’ open modal) ---- if (navigator.serviceWorker) { navigator.serviceWorker.addEventListener('message', (e) => { if (e.data?.type === 'OPEN_SIGNAL' && e.data.symbol) { setSignalMarkerAndOpen(e.data.symbol, e.data.signalId) } }) } // ---- Check URL param on load (from notification click when app was closed) ---- (function checkSignalParam() { const params = new URLSearchParams(window.location.search) const sym = params.get('signal') const sid = params.get('sid') if (sym) { // Clean URL window.history.replaceState({}, '', '/') // Wait for app to init, then open modal with signal marker const tryOpen = () => { if (typeof openCoinModal === 'function' && typeof mc !== 'undefined' && mc.allPairs.length > 0) { setSignalMarkerAndOpen(sym, sid) } else { setTimeout(tryOpen, 500) } } setTimeout(tryOpen, 1000) } })()