โ† Back
โ˜†
/**
 * Signals Tab โ€” Variant 3 (Hybrid: table + detail panel)
 * Fetches live signals + outcome stats
 */

// Fallback if global escAttr (from index.html) isn't loaded yet
if (typeof escAttr === 'undefined') {
  window.escAttr = (s) => String(s).replace(/[&"'<>]/g, c => ({'&':'&amp;','"':'&quot;',"'":'&#39;','<':'&lt;','>':'&gt;'})[c])
}

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', 'orderflow_imbalance']
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', orderflow_imbalance:'Order-Flow' }

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)) {
      // Migration: add types saved before they existed
      if (!arr.includes('channel')) arr.push('channel')
      if (!arr.includes('orderflow_imbalance')) arr.push('orderflow_imbalance')
      sigState.typeFilter = new Set(arr)
      localStorage.setItem('sig_type_filter', JSON.stringify(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 safeFetch = (url) => fetch(url).then(r => {
      if (!r.ok) throw new Error(`HTTP ${r.status}`)
      return r.json()
    })
    const [liveRes, summaryRes, outcomesRes] = await Promise.allSettled([
      safeFetch(`${SIG_API}/api/signals/live?${params}`),
      safeFetch(`${SIG_API}/api/signals/summary`),
      safeFetch(`${SIG_API}/api/signals/outcomes`),
    ])

    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: '๐ŸŽฏ', channel: '๐Ÿ“', orderflow_imbalance: '๐Ÿ“–' }[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
    })
  }

  // Filter order-flow by its own min confidence (independent of global min confidence)
  if (typeof settingsPanel !== 'undefined') {
    const ofMin = settingsPanel.get('signalOfMinConf') || 50
    list = list.filter(s => s.type !== 'orderflow_imbalance' || (s.confidence || 0) >= ofMin)
  }

  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;">${escAttr(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() })
  const _fr = meta.fundingPct ?? meta.fundingRate // fundingPct is canonical, fundingRate is legacy fallback
  if (_fr !== undefined && _fr !== null) metaItems.push({ key: 'Funding', val: `${_fr > 0 ? '+' : ''}${_fr}%`, color: _fr > 0.01 ? '#22c55e' : _fr < -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}` })

  // Order-flow imbalance + paper trade ticket (how the bot WOULD open)
  if (meta.imbalance !== undefined) metaItems.push({ key: 'Imbalance', val: `${(meta.imbalance * 100).toFixed(0)}%`, color: meta.imbalance > 0 ? '#22c55e' : '#ef4444' })
  if (meta.ticket) {
    const tk = meta.ticket
    metaItems.push({ key: '๐ŸŽซ ะกะดะตะปะบะฐ', val: `${tk.side} ยท ${tk.exec}${tk.paper ? ' (ะฑัƒะผะฐะณะฐ)' : ''}` })
    metaItems.push({ key: 'ะ’ั…ะพะด', val: `${formatPrice(tk.entry)}` })
    metaItems.push({ key: `TP +${tk.tpPct}%`, val: formatPrice(tk.tp), color: '#22c55e' })
    metaItems.push({ key: `SL โˆ’${tk.slPct}%`, val: formatPrice(tk.sl), color: '#ef4444' })
    metaItems.push({ key: 'ะ ะฐะทะผะตั€', val: `${tk.qty} (${tk.notional}$ @${tk.leverage}x)` })
    if (tk.holdMaxMin) metaItems.push({ key: 'ะขะฐะนะผ-ัั‚ะพะฟ', val: `${tk.holdMaxMin} ะผะธะฝ` })
  }

  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">${escAttr(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',
    orderflow_imbalance: '๐Ÿ“– Order-Flow',
  }
  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', channel: 'Channel' }
  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()

    // Auto-resubscribe if VAPID key rotated
    if (sub && sub.options?.applicationServerKey) {
      const subKey = btoa(String.fromCharCode(...new Uint8Array(sub.options.applicationServerKey)))
        .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
      if (subKey !== data.key) {
        console.log('[Push] VAPID key rotated, resubscribing...')
        await sub.unsubscribe()
        sub = null
      }
    }

    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: sigState.typeFilter.size > 0 ? [...sigState.typeFilter] : [],
      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

  // Only play sound for signals matching user's type filter
  const relevant = sigState.typeFilter.size > 0
    ? fresh.filter(s => sigState.typeFilter.has(s.type))
    : []
  if (relevant.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 (max 30 retries = 15s)
    let retries = 0
    const MAX_RETRIES = 30
    const tryOpen = () => {
      if (typeof openCoinModal === 'function' && typeof mc !== 'undefined' && mc.allPairs.length > 0) {
        setSignalMarkerAndOpen(sym, sid)
      } else if (++retries < MAX_RETRIES) {
        setTimeout(tryOpen, 500)
      } else {
        console.warn('[Signals] Gave up waiting for app init to open signal', sym)
      }
    }
    setTimeout(tryOpen, 1000)
  }
})()

๐Ÿ“œ Git History

d91bbbdfeat(ui): separate Min Confidence slider for Order-Flow signals4 weeks ago
c35f030feat(ui): order-flow signal type in feed โ€” filter, push, paper ticket4 weeks ago
ddf9d20fix: cache bust + channel typeFilter migration for signals tab8 weeks ago
562f6d2fix: 14-point signal system audit โ€” critical bugs + UX fixes8 weeks ago
59232b4fix: 14-bug audit โ€” null guards, WS cleanup, shutdown flush, input validation9 weeks ago
0115d41fix: show sub-type + TF in signal list for channel signals9 weeks ago
42d9f53fix: signal UI โ€” hide empty summary badges, channel detail panel, chart markers9 weeks ago
558881ffeat: channel signal settings โ€” TF toggles (5m/15m/1h) + push filter9 weeks ago
0824a89feat: channel signal โ€” regression channel reversion & breakout9 weeks ago
4446d3afix: 12 silent failure audit + multi-select signal filter + sync fix9 weeks ago
Show last diff
Loading...