Files
workspace/projects/crypto-signals/dashboard/index.html

434 lines
19 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CoinEx Futures Scanner</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0e17; color: #e1e5eb; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; }
.header { background: #111827; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #1f2937; }
.header h1 { font-size: 16px; color: #60a5fa; }
.header .status { font-size: 11px; color: #6b7280; }
.header .status .live { color: #10b981; }
.controls { background: #111827; padding: 8px 20px; display: flex; gap: 12px; align-items: center; border-bottom: 1px solid #1f2937; }
.controls label { color: #9ca3af; font-size: 11px; }
.controls select, .controls input { background: #1f2937; border: 1px solid #374151; color: #e5e7eb; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-family: inherit; }
.thresholds { background: #0d1321; padding: 10px 20px; display: flex; gap: 20px; border-bottom: 1px solid #1f2937; font-size: 11px; }
.thresholds .th-item { display: flex; gap: 6px; align-items: center; }
.thresholds .th-label { color: #6b7280; }
.thresholds .th-long { color: #10b981; }
.thresholds .th-short { color: #ef4444; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1px; background: #1f2937; padding: 1px; }
.coin-card { background: #111827; padding: 12px; }
.coin-card.signal-long { border-left: 3px solid #10b981; }
.coin-card.signal-short { border-left: 3px solid #ef4444; }
.coin-card.signal-none { border-left: 3px solid #374151; }
.coin-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.coin-name { font-size: 14px; font-weight: 700; }
.coin-price { font-size: 13px; color: #9ca3af; }
.coin-change { font-size: 12px; padding: 2px 6px; border-radius: 3px; }
.coin-change.up { background: #064e3b; color: #34d399; }
.coin-change.down { background: #7f1d1d; color: #fca5a5; }
.indicators { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 8px; }
.ind { background: #0a0e17; padding: 6px 8px; border-radius: 4px; }
.ind-label { font-size: 10px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; }
.ind-value { font-size: 14px; font-weight: 600; margin-top: 2px; }
.ind-pts { font-size: 10px; margin-top: 1px; }
.ind-pts.pts-high { color: #10b981; }
.ind-pts.pts-med { color: #f59e0b; }
.ind-pts.pts-low { color: #6b7280; }
.score-bar { margin-top: 8px; }
.score-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.score-label { font-size: 11px; color: #9ca3af; }
.score-value { font-size: 13px; font-weight: 700; }
.score-value.above { color: #10b981; }
.score-value.below { color: #6b7280; }
.bar-bg { background: #1f2937; height: 6px; border-radius: 3px; overflow: hidden; position: relative; }
.bar-fill { height: 100%; border-radius: 3px; transition: width 0.5s; }
.bar-fill.long { background: linear-gradient(90deg, #065f46, #10b981); }
.bar-fill.short { background: linear-gradient(90deg, #7f1d1d, #ef4444); }
.bar-fill.neutral { background: #374151; }
.bar-threshold { position: absolute; top: -2px; height: 10px; width: 2px; background: #f59e0b; }
.signal-badge { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 11px; font-weight: 700; margin-top: 6px; }
.signal-badge.long { background: #064e3b; color: #34d399; }
.signal-badge.short { background: #7f1d1d; color: #fca5a5; }
.signal-badge.neutral { background: #1f2937; color: #6b7280; }
.position-tag { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 700; margin-left: 6px; }
.position-tag.live-long { background: #10b981; color: #000; }
.position-tag.live-short { background: #ef4444; color: #fff; }
.rsi-gauge { position: relative; height: 8px; background: linear-gradient(90deg, #10b981 0%, #10b981 30%, #f59e0b 30%, #f59e0b 70%, #ef4444 70%, #ef4444 100%); border-radius: 4px; margin-top: 4px; }
.rsi-needle { position: absolute; top: -3px; width: 4px; height: 14px; background: #fff; border-radius: 2px; transform: translateX(-50%); transition: left 0.5s; }
.loading { text-align: center; padding: 60px; color: #6b7280; }
.error { text-align: center; padding: 60px; color: #ef4444; }
.summary { background: #111827; padding: 10px 20px; border-bottom: 1px solid #1f2937; display: flex; gap: 20px; font-size: 12px; }
.summary .s-item { display: flex; gap: 4px; }
.summary .s-label { color: #6b7280; }
.summary .s-val { font-weight: 600; }
.summary .s-val.green { color: #10b981; }
.summary .s-val.red { color: #ef4444; }
.summary .s-val.yellow { color: #f59e0b; }
</style>
</head>
<body>
<div class="header">
<h1>⚡ CoinEx Futures Scanner</h1>
<div class="status">
<span class="live">● LIVE</span> |
Last scan: <span id="lastScan"></span> |
Auto-refresh: <span id="refreshCountdown">30</span>s
</div>
</div>
<div class="thresholds">
<div class="th-item"><span class="th-label">LONG threshold:</span> <span class="th-long">45 pts</span></div>
<div class="th-item"><span class="th-label">SHORT threshold:</span> <span class="th-short">50 pts</span></div>
<div class="th-item"><span class="th-label">TP:</span> <span style="color:#10b981">+5%</span></div>
<div class="th-item"><span class="th-label">SL:</span> <span style="color:#ef4444">-3%</span></div>
<div class="th-item"><span class="th-label">Leverage:</span> <span style="color:#60a5fa">5x / 7x (score≥60)</span></div>
</div>
<div class="controls">
<label>Sort:</label>
<select id="sortBy" onchange="renderCoins()">
<option value="longScore">Long Score ↓</option>
<option value="shortScore">Short Score ↓</option>
<option value="name">Name A-Z</option>
<option value="change">24h Change</option>
<option value="rsi">RSI</option>
</select>
<label>Filter:</label>
<select id="filterBy" onchange="renderCoins()">
<option value="all">All Coins</option>
<option value="longSignal">Long Signals Only</option>
<option value="shortSignal">Short Signals Only</option>
<option value="anySignal">Any Signal</option>
</select>
</div>
<div class="summary" id="summary"></div>
<div class="grid" id="grid"><div class="loading">Scanning 29 coins...</div></div>
<script>
const COINS = [
"BTCUSDT","ETHUSDT","SOLUSDT","XRPUSDT","DOGEUSDT",
"ADAUSDT","AVAXUSDT","LINKUSDT","DOTUSDT","MATICUSDT",
"NEARUSDT","ATOMUSDT","LTCUSDT","UNIUSDT","AAVEUSDT",
"FILUSDT","ALGOUSDT","XLMUSDT","VETUSDT","ICPUSDT",
"APTUSDT","SUIUSDT","ARBUSDT","OPUSDT","SEIUSDT",
"HYPEUSDT","TRUMPUSDT","PUMPUSDT","ASTERUSDT"
];
const LONG_THRESHOLD = 45;
const SHORT_THRESHOLD = 50;
let coinData = [];
let refreshTimer = 30;
function calcRSI(closes, period=14) {
if (closes.length < period + 1) return 50;
let gains = [], losses = [];
for (let i = 1; i < closes.length; i++) {
let d = closes[i] - closes[i-1];
gains.push(d > 0 ? d : 0);
losses.push(d < 0 ? -d : 0);
}
let avgGain = gains.slice(0, period).reduce((a,b)=>a+b,0) / period;
let avgLoss = losses.slice(0, period).reduce((a,b)=>a+b,0) / period;
for (let i = period; i < gains.length; i++) {
avgGain = (avgGain * (period-1) + gains[i]) / period;
avgLoss = (avgLoss * (period-1) + losses[i]) / period;
}
if (avgLoss === 0) return 100;
return Math.round((100 - 100/(1 + avgGain/avgLoss)) * 10) / 10;
}
function calcVWAP(klines) {
let cumVP = 0, cumVol = 0;
for (let k of klines) {
let typical = (k.high + k.low + k.close) / 3;
cumVP += typical * k.volume;
cumVol += k.volume;
}
return cumVol > 0 ? cumVP / cumVol : klines[klines.length-1].close;
}
function calcBB(closes, period=20) {
if (closes.length < period) return { lower: closes[closes.length-1], mid: closes[closes.length-1], upper: closes[closes.length-1] };
let slice = closes.slice(-period);
let sma = slice.reduce((a,b)=>a+b,0) / period;
let std = Math.sqrt(slice.reduce((a,b)=>a+(b-sma)**2,0) / period);
return { lower: sma - 2*std, mid: sma, upper: sma + 2*std };
}
function scoreLong(rsi, vwapPct, change24h, bbPos) {
let score = 0, breakdown = {};
if (rsi < 25) { score += 30; breakdown.rsi = 30; }
else if (rsi < 30) { score += 25; breakdown.rsi = 25; }
else if (rsi < 35) { score += 15; breakdown.rsi = 15; }
else if (rsi < 40) { score += 5; breakdown.rsi = 5; }
else { breakdown.rsi = 0; }
if (vwapPct < -3) { score += 20; breakdown.vwap = 20; }
else if (vwapPct < -1.5) { score += 15; breakdown.vwap = 15; }
else if (vwapPct < 0) { score += 8; breakdown.vwap = 8; }
else { breakdown.vwap = 0; }
if (change24h < -10) { score += 15; breakdown.change = 15; }
else if (change24h < -5) { score += 10; breakdown.change = 10; }
else if (change24h < -2) { score += 5; breakdown.change = 5; }
else { breakdown.change = 0; }
if (bbPos < 0) { score += 15; breakdown.bb = 15; }
else if (bbPos < 0.2) { score += 10; breakdown.bb = 10; }
else { breakdown.bb = 0; }
breakdown.total = score;
return breakdown;
}
function scoreShort(rsi, vwapPct, change24h, bbPos) {
let score = 0, breakdown = {};
if (rsi > 75) { score += 30; breakdown.rsi = 30; }
else if (rsi > 70) { score += 25; breakdown.rsi = 25; }
else if (rsi > 65) { score += 15; breakdown.rsi = 15; }
else if (rsi > 60) { score += 5; breakdown.rsi = 5; }
else { breakdown.rsi = 0; }
if (vwapPct > 3) { score += 20; breakdown.vwap = 20; }
else if (vwapPct > 1.5) { score += 15; breakdown.vwap = 15; }
else if (vwapPct > 0) { score += 8; breakdown.vwap = 8; }
else { breakdown.vwap = 0; }
if (change24h > 10) { score += 15; breakdown.change = 15; }
else if (change24h > 5) { score += 10; breakdown.change = 10; }
else if (change24h > 2) { score += 5; breakdown.change = 5; }
else { breakdown.change = 0; }
if (bbPos > 1) { score += 15; breakdown.bb = 15; }
else if (bbPos > 0.8) { score += 10; breakdown.bb = 10; }
else { breakdown.bb = 0; }
breakdown.total = score;
return breakdown;
}
async function fetchCoin(symbol) {
try {
let url = `https://api.binance.us/api/v3/klines?symbol=${symbol}&interval=1h&limit=100`;
let resp = await fetch(url);
let raw = await resp.json();
if (!Array.isArray(raw) || raw.length < 30) return null;
let klines = raw.map(k => ({
open: parseFloat(k[1]),
high: parseFloat(k[2]),
low: parseFloat(k[3]),
close: parseFloat(k[4]),
volume: parseFloat(k[5])
}));
let closes = klines.map(k => k.close);
let price = closes[closes.length - 1];
let price24hAgo = closes[Math.max(0, closes.length - 25)];
let change24h = ((price - price24hAgo) / price24hAgo) * 100;
let rsi = calcRSI(closes);
let vwap = calcVWAP(klines.slice(-24));
let vwapPct = ((price - vwap) / vwap) * 100;
let bb = calcBB(closes);
let bbRange = bb.upper - bb.lower || 1;
let bbPos = (price - bb.lower) / bbRange;
let longBreakdown = scoreLong(rsi, vwapPct, change24h, bbPos);
let shortBreakdown = scoreShort(rsi, vwapPct, change24h, bbPos);
return {
symbol,
name: symbol.replace('USDT',''),
price,
change24h,
rsi,
vwap,
vwapPct,
bbPos,
bbLower: bb.lower,
bbMid: bb.mid,
bbUpper: bb.upper,
longScore: longBreakdown,
shortScore: shortBreakdown,
isLongSignal: longBreakdown.total >= LONG_THRESHOLD,
isShortSignal: shortBreakdown.total >= SHORT_THRESHOLD,
leverage: Math.max(longBreakdown.total, shortBreakdown.total) >= 60 ? '7x' : '5x'
};
} catch(e) {
return null;
}
}
function ptsClass(pts, max) {
if (pts >= max * 0.6) return 'pts-high';
if (pts >= max * 0.3) return 'pts-med';
return 'pts-low';
}
function formatPrice(p) {
if (p >= 100) return p.toFixed(2);
if (p >= 1) return p.toFixed(4);
if (p >= 0.01) return p.toFixed(5);
return p.toFixed(7);
}
function renderCoins() {
let sorted = [...coinData];
let sortBy = document.getElementById('sortBy').value;
let filterBy = document.getElementById('filterBy').value;
if (filterBy === 'longSignal') sorted = sorted.filter(c => c.isLongSignal);
else if (filterBy === 'shortSignal') sorted = sorted.filter(c => c.isShortSignal);
else if (filterBy === 'anySignal') sorted = sorted.filter(c => c.isLongSignal || c.isShortSignal);
if (sortBy === 'longScore') sorted.sort((a,b) => b.longScore.total - a.longScore.total);
else if (sortBy === 'shortScore') sorted.sort((a,b) => b.shortScore.total - a.shortScore.total);
else if (sortBy === 'name') sorted.sort((a,b) => a.name.localeCompare(b.name));
else if (sortBy === 'change') sorted.sort((a,b) => a.change24h - b.change24h);
else if (sortBy === 'rsi') sorted.sort((a,b) => a.rsi - b.rsi);
let longSignals = coinData.filter(c => c.isLongSignal).length;
let shortSignals = coinData.filter(c => c.isShortSignal).length;
let avgRSI = (coinData.reduce((a,c) => a + c.rsi, 0) / coinData.length).toFixed(1);
document.getElementById('summary').innerHTML = `
<div class="s-item"><span class="s-label">Coins:</span> <span class="s-val">${coinData.length}</span></div>
<div class="s-item"><span class="s-label">Long signals:</span> <span class="s-val green">${longSignals}</span></div>
<div class="s-item"><span class="s-label">Short signals:</span> <span class="s-val red">${shortSignals}</span></div>
<div class="s-item"><span class="s-label">Avg RSI:</span> <span class="s-val ${avgRSI < 40 ? 'green' : avgRSI > 60 ? 'red' : 'yellow'}">${avgRSI}</span></div>
`;
let html = '';
for (let c of sorted) {
let signalClass = c.isLongSignal ? 'signal-long' : c.isShortSignal ? 'signal-short' : 'signal-none';
let changeClass = c.change24h >= 0 ? 'up' : 'down';
let bestScore = Math.max(c.longScore.total, c.shortScore.total);
let bestDir = c.longScore.total >= c.shortScore.total ? 'long' : 'short';
html += `
<div class="coin-card ${signalClass}">
<div class="coin-top">
<div>
<span class="coin-name">${c.name}</span>
${c.isLongSignal ? '<span class="position-tag live-long">LONG ✓</span>' : ''}
${c.isShortSignal ? '<span class="position-tag live-short">SHORT ✓</span>' : ''}
</div>
<span class="coin-change ${changeClass}">${c.change24h >= 0 ? '+' : ''}${c.change24h.toFixed(2)}%</span>
</div>
<div class="coin-price">$${formatPrice(c.price)}</div>
<div class="indicators">
<div class="ind">
<div class="ind-label">RSI (14)</div>
<div class="ind-value" style="color:${c.rsi < 30 ? '#10b981' : c.rsi > 70 ? '#ef4444' : '#f59e0b'}">${c.rsi}</div>
<div class="rsi-gauge"><div class="rsi-needle" style="left:${Math.min(100, Math.max(0, c.rsi))}%"></div></div>
<div class="ind-pts ${ptsClass(Math.max(c.longScore.rsi, c.shortScore.rsi), 30)}">
L:+${c.longScore.rsi} / S:+${c.shortScore.rsi}
</div>
</div>
<div class="ind">
<div class="ind-label">VWAP</div>
<div class="ind-value" style="color:${c.vwapPct < -1.5 ? '#10b981' : c.vwapPct > 1.5 ? '#ef4444' : '#9ca3af'}">${c.vwapPct >= 0 ? '+' : ''}${c.vwapPct.toFixed(2)}%</div>
<div class="ind-pts ${ptsClass(Math.max(c.longScore.vwap, c.shortScore.vwap), 20)}">
L:+${c.longScore.vwap} / S:+${c.shortScore.vwap}
</div>
</div>
<div class="ind">
<div class="ind-label">24h Change</div>
<div class="ind-value" style="color:${c.change24h < -5 ? '#10b981' : c.change24h > 5 ? '#ef4444' : '#9ca3af'}">${c.change24h >= 0 ? '+' : ''}${c.change24h.toFixed(2)}%</div>
<div class="ind-pts ${ptsClass(Math.max(c.longScore.change, c.shortScore.change), 15)}">
L:+${c.longScore.change} / S:+${c.shortScore.change}
</div>
</div>
<div class="ind">
<div class="ind-label">BB Position</div>
<div class="ind-value" style="color:${c.bbPos < 0.2 ? '#10b981' : c.bbPos > 0.8 ? '#ef4444' : '#9ca3af'}">${c.bbPos.toFixed(3)}</div>
<div class="ind-pts ${ptsClass(Math.max(c.longScore.bb, c.shortScore.bb), 15)}">
L:+${c.longScore.bb} / S:+${c.shortScore.bb}
</div>
</div>
</div>
<div class="score-bar">
<div class="score-row">
<span class="score-label">LONG</span>
<span class="score-value ${c.longScore.total >= LONG_THRESHOLD ? 'above' : 'below'}">${c.longScore.total}/80</span>
</div>
<div class="bar-bg">
<div class="bar-fill ${c.longScore.total >= LONG_THRESHOLD ? 'long' : 'neutral'}" style="width:${(c.longScore.total/80)*100}%"></div>
<div class="bar-threshold" style="left:${(LONG_THRESHOLD/80)*100}%"></div>
</div>
</div>
<div class="score-bar" style="margin-top:4px">
<div class="score-row">
<span class="score-label">SHORT</span>
<span class="score-value ${c.shortScore.total >= SHORT_THRESHOLD ? 'above' : 'below'}">${c.shortScore.total}/80</span>
</div>
<div class="bar-bg">
<div class="bar-fill ${c.shortScore.total >= SHORT_THRESHOLD ? 'short' : 'neutral'}" style="width:${(c.shortScore.total/80)*100}%"></div>
<div class="bar-threshold" style="left:${(SHORT_THRESHOLD/80)*100}%"></div>
</div>
</div>
${bestScore >= Math.min(LONG_THRESHOLD, SHORT_THRESHOLD) ?
`<span class="signal-badge ${bestDir}">${bestDir.toUpperCase()} ${bestScore}pts → ${c.leverage}</span>` :
`<span class="signal-badge neutral">No signal</span>`}
</div>`;
}
document.getElementById('grid').innerHTML = html || '<div class="loading">No coins match filter</div>';
}
async function scanAll() {
document.getElementById('grid').innerHTML = '<div class="loading">Scanning 29 coins...</div>';
// Fetch in batches of 5 to avoid rate limits
coinData = [];
for (let i = 0; i < COINS.length; i += 5) {
let batch = COINS.slice(i, i+5);
let results = await Promise.all(batch.map(fetchCoin));
coinData.push(...results.filter(r => r !== null));
renderCoins();
if (i + 5 < COINS.length) await new Promise(r => setTimeout(r, 500));
}
document.getElementById('lastScan').textContent = new Date().toLocaleTimeString();
renderCoins();
}
// Auto-refresh countdown
setInterval(() => {
refreshTimer--;
document.getElementById('refreshCountdown').textContent = refreshTimer;
if (refreshTimer <= 0) {
refreshTimer = 30;
scanAll();
}
}, 1000);
scanAll();
</script>
</body>
</html>