← Назад// Futures Screener - Densities UI
// Utilities and Core Helpers
const el = (id) => document.getElementById(id)
const qs = (selector) => document.querySelector(selector)
const qsa = (selector) => document.querySelectorAll(selector)
// escAttr() and lsSet() are defined in index.html inline <script> (loaded before all modules)
// Configuration
const CONFIG = {
API_BASE_URL: '/densities/simple',
DEFAULT_MIN_NOTIONAL: 50000,
DEFAULT_SYMBOLS: '',
REFRESH_INTERVALS: [5000, 10000, 20000],
DEFAULT_INTERVAL: 10000,
CACHE_DURATION: 30000, // 30 seconds client-side cache
PRESETS: {
'custom': {
name: 'Custom',
windowPct: 5.0,
minNotional: 0,
depthLimit: 100
}
}
}
// State
let state = {
blacklist: '', // Список монет для исключения
hideSqueezes: false, // скрывать маркет-мейкеров (Squeeze)
xFilter: 0,
natrFilter: 0,
interval: CONFIG.DEFAULT_INTERVAL,
sortField: 'score', // сортировка по умолчанию
sortAsc: false,
autoRefresh: false,
refreshTimer: null,
cache: {
data: null,
timestamp: 0,
cacheKey: null
},
lastError: null,
currentPreset: null,
watchlist: [], // Список символов в watchlist (из localStorage)
currentTab: 'mini-charts', // текущая вкладка
watchlistData: null // кэш данных watchlist
}
// Initialize
function init() {
console.log('Futures Screener init')
setupEventListeners()
// Load directly (no initial empty render)
loadWatchlist() // Загрузить watchlist из localStorage
// Default tab is mini-charts, init it on load
if (state.currentTab === 'mini-charts') {
initMiniCharts()
} else {
loadDensities(true)
}
// Prefetch top coins klines for instant modal open
prefetchTopKlines();
}
// Client-side kline cache for instant modal open
const modalKlineCache = {};
function prefetchTopKlines() {
const TOP_COINS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT'];
const tf = mc.globalTF || '5m';
TOP_COINS.forEach(sym => {
fetch(`/api/klines?symbol=${sym}&interval=${tf}&limit=500`)
.then(r => r.ok ? r.json() : null)
.then(json => {
if (Array.isArray(json) && json.length) {
modalKlineCache[`${sym}_${tf}`] = { data: json, ts: Date.now() };
}
})
.catch(() => {});
});
}
function setupEventListeners() {
// Old sidebar filters removed — density filters use defaults (xFilter=4, no blacklist)
// State defaults still apply for API calls
// Old sidebar removed — settings now via right slide-out panel (settings.js)
// Вкладки (tabs)
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'))
tab.classList.add('active')
const tabName = tab.dataset.tab
state.currentTab = tabName
// Скрыть все вкладки
document.querySelectorAll('.tab-content').forEach(tc => tc.style.display = 'none')
// Показать нужную
const targetContent = document.getElementById(`tab-${tabName}`)
if (targetContent) {
targetContent.style.display = 'block'
}
// Обновить UI в зависимости от вкладки
if (tabName === 'densities') {
if (state.cache.data) renderDensities(state.cache.data)
} else if (tabName === 'mini-charts') {
if (typeof initMiniCharts === 'function') {
initMiniCharts()
}
}
})
})
// Сортировка таблицы
document.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const field = th.dataset.sort
if (state.sortField === field) {
state.sortAsc = !state.sortAsc
} else {
state.sortField = field
state.sortAsc = false // по умолчанию по убыванию
}
// Update UI arrows
document.querySelectorAll('th.sortable').forEach(el => el.textContent = el.textContent.replace(/[▲▼]/g, '').trim())
th.textContent = `${th.textContent.trim()} ${state.sortAsc ? '▲' : '▼'}`
// Re-render only if data exists
if (state.cache.data) {
renderDensities(state.cache.data)
}
})
})
}
function getCacheKey() {
return JSON.stringify({
xFilter: state.xFilter,
natrFilter: state.natrFilter,
interval: state.interval
// blacklist and hideSqueezes apply locally, so they don't invalidate cache
})
}
function isCacheValid() {
const currentKey = getCacheKey()
return state.cache.data &&
state.cache.cacheKey === currentKey &&
(Date.now() - state.cache.timestamp) < CONFIG.CACHE_DURATION
}
function updateCache(data) {
state.cache = {
data,
timestamp: Date.now(),
cacheKey: getCacheKey()
}
// Сохранить данные для watchlist (используются при переключении вкладок)
state.watchlistData = data
}
// Load densities from API
async function loadDensities(forceRefresh = false) {
const stateEl = el('state')
const errorEl = el('error')
// Show loading state
stateEl.textContent = 'Загрузка...'
stateEl.classList.add('loading')
errorEl.classList.add('hidden')
try {
// Check cache
if (!forceRefresh && isCacheValid()) {
renderDensities(state.cache.data)
// Debug: count unique symbols
const uniqueSymbols = new Set(state.cache.data.map(e => e.symbol))
stateEl.textContent = `✅ Загружено: ${state.cache.data.length} уровней, ${uniqueSymbols.size} символов`
stateEl.classList.remove('loading')
return
}
// Build query params
const params = new URLSearchParams({
minNotional: 0,
minScore: 0,
windowPct: 5.0,
depthLimit: 100,
xFilter: state.xFilter,
natrFilter: state.natrFilter,
concurrency: 6,
mmMode: 'false' // backend handles clustering logic natively
})
const url = `${CONFIG.API_BASE_URL}?${params.toString()}&_t=${Date.now()}`
// Fetch data
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`)
}
const result = await response.json()
const data = result.data || []
// Update cache
updateCache(data)
// Render
if (state.currentTab === 'densities') {
renderDensities(data)
}
// Update status
const uniqueSymbols = new Set(data.map(e => e.symbol))
stateEl.textContent = `✅ Загружено: ${data.length} уровней, ${uniqueSymbols.size} символов`
stateEl.classList.remove('loading')
el('updated').textContent = `Last updated: ${new Date().toLocaleTimeString()}`
} catch (error) {
console.error('Load error:', error)
state.lastError = error.message
errorEl.textContent = error.message
errorEl.classList.remove('hidden')
stateEl.textContent = '❌ Ошибка'
stateEl.classList.remove('loading')
}
}
// Функция для индикатора объемов (5 свечей по 5 минут)
function renderVolIndicator(vol1, vol2, vol3, vol4, vol5, density) {
const getColor = (v) => {
if (!v || !density) return 'low';
if (v >= density * 0.5) return 'high';
if (v >= density * 0.2) return 'med';
return 'low';
};
// vol1 - самая новая свеча. Слева показываем самую старую (vol5), справа - самую новую (vol1)
return `
<div class="vol-indicator" title="Объемы (старые -> новые): ${formatNotional(vol5)} | ${formatNotional(vol4)} | ${formatNotional(vol3)} | ${formatNotional(vol2)} | ${formatNotional(vol1)}">
<div class="vol-block ${getColor(vol5)}"></div>
<div class="vol-block ${getColor(vol4)}"></div>
<div class="vol-block ${getColor(vol3)}"></div>
<div class="vol-block ${getColor(vol2)}"></div>
<div class="vol-block ${getColor(vol1)}"></div>
</div>
`;
}
// Render table (desktop)
function renderTable(entries) {
const tbody = el('tbody')
if (!entries || entries.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="muted" style="text-align:center; padding: 20px;">Нет данных</td></tr>'
return
}
// Сортируем данные напрямую (без группировки)
const sorted = [...entries].sort((a, b) => {
if (state.sortField === 'symbol') {
return state.sortAsc ? a.symbol.localeCompare(b.symbol) : b.symbol.localeCompare(a.symbol)
}
let fieldMap = state.sortField
if (fieldMap === 'distance') fieldMap = 'distancePct'
if (fieldMap === 'speed') fieldMap = 'timeToEatMinutes'
if (fieldMap === 'age') fieldMap = 'lifetimeSec'
const valA = a[fieldMap] || 0
const valB = b[fieldMap] || 0
return state.sortAsc ? (valA - valB) : (valB - valA)
})
const rows = sorted.map(entry => {
const symbol = entry.symbol
const inWatchlist = isSymbolInWatchlist(symbol)
const isMM = entry.levelsCount > 1
let stateDot = '<span style="color:var(--text-muted);">🛡️</span> <span style="color:var(--text-muted);">Waiting</span>'
if (entry.tags && entry.tags.length > 0) {
if (entry.tags.includes('SPOOF-FAR') || entry.tags.includes('NEW-FAR')) {
stateDot = '<span style="color:#ef4444;">❌</span> Спуфер'
} else if (entry.tags.includes('ROBOT-AGGRESSOR')) {
stateDot = '<span style="color:#f59e0b;">⚔️</span> Робот-толкач'
} else if (entry.tags.includes('CONCRETE-15M') || entry.tags.includes('CONCRETE-5M')) {
stateDot = '<span style="color:var(--neon-green);">🧱</span> Бетон'
} else if (entry.tags.includes('TECH-NATR')) {
stateDot = '<span style="color:#a855f7;">🎯</span> Тех.Уровень'
}
}
const sideBlock = entry.sideKey === 'bid'
? '<span style="color:#60a5fa; font-weight:600;">LONG (BID)</span>'
: '<span style="color:#fb923c; font-weight:600;">SHORT (ASK)</span>'
return `
<tr class="${isMM ? 'isMM' : ''}">
<td class="sym">
<a href="https://www.bybit.com/trade/usdt/${symbol}" target="_blank" style="margin-right: 6px;">${symbol.replace('USDT', '')}</a>
<a href="https://www.binance.com/en/futures/${symbol}" target="_blank" title="Binance Futures" style="text-decoration:none;">
<span style="display:inline-block; width:14px; height:14px; line-height:14px; text-align:center; background:#f3ba2f; color:#000; border-radius:50%; font-size:10px; font-weight:bold; vertical-align:middle; opacity:0.8; transition:opacity 0.2s;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.8'">B</span>
</a>
</td>
<td>${sideBlock}</td>
<td>
<span style="color:#e2e8f0; font-weight:500;">${formatNumber(entry.price, 4)}</span><br>
<span style="color:#94a3b8; font-size:11px;">${formatPercent(entry.distancePct)}</span>
</td>
<td style="font-family: monospace; font-size: 14px;">${formatNotional(entry.notional)}</td>
<td>${renderVolIndicator(entry.vol1, entry.vol2, entry.vol3, entry.vol4, entry.vol5, entry.notional)}</td>
<td class="natr">${(entry.natr || 0) > 0 ? entry.natr.toFixed(1) + '%' : '—'}</td>
<td class="score" style="color:var(--neon-yellow);">${(entry.score || 0).toFixed(1)}</td>
<td style="font-family: monospace; color: #a1a1aa;">${entry.lifetimeMins}m</td>
<td class="state-cell">${stateDot}</td>
<td style="font-family: monospace; color: #a1a1aa;">${formatTimeToEat(entry.timeToEatMinutes)}</td>
<td class="watchlist-btn">
<button class="btn-star ${inWatchlist ? 'active' : ''}" onclick="toggleWatchlist('${escAttr(symbol)}')">
${inWatchlist ? '⭐' : '☆'}
</button>
</td>
</tr>
`
}).join('')
tbody.innerHTML = rows
}
// Render table
function renderDensities(entries) {
if (!entries) return
// Применяем локальные фильтры (Blacklist и HideSqueezes)
let finalEntries = entries
if (state.blacklist && state.blacklist.trim() !== '') {
const blacklistArray = state.blacklist.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
if (blacklistArray.length > 0) {
finalEntries = finalEntries.filter(e => !blacklistArray.some(b => e.symbol.includes(b)))
}
}
// Авто-определение mobile/desktop
const isMobile = window.innerWidth <= 768 || /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
console.log('renderDensities:', isMobile, 'entries:', finalEntries.length)
entries = finalEntries
const cardsContainer = el('cardsContent')
const tableContainer = el('table-container')
console.log('Containers:', { cardsContainer: !!cardsContainer, tableContainer: !!tableContainer })
if (isMobile) {
console.log('Using cards')
if (!cardsContainer) {
console.error('cardsContent element not found!')
// Fallback: show error on page
const errDiv = document.createElement('div')
errDiv.style.cssText = 'color:red;padding:20px;'
errDiv.textContent = 'ERROR: cardsContent element not found'
document.body.appendChild(errDiv)
return
}
renderCards(entries)
cardsContainer.style.display = 'flex'
if (tableContainer) tableContainer.style.display = 'none'
} else {
console.log('Using table')
renderTable(entries)
if (cardsContainer) cardsContainer.style.display = 'none'
if (tableContainer) tableContainer.style.display = 'block'
}
}
// Format helpers
function formatNumber(value, decimals = 2) {
if (!value) return '—'
return Number(value).toFixed(decimals)
}
function formatPercent(value) {
if (!value) return '—'
return Number(value).toFixed(2) + '%'
}
function formatNotional(value) {
if (!value) return '—'
return new Intl.NumberFormat('en-US', {
maximumFractionDigits: 0,
notation: value >= 1000000 ? 'compact' : 'standard'
}).format(value)
}
function formatTimeToEat(minutes) {
if (!minutes || minutes === Infinity) return '∞'
if (minutes < 60) return `${Math.floor(minutes)}m`
const hours = Math.floor(minutes / 60)
const mins = Math.floor(minutes % 60)
if (hours < 24) return `${hours}h ${mins}m`
const days = Math.floor(hours / 24)
return `${days}d ${hours % 24}h`
}
function formatAge(seconds) {
if (seconds == null || isNaN(seconds)) return '—'
if (seconds < 60) return `${seconds}s`
const mins = Math.floor(seconds / 60)
if (mins < 60) return `${mins}m ${seconds % 60}s`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ${mins % 60}m`
const days = Math.floor(hours / 24)
return `${days}d ${hours % 24}h`
}
// Render cards (mobile)
function renderCards(entries) {
const container = el('cardsContent')
if (!entries || entries.length === 0) {
container.innerHTML = `<p style="padding:40px 20px;text-align:center;color:var(--text-muted);">Нет данных</p>`
return
}
const sorted = [...entries].sort((a, b) => {
let fieldMap = state.sortField
if (fieldMap === 'distance') fieldMap = 'distancePct'
if (fieldMap === 'speed') fieldMap = 'timeToEatMinutes'
if (fieldMap === 'age') fieldMap = 'lifetimeSec'
const valA = a[fieldMap] || 0
const valB = b[fieldMap] || 0
return state.sortAsc ? (valA - valB) : (valB - valA)
})
const cards = sorted.map(entry => {
const symbol = entry.symbol
const inWatchlist = isSymbolInWatchlist(symbol)
const isMM = entry.levelsCount > 1
let stateDot = '<span style="color:var(--text-muted);">🛡️</span> <span style="color:var(--text-muted);">Waiting</span>'
if (entry.tags && entry.tags.length > 0) {
if (entry.tags.includes('SPOOF-FAR') || entry.tags.includes('NEW-FAR')) {
stateDot = '<span style="color:#ef4444;">❌</span> Спуфер'
} else if (entry.tags.includes('ROBOT-AGGRESSOR')) {
stateDot = '<span style="color:#f59e0b;">⚔️</span> Робот-толкач'
} else if (entry.tags.includes('CONCRETE-15M') || entry.tags.includes('CONCRETE-5M')) {
stateDot = '<span style="color:var(--neon-green);">🧱</span> Бетон'
} else if (entry.tags.includes('TECH-NATR')) {
stateDot = '<span style="color:#a855f7;">🎯</span> Тех.Уровень'
}
}
const sideClass = entry.sideKey === 'bid' ? 'bid' : 'ask'
const sideIcon = entry.sideKey === 'bid' ? '<span style="color:#60a5fa;">🔵 LONG (BID)</span>' : '<span style="color:#fb923c;">🟠 SHORT (ASK)</span>'
return `
<div class="card ${isMM ? 'isMM' : ''}" data-symbol="${symbol}">
<div class="card-header">
<div>
<a href="https://www.bybit.com/trade/usdt/${symbol}" target="_blank" style="margin-right: 6px;">${symbol.replace('USDT', '')}</a>
<a href="https://www.binance.com/en/futures/${symbol}" target="_blank" title="Binance Futures" style="text-decoration:none;">
<span style="display:inline-block; width:16px; height:16px; line-height:16px; text-align:center; background:#f3ba2f; color:#000; border-radius:50%; font-size:11px; font-weight:bold; vertical-align:text-bottom; opacity:0.8;">B</span>
</a>
<button class="btn-star ${inWatchlist ? 'active' : ''}" style="margin-left:8px; background:none; border:none; color:inherit; cursor:pointer;" onclick="toggleWatchlist('${escAttr(symbol)}')">${inWatchlist ? '⭐' : '☆'}</button>
</div>
<div style="font-size:12px; opacity:0.8">${stateDot}</div>
</div>
<div class="card-body">
<div class="card-row ${sideClass} ${isMM ? 'isMM' : ''}">
<span class="label">${sideIcon}</span>
<span class="value" style="display:flex; flex-direction:column; align-items:flex-end;">
<span style="font-weight:500; font-size:14px;">${formatNumber(entry.price, 4)}</span>
<span class="dist" style="font-size:11px; margin-top:2px;">${formatPercent(entry.distancePct)}</span>
</span>
<span class="notional">${formatNotional(entry.notional)}</span>
</div>
<div class="card-row" style="margin-top:8px; padding-top:8px; border-top:1px solid rgba(255,255,255,0.05);">
<span class="label">Vol Indicator:</span>
<span class="value">${renderVolIndicator(entry.vol1, entry.vol2, entry.vol3, entry.vol4, entry.vol5, entry.notional)}</span>
</div>
<div class="card-row" style="margin-top:4px;">
<span class="label">NATR:</span>
<span class="value">${(entry.natr || 0) > 0 ? entry.natr.toFixed(1) + '%' : '—'}</span>
<span class="label" style="margin-left:10px">Score:</span>
<span class="value" style="color:var(--neon-yellow); font-weight: 600;">${(entry.score || 0).toFixed(1)}</span>
</div>
<div class="card-row" style="margin-top:4px;">
<span class="label">Age (Mins):</span>
<span class="value" style="color: #a1a1aa;">${entry.lifetimeMins}m</span>
<span class="label" style="margin-left:10px">Time To Eat:</span>
<span class="value" style="color: #a1a1aa;">${formatTimeToEat(entry.timeToEatMinutes)}</span>
</div>
</div>
</div>
`
}).join('')
container.innerHTML = cards
}
// Auto refresh
function startAutoRefresh() {
stopAutoRefresh()
state.refreshTimer = setInterval(() => loadDensities(), state.interval)
}
function stopAutoRefresh() {
if (state.refreshTimer) {
clearInterval(state.refreshTimer)
state.refreshTimer = null
}
}
// Watchlist functions
function loadWatchlist() {
try {
const saved = localStorage.getItem('futures-screener-watchlist')
if (saved) {
state.watchlist = JSON.parse(saved)
}
} catch (err) {
console.error('Failed to load watchlist:', err)
state.watchlist = []
}
}
function saveWatchlist() {
try {
localStorage.setItem('futures-screener-watchlist', JSON.stringify(state.watchlist))
} catch (err) {
console.error('Failed to save watchlist:', err)
}
}
function addToWatchlist(symbol) {
if (!state.watchlist.includes(symbol)) {
state.watchlist.push(symbol)
saveWatchlist()
}
}
function removeFromWatchlist(symbol) {
state.watchlist = state.watchlist.filter(s => s !== symbol)
saveWatchlist()
}
function isSymbolInWatchlist(symbol) {
return state.watchlist.includes(symbol)
}
// Глобальная функция для кнопок (используется в onclick)
window.toggleWatchlist = function (symbol) {
if (state.watchlist.includes(symbol)) {
removeFromWatchlist(symbol)
} else {
addToWatchlist(symbol)
}
// Перерисовать watchlist, если сейчас на вкладке watchlist
if (state.currentTab === 'watchlist') {
renderWatchlist(state.watchlistData || [])
}
}
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', init)
// Render watchlist view (mobile + desktop)
function renderWatchlist(entries) {
const container = el('cardsContent')
const table = el('table-container')
if (!entries || entries.length === 0) {
if (el('cardsContent').style.display !== 'none') {
container.innerHTML = `<p style="padding:40px 20px;text-align:center;color:var(--text-muted);">Watchlist пуст. Добавьте символы, нажав на ⭐.</p>`
} else {
table.innerHTML = `<table class="table"><thead><tr><th colspan="9" style="text-align:center;color:var(--text-muted);">Watchlist пуст. Добавьте символы, нажав на ⭐.</th></tr></thead></table>`
}
return
}
// Для watchlist показываем только символы из списка
const watchlistEntries = entries.filter(d => state.watchlist.includes(d.symbol))
if (watchlistEntries.length === 0) {
if (el('cardsContent').style.display !== 'none') {
container.innerHTML = `<p style="padding:40px 20px;text-align:center;color:var(--text-muted);">В watchlist нет уровней с текущими фильтрами.</p>`
} else {
table.innerHTML = `<table class="table"><thead><tr><th colspan="9" style="text-align:center;color:var(--text-muted);">В watchlist нет уровней с текущими фильтрами.</th></tr></thead></table>`
}
return
}
renderDensities(watchlistEntries)
}
// ==========================================
// Mini-Charts v3 — Full Market Screener
// Uses IntersectionObserver to only render visible charts
// ==========================================
const mc = {
sortBy: 'volume',
globalTF: '15m',
loaded: false,
allPairs: [], // all fetched pairs (unfiltered)
filteredPairs: [], // after filters applied
charts: {}, // { sym: { chart, series, lines[] } } — only visible ones
loadedData: {}, // { sym: true } — tracks which symbols have been loaded
observer: null, // IntersectionObserver
loadQueue: [], // queue for staggered loading
loadingActive: false,
filters: { minVol: 50, minNatr: 0, minTrades: 0 }
};
async function initMiniCharts() {
if (!mc.loaded) {
mc.loaded = true;
// Global TF buttons
const tfGroup = el('mcGlobalTF');
if (tfGroup) {
tfGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.mc-tf-btn');
if (!btn) return;
tfGroup.querySelectorAll('.mc-tf-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
mc.globalTF = btn.dataset.tf;
// Reload all currently visible charts with new TF
mc.loadedData = {};
Object.keys(mc.charts).forEach(sym => {
mc.loadQueue.push(sym);
});
processLoadQueue();
});
}
// Refresh button
const refreshBtn = el('mcRefreshBtn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => refreshMiniCharts());
}
// Init modal events
initModalEvents();
// Setup IntersectionObserver
mc.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const sym = entry.target.dataset.symbol;
if (!sym) return;
if (entry.isIntersecting) {
// Card scrolled into view — create chart & load data
if (!mc.charts[sym]) {
createChartInstance(sym);
mc.loadQueue.push(sym);
processLoadQueue();
}
} else {
// Card scrolled out — destroy chart to free memory
if (mc.charts[sym]) {
mc.charts[sym].chart.remove();
delete mc.charts[sym];
delete mc.loadedData[sym];
}
}
});
}, {
root: null,
rootMargin: '200px', // preload 200px before visible
threshold: 0
});
}
if (mc.allPairs.length === 0) {
await refreshMiniCharts();
}
}
async function refreshMiniCharts() {
const status = el('mcStatus');
if (status) status.textContent = 'Loading...';
try {
const res = await fetch('/api/ticker24hr');
const data = await res.json();
let pairs = data.filter(d => d.symbol.endsWith('USDT') && !d.symbol.includes('_'));
pairs.forEach(p => {
const h = parseFloat(p.highPrice);
const l = parseFloat(p.lowPrice);
p.proxyNatr = l > 0 ? ((h - l) / l * 100) : 0;
p.quoteVol = parseFloat(p.quoteVolume);
p.tradesCount = parseInt(p.count);
p.priceChange = parseFloat(p.priceChangePercent);
p.lastPrice = parseFloat(p.lastPrice);
});
// Filter out frozen/halted/delisted pairs
// Frozen pairs have closeTime far in the past (trading stopped)
const now = Date.now();
pairs = pairs.filter(p => {
const closeTime = parseInt(p.closeTime);
const age = now - closeTime;
// If last trade was >1 hour ago, pair is frozen
if (age > 3600000) return false;
// Also filter flat pairs where high == low
if (parseFloat(p.highPrice) === parseFloat(p.lowPrice)) return false;
return true;
});
mc.allPairs = pairs;
applyFiltersAndRebuild();
if (status) {
status.textContent = `${mc.filteredPairs.length}/${pairs.length}`;
}
} catch (e) {
console.error('Mini-Charts fetch error:', e);
if (status) status.textContent = 'Error';
}
}
function applyFiltersAndRebuild() {
mc.filteredPairs = mc.allPairs.filter(p => {
if (mc.filters.minVol > 0 && p.quoteVol < mc.filters.minVol * 1e6) return false;
if (mc.filters.minNatr > 0 && p.proxyNatr < mc.filters.minNatr) return false;
if (mc.filters.minTrades > 0 && p.tradesCount < mc.filters.minTrades) return false;
return true;
});
rebuildGrid();
const status = el('mcStatus');
if (status) status.textContent = `${mc.filteredPairs.length}/${mc.allPairs.length}`;
}
function rebuildGrid() {
// Destroy all existing charts
Object.keys(mc.charts).forEach(sym => {
mc.charts[sym].chart.remove();
delete mc.charts[sym];
});
mc.loadedData = {};
mc.loadQueue = [];
sortPairs();
renderSidebar();
const grid = el('chartsGrid');
if (!grid) return;
// Disconnect old observations
mc.observer.disconnect();
// Render ALL cards (lightweight — just header + empty body)
grid.innerHTML = mc.filteredPairs.map(p => {
const sym = p.symbol;
const ticker = sym.replace('USDT', '');
const chg = p.priceChange;
const chgClass = chg >= 0 ? 'mc-metric-green' : 'mc-metric-red';
const chgSign = chg >= 0 ? '+' : '';
const vol = p.quoteVol >= 1e9 ? (p.quoteVol / 1e9).toFixed(1) + 'B' : (p.quoteVol / 1e6).toFixed(0) + 'M';
const natr = p.proxyNatr.toFixed(1);
return `<div class="mc-chart-card" data-symbol="${sym}" id="mc-card-${sym}">
<div class="mc-chart-header">
<span class="mc-chart-symbol">${ticker}</span>
<div class="mc-chart-metrics">
<span class="${chgClass}">${chgSign}${chg.toFixed(2)}%</span>
<span class="mc-metric-muted">$${vol}</span>
<span class="mc-metric-muted">R${natr}%</span>
</div>
</div>
<div class="mc-chart-body" id="mc-body-${sym}"></div>
</div>`;
}).join('');
// Observe all cards + click to open modal
grid.querySelectorAll('.mc-chart-card').forEach(card => {
mc.observer.observe(card);
card.querySelector('.mc-chart-header').addEventListener('click', () => {
openCoinModal(card.dataset.symbol);
});
});
}
function sortPairs() {
const sorter = (a, b) => {
if (mc.sortBy === 'natr') return b.proxyNatr - a.proxyNatr;
if (mc.sortBy === 'trades') return b.tradesCount - a.tradesCount;
if (mc.sortBy === 'change') return Math.abs(b.priceChange) - Math.abs(a.priceChange);
return b.quoteVol - a.quoteVol;
};
mc.allPairs.sort(sorter);
mc.filteredPairs.sort(sorter);
}
function renderSidebar() {
const list = el('mcCoinList');
const countEl = el('mcCoinCount');
if (!list) return;
if (countEl) countEl.textContent = mc.filteredPairs.length;
list.innerHTML = mc.filteredPairs.map(p => {
const sym = p.symbol;
const ticker = sym.replace('USDT', '');
const chg = p.priceChange;
const chgClass = chg >= 0 ? 'mc-metric-green' : 'mc-metric-red';
const chgSign = chg >= 0 ? '+' : '';
const vol = p.quoteVol >= 1e9 ? (p.quoteVol / 1e9).toFixed(1) + 'B' : (p.quoteVol / 1e6).toFixed(0) + 'M';
return `<div class="mc-coin-item" data-symbol="${sym}">
<div>
<span class="mc-coin-name">${ticker}</span>
<span class="mc-coin-vol">$${vol}</span>
</div>
<span class="mc-coin-change ${chgClass}">${chgSign}${chg.toFixed(2)}%</span>
</div>`;
}).join('');
// Click handler — open coin modal
list.querySelectorAll('.mc-coin-item').forEach(item => {
item.addEventListener('click', () => {
openCoinModal(item.dataset.symbol);
});
});
}
function getPricePrecision(price) {
if (price >= 1000) return 2;
if (price >= 1) return 4;
if (price >= 0.01) return 5;
if (price >= 0.001) return 6;
return 8;
}
function createChartInstance(sym) {
const chartEl = el(`mc-body-${sym}`);
if (!chartEl || mc.charts[sym]) return;
// Get price for precision
const pair = mc.allPairs.find(p => p.symbol === sym);
const price = pair ? pair.lastPrice : 1;
const prec = getPricePrecision(price);
const minMove = parseFloat((1 / Math.pow(10, prec)).toFixed(prec));
const chart = LightweightCharts.createChart(chartEl, {
autoSize: true,
layout: { background: { type: 'solid', color: 'transparent' }, textColor: '#64748b' },
grid: { vertLines: { color: 'rgba(255,255,255,0.02)' }, horzLines: { color: 'rgba(255,255,255,0.02)' } },
crosshair: { mode: 0 },
rightPriceScale: { borderColor: 'rgba(255,255,255,0.06)', scaleMargins: { top: 0.1, bottom: 0.1 } },
timeScale: { borderColor: 'rgba(255,255,255,0.06)', timeVisible: true, secondsVisible: false },
handleScroll: { mouseWheel: true, pressedMouseMove: true },
handleScale: { mouseWheel: true, pinch: true },
});
const series = chart.addSeries(LightweightCharts.CandlestickSeries, {
upColor: '#22c55e', downColor: '#ef4444',
borderVisible: false,
wickUpColor: '#22c55e', wickDownColor: '#ef4444',
priceFormat: { type: 'price', precision: prec, minMove: minMove }
});
mc.charts[sym] = { chart, series, lines: [] };
// Attach shift+drag ruler
attachRuler(chartEl, chart, series);
}
// Staggered load queue — prevents Binance rate limiting
async function processLoadQueue() {
if (mc.loadingActive) return;
mc.loadingActive = true;
while (mc.loadQueue.length > 0) {
const sym = mc.loadQueue.shift();
if (!mc.charts[sym]) continue; // already scrolled away
if (mc.loadedData[sym]) continue; // already loaded this TF
await loadChartData(sym, mc.globalTF);
await new Promise(r => setTimeout(r, 80));
}
mc.loadingActive = false;
}
async function loadChartData(sym, tf) {
if (!mc.charts[sym]) return;
try {
const res = await fetch(`/api/klines?symbol=${sym}&interval=${tf}&limit=200`);
const json = await res.json();
if (!Array.isArray(json)) return;
const data = json.map(k => ({
time: k[0] / 1000,
open: parseFloat(k[1]),
high: parseFloat(k[2]),
low: parseFloat(k[3]),
close: parseFloat(k[4]),
highRaw: parseFloat(k[2]),
lowRaw: parseFloat(k[3])
}));
if (!mc.charts[sym]) return; // check again after await
const series = mc.charts[sym].series;
series.setData(data);
mc.charts[sym].chart.timeScale().fitContent();
mc.loadedData[sym] = true;
setTimeout(() => {
if (mc.charts[sym]) mc.charts[sym].chart.timeScale().fitContent();
}, 150);
// Auto-levels disabled for now
// if (mc.charts[sym].lines.length > 0) {
// mc.charts[sym].lines.forEach(l => series.removePriceLine(l));
// }
// mc.charts[sym].lines = [];
// drawAutoLevels(sym, data, series);
} catch (e) {
console.error(`Chart load error ${sym}:`, e);
}
}
function drawAutoLevels(sym, data, series) {
const WINDOW = 5;
const highs = [];
const lows = [];
for (let i = WINDOW; i < data.length - WINDOW; i++) {
let isHigh = true;
let isLow = true;
for (let j = i - WINDOW; j <= i + WINDOW; j++) {
if (i === j) continue;
if (data[j].highRaw >= data[i].highRaw) isHigh = false;
if (data[j].lowRaw <= data[i].lowRaw) isLow = false;
}
if (isHigh) highs.push({ time: data[i].time, price: data[i].highRaw });
if (isLow) lows.push({ time: data[i].time, price: data[i].lowRaw });
}
const THRESHOLD_PCT = 0.003;
const levels = [];
const findClusters = (pivots, type) => {
const used = new Set();
for (let i = 0; i < Math.min(pivots.length, 50); i++) {
if (used.has(i)) continue;
const cluster = [pivots[i]];
for (let j = i + 1; j < pivots.length; j++) {
if (used.has(j)) continue;
if (Math.abs(pivots[i].price - pivots[j].price) / pivots[i].price < THRESHOLD_PCT) {
cluster.push(pivots[j]);
used.add(j);
}
}
if (cluster.length >= 2) {
const avgPrice = cluster.reduce((sum, p) => sum + p.price, 0) / cluster.length;
levels.push({ price: avgPrice, type, weight: cluster.length });
}
}
};
findClusters(highs, 'resistance');
findClusters(lows, 'support');
const supports = levels.filter(l => l.type === 'support').sort((a, b) => b.weight - a.weight).slice(0, 2);
const resists = levels.filter(l => l.type === 'resistance').sort((a, b) => b.weight - a.weight).slice(0, 2);
[...supports, ...resists].forEach(l => {
const line = series.createPriceLine({
price: l.price,
color: l.type === 'support' ? '#22c55e' : '#ef4444',
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: '',
});
mc.charts[sym].lines.push(line);
});
}
// ==========================================
// Shift+Drag Ruler (like TradingView)
// ==========================================
function attachRuler(chartEl, chart, series) {
let rulerActive = false;
let startX = 0, startY = 0;
let startPrice = 0, startTime = 0;
let line = null, label = null;
function createOverlay() {
// SVG line
line = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
line.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:100;';
line.innerHTML = '<line x1="0" y1="0" x2="0" y2="0" stroke="#5b9cf6" stroke-width="1.5" stroke-dasharray="4,3"/>';
chartEl.appendChild(line);
// Label
label = document.createElement('div');
label.style.cssText = 'position:absolute;z-index:101;pointer-events:none;background:rgba(59,130,246,0.9);color:#fff;font-size:11px;font-weight:600;padding:3px 8px;border-radius:4px;white-space:nowrap;display:none;';
chartEl.appendChild(label);
}
function removeOverlay() {
if (line) { line.remove(); line = null; }
if (label) { label.remove(); label = null; }
}
chartEl.addEventListener('mousedown', (e) => {
if (!e.shiftKey) return;
e.preventDefault();
e.stopPropagation();
const rect = chartEl.getBoundingClientRect();
startX = e.clientX - rect.left;
startY = e.clientY - rect.top;
// Convert pixel to price/time
startPrice = series.coordinateToPrice(startY);
startTime = chart.timeScale().coordinateToTime(startX);
if (startPrice === null) return;
rulerActive = true;
removeOverlay();
createOverlay();
// Temporarily disable chart interaction
chart.applyOptions({ handleScroll: false, handleScale: false });
});
chartEl.addEventListener('mousemove', (e) => {
if (!rulerActive || !line || !label) return;
const rect = chartEl.getBoundingClientRect();
const curX = e.clientX - rect.left;
const curY = e.clientY - rect.top;
const curPrice = series.coordinateToPrice(curY);
if (curPrice === null) return;
// Update SVG line
const svgLine = line.querySelector('line');
svgLine.setAttribute('x1', startX);
svgLine.setAttribute('y1', startY);
svgLine.setAttribute('x2', curX);
svgLine.setAttribute('y2', curY);
// Calculate diff
const priceDiff = curPrice - startPrice;
const pctDiff = startPrice !== 0 ? (priceDiff / startPrice * 100) : 0;
const prec = getPricePrecision(Math.abs(startPrice));
const sign = priceDiff >= 0 ? '+' : '';
const color = priceDiff >= 0 ? '#22c55e' : '#ef4444';
// Position label
const midX = (startX + curX) / 2;
const midY = Math.min(startY, curY) - 8;
label.style.left = midX + 'px';
label.style.top = Math.max(2, midY) + 'px';
label.style.transform = 'translateX(-50%)';
label.style.display = 'block';
label.style.background = priceDiff >= 0 ? 'rgba(34,197,94,0.9)' : 'rgba(239,68,68,0.9)';
label.textContent = `${sign}${priceDiff.toFixed(prec)} (${sign}${pctDiff.toFixed(2)}%)`;
});
const endRuler = () => {
if (!rulerActive) return;
rulerActive = false;
chart.applyOptions({
handleScroll: { mouseWheel: true, pressedMouseMove: true },
handleScale: { mouseWheel: true, pinch: true }
});
// Remove after 3 seconds
setTimeout(removeOverlay, 3000);
};
chartEl.addEventListener('mouseup', endRuler);
chartEl.addEventListener('mouseleave', endRuler);
}
// ==========================================
// Coin Detail Modal
// ==========================================
const modal = {
chart: null,
series: null,
lines: [],
currentSym: null,
currentTF: '15m'
};
function openCoinModal(sym) {
const pair = mc.allPairs.find(p => p.symbol === sym);
if (!pair) return;
modal.currentSym = sym;
modal.currentTF = mc.globalTF;
const ticker = sym.replace('USDT', '');
const prec = getPricePrecision(pair.lastPrice);
const chg = pair.priceChange;
const chgClass = chg >= 0 ? 'mc-metric-green' : 'mc-metric-red';
const chgSign = chg >= 0 ? '+' : '';
// Header
el('cmSymbol').textContent = ticker + '/USDT';
el('cmPrice').textContent = '$' + pair.lastPrice.toFixed(prec);
const cmChange = el('cmChange');
cmChange.textContent = chgSign + chg.toFixed(2) + '%';
cmChange.className = 'mc-modal-change ' + chgClass;
// Stats
const vol = pair.quoteVol >= 1e9 ? (pair.quoteVol / 1e9).toFixed(2) + 'B' : (pair.quoteVol / 1e6).toFixed(1) + 'M';
const tradesStr = pair.tradesCount >= 1e6 ? (pair.tradesCount / 1e6).toFixed(1) + 'M'
: pair.tradesCount >= 1e3 ? (pair.tradesCount / 1e3).toFixed(1) + 'K'
: pair.tradesCount.toString();
el('cmStats').innerHTML = `
<div class="mc-stat"><span class="mc-stat-label">24h Vol:</span><span class="mc-stat-value">$${vol}</span></div>
<div class="mc-stat"><span class="mc-stat-label">Range:</span><span class="mc-stat-value">${pair.proxyNatr.toFixed(1)}%</span></div>
<div class="mc-stat"><span class="mc-stat-label">Trades:</span><span class="mc-stat-value">${tradesStr}</span></div>
<div class="mc-stat"><span class="mc-stat-label">High:</span><span class="mc-stat-value">${parseFloat(pair.highPrice).toFixed(prec)}</span></div>
<div class="mc-stat"><span class="mc-stat-label">Low:</span><span class="mc-stat-value">${parseFloat(pair.lowPrice).toFixed(prec)}</span></div>
`;
// TF buttons — set active
const tfBtns = el('cmTFButtons');
tfBtns.querySelectorAll('.mc-tf-btn').forEach(b => {
b.classList.toggle('active', b.dataset.tf === modal.currentTF);
});
// Show modal (CSS: visibility:hidden→visible, NOT display:none)
el('coinModal').classList.remove('hidden');
// Create or recreate chart
if (modal.chart) {
modal.chart.remove();
modal.chart = null;
}
const chartEl = el('cmChartBody');
const minMove = parseFloat((1 / Math.pow(10, prec)).toFixed(prec));
modal.chart = LightweightCharts.createChart(chartEl, {
autoSize: true,
layout: { background: { type: 'solid', color: 'transparent' }, textColor: '#94a3b8' },
grid: { vertLines: { color: 'rgba(255,255,255,0.03)' }, horzLines: { color: 'rgba(255,255,255,0.03)' } },
crosshair: { mode: 0 },
rightPriceScale: { borderColor: 'rgba(255,255,255,0.08)', scaleMargins: { top: 0.05, bottom: 0.05 } },
timeScale: { borderColor: 'rgba(255,255,255,0.08)', timeVisible: true, secondsVisible: false },
handleScroll: { mouseWheel: true, pressedMouseMove: true },
handleScale: { mouseWheel: true, pinch: true },
});
modal.series = modal.chart.addSeries(LightweightCharts.CandlestickSeries, {
upColor: '#22c55e', downColor: '#ef4444',
borderVisible: false,
wickUpColor: '#22c55e', wickDownColor: '#ef4444',
priceFormat: { type: 'price', precision: prec, minMove: minMove }
});
modal.lines = [];
// Attach ruler to modal chart
attachRuler(chartEl, modal.chart, modal.series);
// Show loader
const loader = el('cmChartLoader');
if (loader) {
loader.innerHTML = '<div class="cm-spinner"></div><span>Loading chart...</span>';
loader.classList.remove('hidden');
}
loadModalChart(sym, modal.currentTF);
}
async function loadModalChart(sym, tf) {
const loader = el('cmChartLoader');
const showLoader = () => {
if (loader) {
loader.innerHTML = '<div class="cm-spinner"></div><span>Loading chart...</span>';
loader.classList.remove('hidden');
}
};
const hideLoader = () => { if (loader) loader.classList.add('hidden'); };
const showError = (msg) => {
if (loader) {
loader.innerHTML = `<div class="cm-chart-error">
<div>${msg}</div>
<button onclick="loadModalChart('${sym}','${tf}')">Retry</button>
</div>`;
loader.classList.remove('hidden');
}
};
const cacheKey = `${sym}_${tf}`;
const cached = modalKlineCache[cacheKey];
const CACHE_TTL = 60000; // 60s — use cache if fresh
// Parse raw klines to chart format
const parseKlines = (json) => json.map(k => ({
time: k[0] / 1000,
open: parseFloat(k[1]),
high: parseFloat(k[2]),
low: parseFloat(k[3]),
close: parseFloat(k[4]),
highRaw: parseFloat(k[2]),
lowRaw: parseFloat(k[3])
}));
// Render data to chart
const renderData = (data) => {
if (!modal.chart || !modal.series) return;
modal.series.setData(data);
modal.chart.timeScale().fitContent();
setTimeout(() => { if (modal.chart) modal.chart.timeScale().fitContent(); }, 150);
};
// If cache is fresh — instant render, no spinner
if (cached && (Date.now() - cached.ts) < CACHE_TTL) {
hideLoader();
renderData(parseKlines(cached.data));
// Background refresh
fetch(`/api/klines?symbol=${sym}&interval=${tf}&limit=500`)
.then(r => r.ok ? r.json() : null)
.then(json => {
if (Array.isArray(json) && json.length) {
modalKlineCache[cacheKey] = { data: json, ts: Date.now() };
if (modal.currentSym === sym && modal.currentTF === tf) {
renderData(parseKlines(json));
}
}
})
.catch(() => {});
return;
}
showLoader();
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
const res = await fetch(`/api/klines?symbol=${sym}&interval=${tf}&limit=500`, { signal: controller.signal });
clearTimeout(timeout);
if (!res.ok) {
showError(`Server error (${res.status})`);
return;
}
const json = await res.json();
if (!Array.isArray(json) || !json.length || !modal.chart) {
showError('No data received');
return;
}
// Cache for future use
modalKlineCache[cacheKey] = { data: json, ts: Date.now() };
renderData(parseKlines(json));
hideLoader();
} catch (e) {
if (e.name === 'AbortError') {
showError('Timeout — Binance is slow');
} else {
showError('Failed to load chart');
}
console.error('Modal chart error:', e);
}
}
function drawModalLevels(data) {
const WINDOW = 5;
const highs = [], lows = [];
for (let i = WINDOW; i < data.length - WINDOW; i++) {
let isHigh = true, isLow = true;
for (let j = i - WINDOW; j <= i + WINDOW; j++) {
if (i === j) continue;
if (data[j].highRaw >= data[i].highRaw) isHigh = false;
if (data[j].lowRaw <= data[i].lowRaw) isLow = false;
}
if (isHigh) highs.push(data[i].highRaw);
if (isLow) lows.push(data[i].lowRaw);
}
const THRESHOLD = 0.003;
const findClusters = (pivots, type) => {
const used = new Set(), result = [];
for (let i = 0; i < Math.min(pivots.length, 60); i++) {
if (used.has(i)) continue;
const cluster = [pivots[i]];
for (let j = i + 1; j < pivots.length; j++) {
if (used.has(j)) continue;
if (Math.abs(pivots[i] - pivots[j]) / pivots[i] < THRESHOLD) {
cluster.push(pivots[j]);
used.add(j);
}
}
if (cluster.length >= 2) {
result.push({ price: cluster.reduce((s, p) => s + p, 0) / cluster.length, type, weight: cluster.length });
}
}
return result;
};
const levels = [
...findClusters(highs, 'resistance').sort((a, b) => b.weight - a.weight).slice(0, 3),
...findClusters(lows, 'support').sort((a, b) => b.weight - a.weight).slice(0, 3)
];
levels.forEach(l => {
const line = modal.series.createPriceLine({
price: l.price,
color: l.type === 'support' ? '#22c55e' : '#ef4444',
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: l.type === 'support' ? `S×${l.weight}` : `R×${l.weight}`,
});
modal.lines.push(line);
});
}
function closeCoinModal() {
el('coinModal').classList.add('hidden');
if (modal.chart) {
modal.chart.remove();
modal.chart = null;
modal.series = null;
modal.lines = [];
}
modal.currentSym = null;
}
// Init modal event listeners (called once in initMiniCharts)
function initModalEvents() {
// Close button
el('cmClose').addEventListener('click', closeCoinModal);
// Overlay click
document.querySelector('.mc-modal-overlay').addEventListener('click', closeCoinModal);
// Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.currentSym) closeCoinModal();
});
// TF buttons in modal
el('cmTFButtons').addEventListener('click', (e) => {
const btn = e.target.closest('.mc-tf-btn');
if (!btn || !modal.currentSym) return;
el('cmTFButtons').querySelectorAll('.mc-tf-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
modal.currentTF = btn.dataset.tf;
loadModalChart(modal.currentSym, modal.currentTF);
});
}