Spaces:
Running
Running
Utkarsh Pathak commited on
Commit ·
7bb81a6
1
Parent(s): 18e0bea
Live price ticker every 5s + configurable 1/2/5min full rescan
Browse files- main.py +24 -0
- static/index.html +93 -50
main.py
CHANGED
|
@@ -134,6 +134,30 @@ def scan(top: int = 8, min_volume: float = None, account: float = 100000,
|
|
| 134 |
raise HTTPException(503, str(e))
|
| 135 |
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
@app.get("/api/card/{symbol}")
|
| 138 |
def card(symbol: str, account: float = 100000, risk_pct: float = 1.0):
|
| 139 |
try:
|
|
|
|
| 134 |
raise HTTPException(503, str(e))
|
| 135 |
|
| 136 |
|
| 137 |
+
@app.get("/api/prices")
|
| 138 |
+
def prices(symbols: str = ""):
|
| 139 |
+
"""Lightweight price ticker — returns current price + 24h change for a
|
| 140 |
+
comma-separated list of symbols. Used for real-time card price updates."""
|
| 141 |
+
try:
|
| 142 |
+
src = get_source()
|
| 143 |
+
requested = {s.strip() for s in symbols.split(",") if s.strip()}
|
| 144 |
+
tickers = src.tickers()
|
| 145 |
+
result = {}
|
| 146 |
+
for t in tickers:
|
| 147 |
+
sym = t.get("symbol", "")
|
| 148 |
+
if not requested or sym in requested:
|
| 149 |
+
result[sym] = {
|
| 150 |
+
"price": t.get("last_price"),
|
| 151 |
+
"change_24h_pct": t.get("change_24h_pct"),
|
| 152 |
+
"high": t.get("high"),
|
| 153 |
+
"low": t.get("low"),
|
| 154 |
+
"quote_volume_usd": t.get("quote_volume_usd"),
|
| 155 |
+
}
|
| 156 |
+
return {"ts": datetime.now(timezone.utc).isoformat(), "prices": result}
|
| 157 |
+
except Exception as e:
|
| 158 |
+
raise HTTPException(503, str(e))
|
| 159 |
+
|
| 160 |
+
|
| 161 |
@app.get("/api/card/{symbol}")
|
| 162 |
def card(symbol: str, account: float = 100000, risk_pct: float = 1.0):
|
| 163 |
try:
|
static/index.html
CHANGED
|
@@ -639,25 +639,19 @@ h1.hero-title .accent {
|
|
| 639 |
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
| 640 |
}
|
| 641 |
|
| 642 |
-
/* ── Auto-refresh
|
| 643 |
-
#auto-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
#auto-btn:hover { border-color: rgba(13,148,136,0.3); color: var(--teal); }
|
| 655 |
-
#auto-btn.on {
|
| 656 |
-
background: rgba(209,250,229,0.7);
|
| 657 |
-
border-color: rgba(13,148,136,0.3);
|
| 658 |
-
color: var(--teal);
|
| 659 |
}
|
| 660 |
-
#auto-btn.on #auto-icon { animation: spin 2s linear infinite; }
|
| 661 |
|
| 662 |
#refresh-countdown {
|
| 663 |
font-size: 11px; font-weight: 700; letter-spacing: 0.3px;
|
|
@@ -750,10 +744,15 @@ h1.hero-title .accent {
|
|
| 750 |
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
| 751 |
Scan
|
| 752 |
</button>
|
| 753 |
-
<
|
| 754 |
-
<
|
| 755 |
-
<
|
| 756 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 757 |
<span id="refresh-countdown" style="display:none"></span>
|
| 758 |
</div>
|
| 759 |
</nav>
|
|
@@ -829,41 +828,30 @@ const fu = n => {
|
|
| 829 |
};
|
| 830 |
const pc = n => n == null ? null : (n>=0?'+':'')+( +n).toFixed(2)+'%';
|
| 831 |
|
| 832 |
-
/* ── Auto-refresh ────────────────────────────────────────
|
| 833 |
-
const AUTO_INTERVAL = 5 * 60; // 5 minutes in seconds
|
| 834 |
-
let autoOn = false;
|
| 835 |
let autoCountdown = 0;
|
| 836 |
-
let autoTimer = null;
|
| 837 |
let countdownTimer = null;
|
|
|
|
|
|
|
|
|
|
| 838 |
|
| 839 |
-
function
|
| 840 |
-
autoOn = on;
|
| 841 |
-
const btn = $('auto-btn'), lbl = $('auto-label'), cd = $('refresh-countdown');
|
| 842 |
-
if (on) {
|
| 843 |
-
btn.classList.add('on');
|
| 844 |
-
lbl.textContent = '5 min';
|
| 845 |
-
cd.style.display = 'inline-flex';
|
| 846 |
-
startCountdown();
|
| 847 |
-
} else {
|
| 848 |
-
btn.classList.remove('on');
|
| 849 |
-
lbl.textContent = 'Auto';
|
| 850 |
-
cd.style.display = 'none';
|
| 851 |
-
clearInterval(autoTimer);
|
| 852 |
-
clearInterval(countdownTimer);
|
| 853 |
-
}
|
| 854 |
-
}
|
| 855 |
|
| 856 |
function startCountdown() {
|
| 857 |
-
autoCountdown = AUTO_INTERVAL;
|
| 858 |
clearInterval(countdownTimer);
|
| 859 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 860 |
updateCountdownDisplay();
|
| 861 |
countdownTimer = setInterval(() => {
|
| 862 |
autoCountdown--;
|
| 863 |
updateCountdownDisplay();
|
| 864 |
if (autoCountdown <= 0) {
|
| 865 |
clearInterval(countdownTimer);
|
| 866 |
-
|
| 867 |
}
|
| 868 |
}, 1000);
|
| 869 |
}
|
|
@@ -873,10 +861,62 @@ function updateCountdownDisplay() {
|
|
| 873 |
const m = Math.floor(autoCountdown / 60);
|
| 874 |
const s = autoCountdown % 60;
|
| 875 |
cd.textContent = `↻ ${m}:${String(s).padStart(2,'0')}`;
|
| 876 |
-
cd.className = autoCountdown <=
|
| 877 |
}
|
| 878 |
|
| 879 |
-
$('auto-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 880 |
|
| 881 |
/* Status */
|
| 882 |
async function checkStatus() {
|
|
@@ -932,13 +972,14 @@ async function runScan() {
|
|
| 932 |
const sm = $('stat-market'); if (sm) sm.textContent = data.market.includes('BingX') ? 'USDT Perps' : data.market.split('(')[0].trim();
|
| 933 |
|
| 934 |
if (!data.cards.length) { empty.style.display = 'flex'; return; }
|
| 935 |
-
|
| 936 |
-
if (autoOn) startCountdown();
|
| 937 |
data.cards.forEach((c, i) => {
|
| 938 |
const el = buildCard(c, account, risk);
|
| 939 |
el.style.animationDelay = (i * 55) + 'ms';
|
| 940 |
grid.appendChild(el);
|
|
|
|
| 941 |
});
|
|
|
|
| 942 |
} catch(e) {
|
| 943 |
loader.style.display = 'none';
|
| 944 |
empty.style.display = 'flex';
|
|
@@ -1101,7 +1142,9 @@ function buildCard(c, account, risk) {
|
|
| 1101 |
}
|
| 1102 |
|
| 1103 |
checkStatus();
|
| 1104 |
-
setInterval(checkStatus,
|
|
|
|
|
|
|
| 1105 |
</script>
|
| 1106 |
</body>
|
| 1107 |
</html>
|
|
|
|
| 639 |
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
| 640 |
}
|
| 641 |
|
| 642 |
+
/* ── Auto-refresh select ─────────────────────────────────────────────── */
|
| 643 |
+
#auto-wrap { transition: border-color 0.2s, background 0.2s; }
|
| 644 |
+
#auto-wrap.active {
|
| 645 |
+
border-color: rgba(13,148,136,0.35) !important;
|
| 646 |
+
background: rgba(209,250,229,0.7) !important;
|
| 647 |
+
}
|
| 648 |
+
#auto-wrap.active label { color: var(--teal); }
|
| 649 |
+
#auto-interval {
|
| 650 |
+
background: none; border: none; outline: none;
|
| 651 |
+
color: var(--t1); font-family: var(--font);
|
| 652 |
+
font-size: 13px; font-weight: 700; cursor: pointer;
|
| 653 |
+
padding-right: 2px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
}
|
|
|
|
| 655 |
|
| 656 |
#refresh-countdown {
|
| 657 |
font-size: 11px; font-weight: 700; letter-spacing: 0.3px;
|
|
|
|
| 744 |
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
| 745 |
Scan
|
| 746 |
</button>
|
| 747 |
+
<div class="input-chip" id="auto-wrap">
|
| 748 |
+
<label for="auto-interval">Auto</label>
|
| 749 |
+
<select id="auto-interval">
|
| 750 |
+
<option value="0">Off</option>
|
| 751 |
+
<option value="60">1 min</option>
|
| 752 |
+
<option value="120">2 min</option>
|
| 753 |
+
<option value="300" selected>5 min</option>
|
| 754 |
+
</select>
|
| 755 |
+
</div>
|
| 756 |
<span id="refresh-countdown" style="display:none"></span>
|
| 757 |
</div>
|
| 758 |
</nav>
|
|
|
|
| 828 |
};
|
| 829 |
const pc = n => n == null ? null : (n>=0?'+':'')+( +n).toFixed(2)+'%';
|
| 830 |
|
| 831 |
+
/* ── Auto-refresh (full scan) ──────────────────────────────────────── */
|
|
|
|
|
|
|
| 832 |
let autoCountdown = 0;
|
|
|
|
| 833 |
let countdownTimer = null;
|
| 834 |
+
let liveTickerTimer = null;
|
| 835 |
+
// track which symbols are currently on screen + their card elements
|
| 836 |
+
const liveCards = new Map(); // symbol → {el, account, risk}
|
| 837 |
|
| 838 |
+
function getAutoInterval() { return parseInt($('auto-interval').value) || 0; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 839 |
|
| 840 |
function startCountdown() {
|
|
|
|
| 841 |
clearInterval(countdownTimer);
|
| 842 |
+
const secs = getAutoInterval();
|
| 843 |
+
const cd = $('refresh-countdown');
|
| 844 |
+
if (!secs) { cd.style.display = 'none'; $('auto-wrap').classList.remove('active'); return; }
|
| 845 |
+
$('auto-wrap').classList.add('active');
|
| 846 |
+
cd.style.display = 'inline-flex';
|
| 847 |
+
autoCountdown = secs;
|
| 848 |
updateCountdownDisplay();
|
| 849 |
countdownTimer = setInterval(() => {
|
| 850 |
autoCountdown--;
|
| 851 |
updateCountdownDisplay();
|
| 852 |
if (autoCountdown <= 0) {
|
| 853 |
clearInterval(countdownTimer);
|
| 854 |
+
runScan().then(() => startCountdown());
|
| 855 |
}
|
| 856 |
}, 1000);
|
| 857 |
}
|
|
|
|
| 861 |
const m = Math.floor(autoCountdown / 60);
|
| 862 |
const s = autoCountdown % 60;
|
| 863 |
cd.textContent = `↻ ${m}:${String(s).padStart(2,'0')}`;
|
| 864 |
+
cd.className = autoCountdown <= 15 ? 'urgent' : '';
|
| 865 |
}
|
| 866 |
|
| 867 |
+
$('auto-interval').addEventListener('change', () => startCountdown());
|
| 868 |
+
|
| 869 |
+
/* ── Live price ticker (every 5 seconds) ──────────────────────────── */
|
| 870 |
+
async function tickPrices() {
|
| 871 |
+
if (liveCards.size === 0) return;
|
| 872 |
+
const syms = [...liveCards.keys()].join(',');
|
| 873 |
+
try {
|
| 874 |
+
const data = await fetch(`/api/prices?symbols=${encodeURIComponent(syms)}`).then(r => r.json());
|
| 875 |
+
const now = new Date(data.ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
| 876 |
+
for (const [sym, {el}] of liveCards) {
|
| 877 |
+
const p = data.prices[sym];
|
| 878 |
+
if (!p) continue;
|
| 879 |
+
// update price
|
| 880 |
+
const priceEl = el.querySelector('.lv.entry .lv-val');
|
| 881 |
+
if (priceEl) {
|
| 882 |
+
const oldPrice = parseFloat(priceEl.dataset.raw || priceEl.textContent.replace(/[^0-9.]/g,''));
|
| 883 |
+
const newPrice = p.price;
|
| 884 |
+
priceEl.textContent = sf(newPrice);
|
| 885 |
+
priceEl.dataset.raw = newPrice;
|
| 886 |
+
// flash green/red on price change
|
| 887 |
+
if (oldPrice && newPrice !== oldPrice) {
|
| 888 |
+
priceEl.style.transition = 'color 0.3s';
|
| 889 |
+
priceEl.style.color = newPrice > oldPrice ? 'var(--green)' : 'var(--red)';
|
| 890 |
+
setTimeout(() => { priceEl.style.color = ''; }, 1200);
|
| 891 |
+
}
|
| 892 |
+
}
|
| 893 |
+
// update 24h change
|
| 894 |
+
const chgEl = el.querySelector('.chg');
|
| 895 |
+
if (chgEl && p.change_24h_pct != null) {
|
| 896 |
+
const chg = +p.change_24h_pct;
|
| 897 |
+
chgEl.textContent = (chg >= 0 ? '+' : '') + chg.toFixed(2) + '%';
|
| 898 |
+
chgEl.className = 'chg ' + (chg >= 0 ? 'pos' : 'neg');
|
| 899 |
+
}
|
| 900 |
+
// update volume
|
| 901 |
+
const volEl = el.querySelector('.vol');
|
| 902 |
+
if (volEl && p.quote_volume_usd) volEl.textContent = fu(p.quote_volume_usd);
|
| 903 |
+
// update live timestamp on meta
|
| 904 |
+
const meta = $('meta');
|
| 905 |
+
if (meta && meta.textContent) {
|
| 906 |
+
meta.textContent = meta.textContent.replace(/· \d{1,2}:\d{2}.*$/, `· ${now} · live`);
|
| 907 |
+
}
|
| 908 |
+
}
|
| 909 |
+
} catch { /* silent fail — next tick will retry */ }
|
| 910 |
+
}
|
| 911 |
+
|
| 912 |
+
function startLiveTicker() {
|
| 913 |
+
clearInterval(liveTickerTimer);
|
| 914 |
+
liveTickerTimer = setInterval(tickPrices, 5000);
|
| 915 |
+
}
|
| 916 |
+
function stopLiveTicker() {
|
| 917 |
+
clearInterval(liveTickerTimer);
|
| 918 |
+
liveCards.clear();
|
| 919 |
+
}
|
| 920 |
|
| 921 |
/* Status */
|
| 922 |
async function checkStatus() {
|
|
|
|
| 972 |
const sm = $('stat-market'); if (sm) sm.textContent = data.market.includes('BingX') ? 'USDT Perps' : data.market.split('(')[0].trim();
|
| 973 |
|
| 974 |
if (!data.cards.length) { empty.style.display = 'flex'; return; }
|
| 975 |
+
stopLiveTicker();
|
|
|
|
| 976 |
data.cards.forEach((c, i) => {
|
| 977 |
const el = buildCard(c, account, risk);
|
| 978 |
el.style.animationDelay = (i * 55) + 'ms';
|
| 979 |
grid.appendChild(el);
|
| 980 |
+
if (c.symbol) liveCards.set(c.symbol, {el, account, risk});
|
| 981 |
});
|
| 982 |
+
startLiveTicker();
|
| 983 |
} catch(e) {
|
| 984 |
loader.style.display = 'none';
|
| 985 |
empty.style.display = 'flex';
|
|
|
|
| 1142 |
}
|
| 1143 |
|
| 1144 |
checkStatus();
|
| 1145 |
+
setInterval(checkStatus, 30000);
|
| 1146 |
+
// start countdown if a non-Off interval is pre-selected
|
| 1147 |
+
if (getAutoInterval()) startCountdown();
|
| 1148 |
</script>
|
| 1149 |
</body>
|
| 1150 |
</html>
|