diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61f2dc9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +**/__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c27e321 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# Order Book Density Scanner + +Сканер крупных плотностей в стакане заявок криптобирж. Анализирует спотовые и бессрочные фьючерсные рынки на нескольких биржах и выводит результаты в веб-интерфейс. + +## Поддерживаемые биржи + +| Биржа | Спот | Фьючерсы | +|-------|------|----------| +| Gate.io | ✅ | ✅ | +| Bybit | ✅ | ✅ | +| MEXC | ✅ | ✅ | +| KuCoin | ✅ | ✅ | +| OKX | ✅ | ✅ | +| Bitget | ✅ | ✅ | +| HyperLiquid | ❌ | ✅ | + +## Возможности + +- Сканирование стакана заявок на 7 биржах (спот + фьючерсы) +- Поиск уровней с крупным объёмом (плотностей / стенок) +- Настраиваемые фильтры: + - Минимальный объём торгов 24ч (отдельно для спота и фьючерсов) + - Дальность до плотности от текущей цены (%) + - Минимальный объём плотности (USD) + - Включение/выключение бирж + - Включение/выключение типов рынка (спот/фьючерсы) +- Избранные монеты с приоритетным сканированием +- Сортировка и поиск по результатам +- Адаптивный тёмный интерфейс + +## Запуск + +### Требования + +- Python 3.11+ + +### Установка + +```bash +pip install -r requirements.txt +``` + +### Запуск сервера + +```bash +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +Откройте http://localhost:8000 в браузере. + +## API + +| Метод | URL | Описание | +|-------|-----|----------| +| GET | `/api/exchanges` | Список доступных бирж | +| POST | `/api/scan` | Запуск сканирования с настройками | +| GET | `/api/status` | Статус сканера | +| GET | `/api/results` | Последние результаты | + +### Пример запроса сканирования + +```json +POST /api/scan +{ + "min_volume_spot": 100000, + "min_volume_futures": 100000, + "max_distance_pct": 5.0, + "min_density_usd": 50000, + "enabled_exchanges": ["bybit", "okx", "binance"], + "market_types": ["spot", "futures"], + "favorites": ["BTC", "ETH"], + "max_symbols_per_exchange": 50 +} +``` + +## Архитектура + +``` +app/ +├── main.py # FastAPI приложение, маршруты +├── scanner.py # Логика сканирования стакана +├── models.py # Pydantic модели данных +└── exchanges/ + └── __init__.py # Менеджер подключений к биржам (ccxt) +static/ +├── index.html # Главная страница +├── css/style.css # Стили (тёмная тема) +└── js/app.js # Логика фронтенда +``` + +## Как это работает + +1. При нажатии кнопки "Сканировать" фронтенд отправляет POST-запрос с настройками +2. Бэкенд для каждой включённой биржи: + - Загружает список торговых пар + - Фильтрует по 24ч объёму торгов + - Запрашивает стакан заявок для отфильтрованных пар + - Анализирует каждый уровень на предмет крупного объёма +3. Результаты сортируются по объёму и отображаются в таблице +4. Пользователь может фильтровать, сортировать и добавлять в избранное diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/exchanges/__init__.py b/app/exchanges/__init__.py new file mode 100644 index 0000000..dc5322e --- /dev/null +++ b/app/exchanges/__init__.py @@ -0,0 +1,461 @@ +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from typing import Any + +import aiohttp + +logger = logging.getLogger(__name__) + +TIMEOUT = aiohttp.ClientTimeout(total=20) + + +@dataclass +class TickerInfo: + symbol: str + display_symbol: str + last_price: float + volume_24h_quote: float + + +@dataclass +class OrderBook: + bids: list[tuple[float, float]] + asks: list[tuple[float, float]] + + +EXCHANGE_REGISTRY: dict[str, type[BaseExchange]] = {} + + +class BaseExchange: + exchange_id: str = "" + exchange_name: str = "" + has_spot: bool = True + has_futures: bool = True + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + if cls.exchange_id: + EXCHANGE_REGISTRY[cls.exchange_id] = cls + + def __init__(self, session: aiohttp.ClientSession) -> None: + self.session = session + + async def _get(self, url: str, params: dict | None = None) -> Any: + try: + async with self.session.get(url, params=params, timeout=TIMEOUT) as resp: + if resp.status == 200: + return await resp.json() + except Exception as e: + logger.debug("%s GET %s failed: %s", self.exchange_id, url, e) + return None + + async def _post(self, url: str, json_data: dict | None = None) -> Any: + try: + async with self.session.post(url, json=json_data, timeout=TIMEOUT) as resp: + if resp.status == 200: + return await resp.json() + except Exception as e: + logger.debug("%s POST %s failed: %s", self.exchange_id, url, e) + return None + + async def get_tickers(self, market_type: str) -> list[TickerInfo]: + raise NotImplementedError + + async def get_orderbook(self, symbol: str, market_type: str, limit: int = 50) -> OrderBook | None: + raise NotImplementedError + + def _normalize_symbol(self, raw: str) -> str: + return raw.replace("/", "").replace("-", "").replace("_", "").upper() + + +class GateExchange(BaseExchange): + exchange_id = "gate" + exchange_name = "Gate.io" + + async def get_tickers(self, market_type: str) -> list[TickerInfo]: + tickers: list[TickerInfo] = [] + if market_type == "spot": + data = await self._get("https://api.gateio.ws/api/v4/spot/tickers") + if not data: + return [] + for t in data: + try: + last = float(t.get("last", 0)) + vol = float(t.get("quote_volume", 0)) + pair = t["currency_pair"] + if last > 0: + tickers.append(TickerInfo(pair, pair.replace("_", ""), last, vol)) + except (ValueError, KeyError): + continue + else: + data = await self._get("https://api.gateio.ws/api/v4/futures/usdt/tickers") + if not data: + return [] + for t in data: + try: + last = float(t.get("last", 0)) + vol = float(t.get("volume_24h_quote", 0)) + contract = t["contract"] + if last > 0: + tickers.append(TickerInfo(contract, contract.replace("_", ""), last, vol)) + except (ValueError, KeyError): + continue + return tickers + + async def get_orderbook(self, symbol: str, market_type: str, limit: int = 50) -> OrderBook | None: + if market_type == "spot": + data = await self._get( + "https://api.gateio.ws/api/v4/spot/order_book", + {"currency_pair": symbol, "limit": str(limit)}, + ) + if not data: + return None + bids = [(float(p), float(a)) for p, a in data.get("bids", [])] + asks = [(float(p), float(a)) for p, a in data.get("asks", [])] + else: + data = await self._get( + "https://api.gateio.ws/api/v4/futures/usdt/order_book", + {"contract": symbol, "limit": str(limit)}, + ) + if not data: + return None + bids = [(float(lv["p"]), abs(float(lv["s"]))) for lv in data.get("bids", [])] + asks = [(float(lv["p"]), abs(float(lv["s"]))) for lv in data.get("asks", [])] + return OrderBook(bids=bids, asks=asks) + + +class BybitExchange(BaseExchange): + exchange_id = "bybit" + exchange_name = "Bybit" + + async def get_tickers(self, market_type: str) -> list[TickerInfo]: + category = "spot" if market_type == "spot" else "linear" + data = await self._get( + "https://api.bybit.com/v5/market/tickers", {"category": category} + ) + if not data or data.get("retCode") != 0: + return [] + tickers: list[TickerInfo] = [] + for t in data.get("result", {}).get("list", []): + try: + sym = t["symbol"] + last = float(t.get("lastPrice", 0)) + vol = float(t.get("turnover24h", 0)) + if last > 0: + tickers.append(TickerInfo(sym, sym, last, vol)) + except (ValueError, KeyError): + continue + return tickers + + async def get_orderbook(self, symbol: str, market_type: str, limit: int = 50) -> OrderBook | None: + category = "spot" if market_type == "spot" else "linear" + data = await self._get( + "https://api.bybit.com/v5/market/orderbook", + {"category": category, "symbol": symbol, "limit": str(limit)}, + ) + if not data or data.get("retCode") != 0: + return None + result = data.get("result", {}) + bids = [(float(p), float(a)) for p, a in result.get("b", [])] + asks = [(float(p), float(a)) for p, a in result.get("a", [])] + return OrderBook(bids=bids, asks=asks) + + +class MexcExchange(BaseExchange): + exchange_id = "mexc" + exchange_name = "MEXC" + + async def get_tickers(self, market_type: str) -> list[TickerInfo]: + tickers: list[TickerInfo] = [] + if market_type == "spot": + data = await self._get("https://api.mexc.com/api/v3/ticker/24hr") + if not data: + return [] + for t in data: + try: + sym = t["symbol"] + last = float(t.get("lastPrice", 0)) + vol = float(t.get("quoteVolume", 0)) + if last > 0: + tickers.append(TickerInfo(sym, sym, last, vol)) + except (ValueError, KeyError): + continue + else: + data = await self._get("https://contract.mexc.com/api/v1/contract/ticker") + if not data or not data.get("success"): + return [] + for t in data.get("data", []): + try: + sym = t["symbol"] + last = float(t.get("lastPrice", 0)) + vol = float(t.get("volume24", 0)) * last + if last > 0: + display = sym.replace("_", "") + tickers.append(TickerInfo(sym, display, last, vol)) + except (ValueError, KeyError): + continue + return tickers + + async def get_orderbook(self, symbol: str, market_type: str, limit: int = 50) -> OrderBook | None: + if market_type == "spot": + data = await self._get( + "https://api.mexc.com/api/v3/depth", + {"symbol": symbol, "limit": str(limit)}, + ) + else: + data = await self._get( + f"https://contract.mexc.com/api/v1/contract/depth/{symbol}", + {"limit": str(limit)}, + ) + if data and data.get("success"): + data = data.get("data", {}) + if not data: + return None + bids = [(float(lv[0]), float(lv[1])) for lv in data.get("bids", []) if len(lv) >= 2] + asks = [(float(lv[0]), float(lv[1])) for lv in data.get("asks", []) if len(lv) >= 2] + return OrderBook(bids=bids, asks=asks) + + +class HyperliquidExchange(BaseExchange): + exchange_id = "hyperliquid" + exchange_name = "HyperLiquid" + has_spot = False + + async def get_tickers(self, market_type: str) -> list[TickerInfo]: + if market_type == "spot": + return [] + data = await self._post( + "https://api.hyperliquid.xyz/info", + {"type": "metaAndAssetCtxs"}, + ) + if not data or not isinstance(data, list) or len(data) < 2: + return [] + meta = data[0] + ctxs = data[1] + universe = meta.get("universe", []) + tickers: list[TickerInfo] = [] + for i, asset in enumerate(universe): + if i >= len(ctxs): + break + try: + coin = asset["name"] + ctx = ctxs[i] + last = float(ctx.get("markPx", 0)) + vol = float(ctx.get("dayNtlVlm", 0)) + if last > 0: + tickers.append(TickerInfo(coin, f"{coin}USDT", last, vol)) + except (ValueError, KeyError, IndexError): + continue + return tickers + + async def get_orderbook(self, symbol: str, market_type: str, limit: int = 50) -> OrderBook | None: + data = await self._post( + "https://api.hyperliquid.xyz/info", + {"type": "l2Book", "coin": symbol}, + ) + if not data: + return None + levels = data.get("levels", []) + if len(levels) < 2: + return None + bids = [(float(lv["px"]), float(lv["sz"])) for lv in levels[0][:limit]] + asks = [(float(lv["px"]), float(lv["sz"])) for lv in levels[1][:limit]] + return OrderBook(bids=bids, asks=asks) + + +class KucoinExchange(BaseExchange): + exchange_id = "kucoin" + exchange_name = "KuCoin" + + async def get_tickers(self, market_type: str) -> list[TickerInfo]: + tickers: list[TickerInfo] = [] + if market_type == "spot": + data = await self._get("https://api.kucoin.com/api/v1/market/allTickers") + if not data or data.get("code") != "200000": + return [] + for t in data.get("data", {}).get("ticker", []): + try: + sym = t["symbol"] + last = float(t.get("last", 0)) + vol = float(t.get("volValue", 0)) + if last > 0: + display = sym.replace("-", "") + tickers.append(TickerInfo(sym, display, last, vol)) + except (ValueError, KeyError): + continue + else: + data = await self._get("https://api-futures.kucoin.com/api/v1/contracts/active") + if not data or data.get("code") != "200000": + return [] + for t in data.get("data", []): + try: + sym = t["symbol"] + last = float(t.get("lastTradePrice", 0) or t.get("markPrice", 0)) + vol = float(t.get("turnoverOf24h", 0)) + if last > 0: + display = sym.replace("-", "") + if display.endswith("M"): + display = display[:-1] + tickers.append(TickerInfo(sym, display, last, vol)) + except (ValueError, KeyError): + continue + return tickers + + async def get_orderbook(self, symbol: str, market_type: str, limit: int = 50) -> OrderBook | None: + if market_type == "spot": + data = await self._get( + f"https://api.kucoin.com/api/v1/market/orderbook/level2_100", + {"symbol": symbol}, + ) + if not data or data.get("code") != "200000": + return None + book = data.get("data", {}) + else: + data = await self._get( + f"https://api-futures.kucoin.com/api/v1/level2/depth100", + {"symbol": symbol}, + ) + if not data or data.get("code") != "200000": + return None + book = data.get("data", {}) + bids = [(float(p), float(a)) for p, a in book.get("bids", [])[:limit]] + asks = [(float(p), float(a)) for p, a in book.get("asks", [])[:limit]] + return OrderBook(bids=bids, asks=asks) + + +class OkxExchange(BaseExchange): + exchange_id = "okx" + exchange_name = "OKX" + + async def get_tickers(self, market_type: str) -> list[TickerInfo]: + inst_type = "SPOT" if market_type == "spot" else "SWAP" + data = await self._get( + "https://www.okx.com/api/v5/market/tickers", + {"instType": inst_type}, + ) + if not data or data.get("code") != "0": + return [] + tickers: list[TickerInfo] = [] + for t in data.get("data", []): + try: + inst_id = t["instId"] + last = float(t.get("last", 0)) + vol = float(t.get("volCcy24h", 0)) + if market_type != "spot": + vol = float(t.get("volCcy24h", 0)) * last + if last > 0: + display = inst_id.replace("-", "").replace("SWAP", "") + tickers.append(TickerInfo(inst_id, display, last, vol)) + except (ValueError, KeyError): + continue + return tickers + + async def get_orderbook(self, symbol: str, market_type: str, limit: int = 50) -> OrderBook | None: + data = await self._get( + "https://www.okx.com/api/v5/market/books", + {"instId": symbol, "sz": str(limit)}, + ) + if not data or data.get("code") != "0": + return None + books = data.get("data", []) + if not books: + return None + book = books[0] + bids = [(float(row[0]), float(row[1])) for row in book.get("bids", [])] + asks = [(float(row[0]), float(row[1])) for row in book.get("asks", [])] + return OrderBook(bids=bids, asks=asks) + + +class BitgetExchange(BaseExchange): + exchange_id = "bitget" + exchange_name = "Bitget" + + async def get_tickers(self, market_type: str) -> list[TickerInfo]: + tickers: list[TickerInfo] = [] + if market_type == "spot": + data = await self._get("https://api.bitget.com/api/v2/spot/market/tickers") + if not data or data.get("code") != "00000": + return [] + for t in data.get("data", []): + try: + sym = t["symbol"] + last = float(t.get("lastPr", 0)) + vol = float(t.get("quoteVolume", 0)) + if last > 0: + tickers.append(TickerInfo(sym, sym, last, vol)) + except (ValueError, KeyError): + continue + else: + data = await self._get( + "https://api.bitget.com/api/v2/mix/market/tickers", + {"productType": "USDT-FUTURES"}, + ) + if not data or data.get("code") != "00000": + return [] + for t in data.get("data", []): + try: + sym = t["symbol"] + last = float(t.get("lastPr", 0)) + vol = float(t.get("quoteVolume", 0)) + if last > 0: + tickers.append(TickerInfo(sym, sym, last, vol)) + except (ValueError, KeyError): + continue + return tickers + + async def get_orderbook(self, symbol: str, market_type: str, limit: int = 50) -> OrderBook | None: + if market_type == "spot": + data = await self._get( + "https://api.bitget.com/api/v2/spot/market/orderbook", + {"symbol": symbol, "limit": str(limit)}, + ) + else: + data = await self._get( + "https://api.bitget.com/api/v2/mix/market/merge-depth", + {"symbol": symbol, "productType": "USDT-FUTURES", "limit": str(limit)}, + ) + if not data or data.get("code") != "00000": + return None + book = data.get("data", {}) + bids = [(float(p), float(a)) for p, a in book.get("bids", [])[:limit]] + asks = [(float(p), float(a)) for p, a in book.get("asks", [])[:limit]] + return OrderBook(bids=bids, asks=asks) + + +class ExchangeManager: + def __init__(self) -> None: + self.session: aiohttp.ClientSession | None = None + self._instances: dict[str, BaseExchange] = {} + + async def _ensure_session(self) -> aiohttp.ClientSession: + if self.session is None or self.session.closed: + self.session = aiohttp.ClientSession() + return self.session + + async def get_exchange(self, exchange_id: str) -> BaseExchange | None: + if exchange_id not in EXCHANGE_REGISTRY: + return None + if exchange_id not in self._instances: + session = await self._ensure_session() + cls = EXCHANGE_REGISTRY[exchange_id] + self._instances[exchange_id] = cls(session) + return self._instances[exchange_id] + + def get_all_exchange_info(self) -> list[dict[str, Any]]: + result = [] + for eid, cls in EXCHANGE_REGISTRY.items(): + result.append({ + "id": eid, + "name": cls.exchange_name, + "spot": cls.has_spot, + "futures": cls.has_futures, + }) + return result + + async def close(self) -> None: + if self.session and not self.session.closed: + await self.session.close() + self._instances.clear() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..1e85d90 --- /dev/null +++ b/app/main.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from app.models import DensityCard, ExchangeInfo, ScanSettings, ScanStatus +from app.scanner import DensityScanner + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + +scanner = DensityScanner() + + +@asynccontextmanager +async def lifespan(app: FastAPI): # type: ignore[no-untyped-def] + logger.info("Density Scanner starting up") + yield + logger.info("Shutting down") + await scanner.close() + + +app = FastAPI(title="Order Book Density Scanner", lifespan=lifespan) + + +@app.get("/api/exchanges") +async def get_exchanges() -> list[ExchangeInfo]: + infos = scanner.exchange_manager.get_all_exchange_info() + return [ExchangeInfo(**i) for i in infos] + + +@app.post("/api/scan") +async def run_scan(settings: ScanSettings) -> list[DensityCard]: + return await scanner.scan(settings) + + +@app.post("/api/auto-scan/start") +async def start_auto_scan(settings: ScanSettings) -> ScanStatus: + scanner.settings = settings + scanner.start_auto_scan() + return scanner.status + + +@app.post("/api/auto-scan/stop") +async def stop_auto_scan() -> ScanStatus: + scanner.stop_auto_scan() + return scanner.status + + +@app.get("/api/status") +async def get_status() -> ScanStatus: + return scanner.status + + +@app.get("/api/results") +async def get_results() -> list[DensityCard]: + return scanner.cards + + +@app.get("/") +async def index() -> FileResponse: + return FileResponse("static/index.html") + + +app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..e70687c --- /dev/null +++ b/app/models.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class ScanSettings(BaseModel): + min_volume_spot: float = Field(default=100_000) + min_volume_futures: float = Field(default=100_000) + max_distance_pct: float = Field(default=5.0) + min_density_usd: float = Field(default=50_000) + enabled_exchanges: list[str] = Field( + default_factory=lambda: [ + "gate", "bybit", "mexc", "hyperliquid", + "kucoin", "okx", "bitget", + ] + ) + market_types: list[str] = Field(default_factory=lambda: ["spot", "futures"]) + favorites: list[str] = Field(default_factory=list) + max_symbols_per_exchange: int = Field(default=50) + auto_scan: bool = Field(default=False) + scan_interval: int = Field(default=30) + + +class DensityItem(BaseModel): + exchange: str + exchange_id: str + symbol: str + market_type: str + side: str + price: float + volume_usd: float + amount: float + distance_pct: float + volume_ratio: float + volume_24h_usd: float + age_seconds: int = 0 + is_favorite: bool = False + + +class DensityCard(BaseModel): + symbol: str + market_type: str + densities: list[DensityItem] = Field(default_factory=list) + max_volume: float = 0 + is_favorite: bool = False + + +class ExchangeInfo(BaseModel): + id: str + name: str + spot: bool = True + futures: bool = True + + +class ScanStatus(BaseModel): + scanning: bool = False + last_scan_time: str | None = None + total_densities: int = 0 + exchanges_scanned: int = 0 + symbols_scanned: int = 0 + auto_scan: bool = False + errors: list[str] = Field(default_factory=list) diff --git a/app/scanner.py b/app/scanner.py new file mode 100644 index 0000000..bea891c --- /dev/null +++ b/app/scanner.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +import asyncio +import logging +import time +from collections import defaultdict +from datetime import datetime, timezone +from typing import Any + +from app.exchanges import ExchangeManager, OrderBook, TickerInfo +from app.models import DensityCard, DensityItem, ScanSettings, ScanStatus + +logger = logging.getLogger(__name__) + + +def _analyse_orderbook( + orderbook: OrderBook, + current_price: float, + min_density_usd: float, + max_distance_pct: float, +) -> list[dict[str, Any]]: + densities: list[dict[str, Any]] = [] + + for side_name, orders in [("bid", orderbook.bids), ("ask", orderbook.asks)]: + if not orders: + continue + volumes = [p * a for p, a in orders if p > 0 and a > 0] + avg_volume = sum(volumes) / len(volumes) if volumes else 0 + + for price, amount in orders: + if price <= 0 or amount <= 0: + continue + volume_usd = price * amount + distance_pct = abs(price - current_price) / current_price * 100 + if distance_pct > max_distance_pct or volume_usd < min_density_usd: + continue + volume_ratio = volume_usd / avg_volume if avg_volume > 0 else 0 + densities.append({ + "side": side_name, + "price": price, + "volume_usd": volume_usd, + "amount": amount, + "distance_pct": round(distance_pct, 4), + "volume_ratio": round(volume_ratio, 2), + }) + return densities + + +def _price_key(price: float) -> str: + if price >= 1000: + return f"{price:.1f}" + if price >= 1: + return f"{price:.3f}" + if price >= 0.01: + return f"{price:.5f}" + return f"{price:.8f}" + + +class DensityScanner: + def __init__(self) -> None: + self.exchange_manager = ExchangeManager() + self.status = ScanStatus() + self.cards: list[DensityCard] = [] + self.settings = ScanSettings() + self._scan_lock = asyncio.Lock() + self._density_times: dict[str, float] = {} + self._auto_scan_task: asyncio.Task | None = None # type: ignore[type-arg] + + def start_auto_scan(self) -> None: + if self._auto_scan_task and not self._auto_scan_task.done(): + return + self.settings.auto_scan = True + self.status.auto_scan = True + self._auto_scan_task = asyncio.create_task(self._auto_scan_loop()) + + def stop_auto_scan(self) -> None: + self.settings.auto_scan = False + self.status.auto_scan = False + if self._auto_scan_task: + self._auto_scan_task.cancel() + self._auto_scan_task = None + + async def _auto_scan_loop(self) -> None: + while self.settings.auto_scan: + try: + await self.scan(self.settings) + except Exception as e: + logger.error("Auto-scan error: %s", e) + await asyncio.sleep(self.settings.scan_interval) + + async def scan(self, settings: ScanSettings) -> list[DensityCard]: + if self._scan_lock.locked(): + return self.cards + + async with self._scan_lock: + self.settings = settings + self.status.scanning = True + self.status.errors = [] + all_items: list[DensityItem] = [] + exchanges_ok = 0 + symbols_total = 0 + + tasks = [] + for ex_id in settings.enabled_exchanges: + tasks.append(self._scan_exchange(ex_id, settings)) + + results = await asyncio.gather(*tasks, return_exceptions=True) + for r in results: + if isinstance(r, Exception): + self.status.errors.append(str(r)) + continue + items, n_sym = r + all_items.extend(items) + exchanges_ok += 1 + symbols_total += n_sym + + now = time.time() + new_times: dict[str, float] = {} + for item in all_items: + key = f"{item.exchange_id}|{item.symbol}|{item.side}|{_price_key(item.price)}" + first = self._density_times.get(key, now) + new_times[key] = first + item.age_seconds = int(now - first) + + self._density_times = new_times + + favorites_upper = {f.upper() for f in settings.favorites} + for item in all_items: + base = item.symbol.split("/")[0] if "/" in item.symbol else item.symbol + base_clean = base.replace("USDT", "").replace("USD", "").replace("BUSD", "") + item.is_favorite = ( + base.upper() in favorites_upper + or base_clean.upper() in favorites_upper + or item.symbol.upper() in favorites_upper + ) + + cards = self._group_into_cards(all_items) + + self.cards = cards + self.status.scanning = False + self.status.last_scan_time = datetime.now(timezone.utc).isoformat() + self.status.total_densities = len(all_items) + self.status.exchanges_scanned = exchanges_ok + self.status.symbols_scanned = symbols_total + return cards + + def _group_into_cards(self, items: list[DensityItem]) -> list[DensityCard]: + groups: dict[str, list[DensityItem]] = defaultdict(list) + for item in items: + key = f"{item.symbol}|{item.market_type}" + groups[key].append(item) + + cards: list[DensityCard] = [] + for key, group in groups.items(): + asks = sorted( + [d for d in group if d.side == "ask"], + key=lambda x: x.distance_pct, + ) + bids = sorted( + [d for d in group if d.side == "bid"], + key=lambda x: x.distance_pct, + ) + ordered = asks + bids + sym, mtype = key.split("|", 1) + max_vol = max(d.volume_usd for d in ordered) if ordered else 0 + is_fav = any(d.is_favorite for d in ordered) + cards.append(DensityCard( + symbol=sym, + market_type=mtype, + densities=ordered, + max_volume=max_vol, + is_favorite=is_fav, + )) + + cards.sort(key=lambda c: c.max_volume, reverse=True) + return cards + + async def _scan_exchange( + self, exchange_id: str, settings: ScanSettings + ) -> tuple[list[DensityItem], int]: + exchange = await self.exchange_manager.get_exchange(exchange_id) + if not exchange: + raise RuntimeError(f"Unknown exchange: {exchange_id}") + + all_items: list[DensityItem] = [] + total_symbols = 0 + + for market_type in settings.market_types: + if market_type == "spot" and not exchange.has_spot: + continue + if market_type == "futures" and not exchange.has_futures: + continue + + min_vol = ( + settings.min_volume_spot + if market_type == "spot" + else settings.min_volume_futures + ) + + try: + tickers = await exchange.get_tickers(market_type) + except Exception as e: + logger.error("Tickers failed %s/%s: %s", exchange_id, market_type, e) + continue + + usdt_tickers = [ + t for t in tickers + if t.display_symbol.upper().endswith("USDT") and t.last_price > 0 + ] + + favorites_upper = {f.upper() for f in settings.favorites} + filtered = [ + t for t in usdt_tickers + if t.volume_24h_quote >= min_vol + or any( + f in t.display_symbol.upper() + for f in favorites_upper + ) + ] + filtered.sort(key=lambda t: t.volume_24h_quote, reverse=True) + filtered = filtered[: settings.max_symbols_per_exchange] + total_symbols += len(filtered) + + batch_size = 5 + for i in range(0, len(filtered), batch_size): + batch = filtered[i: i + batch_size] + tasks = [ + self._scan_symbol(exchange, market_type, t, settings) + for t in batch + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + for r in results: + if isinstance(r, Exception): + continue + all_items.extend(r) + if i + batch_size < len(filtered): + await asyncio.sleep(0.15) + + return all_items, total_symbols + + async def _scan_symbol( + self, + exchange: Any, + market_type: str, + ticker: TickerInfo, + settings: ScanSettings, + ) -> list[DensityItem]: + orderbook = await exchange.get_orderbook(ticker.symbol, market_type, limit=50) + if not orderbook: + return [] + + raw = _analyse_orderbook( + orderbook, ticker.last_price, + settings.min_density_usd, settings.max_distance_pct, + ) + + items = [] + for d in raw: + items.append(DensityItem( + exchange=exchange.exchange_name, + exchange_id=exchange.exchange_id, + symbol=ticker.display_symbol, + market_type=market_type, + side=d["side"], + price=d["price"], + volume_usd=round(d["volume_usd"], 2), + amount=d["amount"], + distance_pct=d["distance_pct"], + volume_ratio=d["volume_ratio"], + volume_24h_usd=round(ticker.volume_24h_quote, 2), + )) + return items + + async def close(self) -> None: + self.stop_auto_scan() + await self.exchange_manager.close() diff --git a/main.py b/main.py new file mode 100644 index 0000000..58d291a --- /dev/null +++ b/main.py @@ -0,0 +1,43 @@ +""" +Order Book Density Scanner — Сканер плотностей стакана заявок. + +Просто запустите этот файл: python main.py +Зависимости установятся автоматически при первом запуске. +После запуска откройте http://localhost:8000 в браузере. +""" + +import subprocess +import sys + + +def install_dependencies(): + """Install required packages automatically.""" + required = ["fastapi", "uvicorn", "aiohttp", "pydantic"] + missing = [] + for pkg in required: + try: + __import__(pkg) + except ImportError: + missing.append(pkg) + + if missing: + print(f"Установка зависимостей: {', '.join(missing)}...") + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "-r", "requirements.txt"], + stdout=subprocess.DEVNULL, + ) + print("Зависимости установлены!") + + +if __name__ == "__main__": + install_dependencies() + + import uvicorn + from app.main import app + + print("\n" + "=" * 50) + print(" Density Scanner запущен!") + print(" Откройте в браузере: http://localhost:8000") + print("=" * 50 + "\n") + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d2d566d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.12 +uvicorn[standard]==0.34.3 +aiohttp==3.12.4 +pydantic==2.11.3 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..ea09d1e --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,143 @@ +*,*::before,*::after{margin:0;padding:0;box-sizing:border-box} +:root{ + --bg:#0b0e14;--bg2:#131720;--bg3:#1a1f2e;--bg4:#222838; + --border:#2a3040;--text:#d0d8e8;--text2:#7a8599;--text3:#505a6e; + --green:#00d26a;--green-bg:rgba(0,210,106,.12);--green-border:rgba(0,210,106,.25); + --red:#ff4d6a;--red-bg:rgba(255,77,106,.12);--red-border:rgba(255,77,106,.25); + --blue:#4da6ff;--blue-bg:rgba(77,166,255,.1); + --yellow:#f0b400;--yellow-bg:rgba(240,180,0,.1); + --purple:#b366ff; +} +body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);font-size:13px;min-height:100vh} + +/* header */ +header{display:flex;justify-content:space-between;align-items:center;padding:8px 16px;background:var(--bg2);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:100} +.header-left h1{font-size:16px;font-weight:600} +.header-center{display:flex;gap:8px} +.header-right{display:flex;align-items:center;gap:12px;font-size:12px;color:var(--text2)} +#density-count{color:var(--blue);font-weight:600} + +/* buttons */ +.btn{padding:5px 14px;border:1px solid var(--border);border-radius:4px;background:var(--bg3);color:var(--text);cursor:pointer;font-size:12px;transition:all .15s} +.btn:hover{background:var(--bg4)} +.btn:disabled{opacity:.4;cursor:not-allowed} +.btn-primary{background:#1a6b3a;border-color:#228b44;color:#fff} +.btn-primary:hover{background:#228b44} +.btn-outline{background:transparent} +.btn-small{padding:3px 8px;font-size:11px} +.btn-active{background:var(--blue-bg);border-color:var(--blue);color:var(--blue)} + +/* status */ +.status-idle{color:var(--text2)} +.status-scanning{color:var(--yellow)} +.status-done{color:var(--green)} + +/* settings panel */ +.settings-panel{background:var(--bg2);border-bottom:1px solid var(--border);padding:12px 16px;overflow:hidden;transition:max-height .3s} +.settings-panel.hidden{display:none} +.settings-grid{display:flex;gap:24px;flex-wrap:wrap} +.settings-section{min-width:160px} +.settings-section h4{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--text2);margin-bottom:6px;font-weight:600} +.form-row{display:flex;align-items:center;gap:8px;margin-bottom:5px} +.form-row label{font-size:12px;color:var(--text2);min-width:80px} +.form-row input[type=number],.form-row input[type=text]{width:110px;padding:4px 6px;border:1px solid var(--border);border-radius:3px;background:var(--bg);color:var(--text);font-size:12px} +.form-row input:focus{outline:none;border-color:var(--blue)} +.cb{display:flex;align-items:center;gap:5px;font-size:12px;cursor:pointer;margin-bottom:3px;color:var(--text)} +.cb input{accent-color:var(--blue)} +.tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px} +.tag{display:inline-flex;align-items:center;gap:3px;padding:2px 8px;background:var(--blue-bg);color:var(--blue);border-radius:10px;font-size:11px} +.tag button{background:none;border:none;color:var(--blue);cursor:pointer;font-size:13px;padding:0} +.tag button:hover{color:var(--red)} + +/* cards container */ +.cards-container{ + display:grid; + grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); + gap:8px; + padding:8px; + align-items:start; +} +.empty-state{grid-column:1/-1;text-align:center;padding:60px;color:var(--text3);font-size:15px} + +/* density card */ +.density-card{ + background:var(--bg2); + border:1px solid var(--border); + border-radius:6px; + overflow:hidden; +} +.card-header{ + display:flex; + align-items:center; + justify-content:space-between; + padding:6px 10px; + background:var(--bg3); + border-bottom:1px solid var(--border); +} +.card-title{display:flex;align-items:center;gap:8px} +.card-symbol{font-weight:700;font-size:13px} +.card-type{ + font-size:10px;font-weight:700;padding:1px 5px;border-radius:3px; +} +.card-type.spot{background:var(--green-bg);color:var(--green)} +.card-type.futures{background:var(--blue-bg);color:var(--blue)} +.card-actions{display:flex;gap:6px} +.card-actions button{ + background:none;border:none;color:var(--text3);cursor:pointer;font-size:14px; + padding:2px;transition:color .15s; +} +.card-actions button:hover{color:var(--text)} +.card-actions button.active{color:var(--yellow)} + +/* density rows */ +.density-row{ + display:grid; + grid-template-columns:90px 20px 60px 1fr 50px; + align-items:center; + padding:3px 8px; + border-bottom:1px solid rgba(42,48,64,.4); + font-size:12px; + gap:4px; +} +.density-row:last-child{border-bottom:none} +.density-row:hover{background:rgba(255,255,255,.02)} + +.density-row.bid{background:var(--green-bg)} +.density-row.ask{background:var(--red-bg)} + +.vol-cell{font-weight:700;font-size:12px} +.bid .vol-cell{color:var(--green)} +.ask .vol-cell{color:var(--red)} + +.side-icon{font-size:11px;text-align:center} +.bid .side-icon{color:var(--green)} +.ask .side-icon{color:var(--red)} + +.age-cell{ + font-size:10px;color:var(--text2); + display:flex;align-items:center;gap:3px; +} +.exchange-tag{ + font-size:9px; + padding:0 3px; + border-radius:2px; + background:var(--bg4); + color:var(--text2); + font-weight:600; +} + +.price-cell{text-align:right;color:var(--text);font-family:'Courier New',monospace;font-size:12px} +.dist-cell{text-align:right;color:var(--text2);font-size:11px} + +/* loading */ +.loading-overlay{position:fixed;inset:0;background:rgba(11,14,20,.85);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:200} +.loading-overlay.hidden{display:none} +.loading-overlay p{color:var(--text2);font-size:14px} +.spinner{width:36px;height:36px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;animation:spin .7s linear infinite;margin-bottom:12px} +@keyframes spin{to{transform:rotate(360deg)}} + +@media(max-width:700px){ + .cards-container{grid-template-columns:1fr} + .settings-grid{flex-direction:column;gap:12px} + header{flex-wrap:wrap;gap:8px} +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..93bf294 --- /dev/null +++ b/static/index.html @@ -0,0 +1,91 @@ + + + + + + Density Scanner + + + +
+
+

Плотности

+
+
+ + + +
+
+ Готов + + +
+
+ + + +
+
+ Нажмите «Сканировать» для поиска плотностей +
+
+ + + + + + diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..3c87dab --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,323 @@ +let allCards = []; +let favorites = JSON.parse(localStorage.getItem("favorites") || "[]"); +let autoScanActive = false; +let pollInterval = null; +let hiddenCards = new Set(JSON.parse(localStorage.getItem("hiddenCards") || "[]")); + +document.addEventListener("DOMContentLoaded", () => { + loadSettings(); + loadExchanges(); + renderFavorites(); + document.getElementById("max-distance").addEventListener("input", () => { if (allCards.length) renderCards(); }); + document.getElementById("min-density").addEventListener("input", () => { if (allCards.length) renderCards(); }); + document.getElementById("max-rows").addEventListener("input", () => { if (allCards.length) renderCards(); }); +}); + +async function loadExchanges() { + try { + const resp = await fetch("/api/exchanges"); + const exchanges = await resp.json(); + const el = document.getElementById("exchanges-list"); + el.innerHTML = ""; + const saved = JSON.parse(localStorage.getItem("enabledExchanges") || "null"); + exchanges.forEach(ex => { + const label = document.createElement("label"); + label.className = "cb"; + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.dataset.exchange = ex.id; + cb.checked = saved ? saved.includes(ex.id) : true; + label.appendChild(cb); + label.appendChild(document.createTextNode(` ${ex.name}`)); + el.appendChild(label); + }); + } catch (e) { console.error("loadExchanges:", e); } +} + +function loadSettings() { + const s = JSON.parse(localStorage.getItem("scanSettings") || "{}"); + if (s.min_volume_spot != null) document.getElementById("min-volume-spot").value = s.min_volume_spot; + if (s.min_volume_futures != null) document.getElementById("min-volume-futures").value = s.min_volume_futures; + if (s.max_distance_pct != null) document.getElementById("max-distance").value = s.max_distance_pct; + if (s.min_density_usd != null) document.getElementById("min-density").value = s.min_density_usd; + if (s.max_symbols != null) document.getElementById("max-symbols").value = s.max_symbols; + if (s.max_rows != null) document.getElementById("max-rows").value = s.max_rows; + if (s.market_spot != null) document.getElementById("market-spot").checked = s.market_spot; + if (s.market_futures != null) document.getElementById("market-futures").checked = s.market_futures; +} + +function saveSettings() { + const s = { + min_volume_spot: +document.getElementById("min-volume-spot").value, + min_volume_futures: +document.getElementById("min-volume-futures").value, + max_distance_pct: +document.getElementById("max-distance").value, + min_density_usd: +document.getElementById("min-density").value, + max_symbols: +document.getElementById("max-symbols").value, + max_rows: +document.getElementById("max-rows").value, + market_spot: document.getElementById("market-spot").checked, + market_futures: document.getElementById("market-futures").checked, + }; + localStorage.setItem("scanSettings", JSON.stringify(s)); + const enabled = []; + document.querySelectorAll("#exchanges-list input[type=checkbox]").forEach(cb => { + if (cb.checked) enabled.push(cb.dataset.exchange); + }); + localStorage.setItem("enabledExchanges", JSON.stringify(enabled)); +} + +function getSettings() { + const mt = []; + if (document.getElementById("market-spot").checked) mt.push("spot"); + if (document.getElementById("market-futures").checked) mt.push("futures"); + const ex = []; + document.querySelectorAll("#exchanges-list input[type=checkbox]").forEach(cb => { + if (cb.checked) ex.push(cb.dataset.exchange); + }); + return { + min_volume_spot: +document.getElementById("min-volume-spot").value, + min_volume_futures: +document.getElementById("min-volume-futures").value, + max_distance_pct: +document.getElementById("max-distance").value, + min_density_usd: +document.getElementById("min-density").value, + max_symbols_per_exchange: +document.getElementById("max-symbols").value, + enabled_exchanges: ex, + market_types: mt, + favorites: favorites, + }; +} + +function toggleSettings() { + document.getElementById("settings-panel").classList.toggle("hidden"); +} + +/* scan */ +async function startScan() { + const btn = document.getElementById("btn-scan"); + const st = document.getElementById("status-text"); + const overlay = document.getElementById("loading-overlay"); + btn.disabled = true; + st.textContent = "Сканирование..."; + st.className = "status-scanning"; + overlay.classList.remove("hidden"); + saveSettings(); + try { + const resp = await fetch("/api/scan", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(getSettings()), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + allCards = await resp.json(); + st.textContent = "Готово"; + st.className = "status-done"; + document.getElementById("last-update").textContent = new Date().toLocaleTimeString("ru-RU"); + renderCards(); + } catch (e) { + console.error("scan:", e); + st.textContent = "Ошибка"; + st.className = "status-error"; + } finally { + btn.disabled = false; + overlay.classList.add("hidden"); + } +} + +async function toggleAutoScan() { + const btn = document.getElementById("btn-auto"); + if (autoScanActive) { + autoScanActive = false; + btn.classList.remove("btn-active"); + btn.textContent = "Авто"; + if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } + await fetch("/api/auto-scan/stop", {method: "POST"}); + } else { + saveSettings(); + autoScanActive = true; + btn.classList.add("btn-active"); + btn.textContent = "Стоп"; + await fetch("/api/auto-scan/start", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(getSettings()), + }); + pollInterval = setInterval(pollResults, 3000); + } +} + +async function pollResults() { + try { + const resp = await fetch("/api/results"); + const data = await resp.json(); + if (data && data.length > 0) { + allCards = data; + document.getElementById("last-update").textContent = new Date().toLocaleTimeString("ru-RU"); + document.getElementById("status-text").textContent = "Авто-скан"; + document.getElementById("status-text").className = "status-done"; + renderCards(); + } + } catch (e) { console.error("poll:", e); } +} + +/* render */ +function renderCards() { + const container = document.getElementById("cards-container"); + const empty = document.getElementById("empty-state"); + const countEl = document.getElementById("density-count"); + + const maxDist = +document.getElementById("max-distance").value; + const minDensity = +document.getElementById("min-density").value; + + let totalDensities = 0; + const fragment = document.createDocumentFragment(); + + allCards.forEach(card => { + const cardKey = card.symbol + "|" + card.market_type; + if (hiddenCards.has(cardKey)) return; + + const filtered = card.densities.filter(d => + d.distance_pct <= maxDist && d.volume_usd >= minDensity + ); + if (filtered.length === 0) return; + + const maxRows = +document.getElementById("max-rows").value || 10; + const asks = filtered.filter(d => d.side === "ask").sort((a, b) => a.distance_pct - b.distance_pct); + const bids = filtered.filter(d => d.side === "bid").sort((a, b) => a.distance_pct - b.distance_pct); + const halfMax = Math.ceil(maxRows / 2); + const trimAsks = asks.slice(0, halfMax); + const trimBids = bids.slice(0, maxRows - trimAsks.length); + const ordered = [...trimAsks, ...trimBids]; + + totalDensities += ordered.length; + + const el = document.createElement("div"); + el.className = "density-card"; + + const typeLabel = card.market_type === "spot" ? "S" : "F"; + const typeClass = card.market_type; + const isFav = isFavoriteSymbol(card.symbol); + + let headerHtml = ` +
+
+ ${card.symbol} + ${typeLabel} +
+
+ + +
+
`; + + let rowsHtml = ""; + ordered.forEach(d => { + const rowClass = d.side === "bid" ? "bid" : "ask"; + const arrow = d.side === "bid" ? "▲" : "▼"; + const ageStr = formatAge(d.age_seconds); + rowsHtml += ` +
+ ${formatUsd(d.volume_usd)} + ${arrow} + + ${d.exchange_id} + ${ageStr} + + ${formatPrice(d.price)} + ${d.distance_pct.toFixed(1)}% +
`; + }); + + el.innerHTML = headerHtml + rowsHtml; + fragment.appendChild(el); + }); + + countEl.textContent = `${totalDensities} плотностей`; + + if (totalDensities === 0 && allCards.length === 0) { + container.innerHTML = ""; + container.appendChild(empty); + return; + } + + container.innerHTML = ""; + if (totalDensities === 0) { + container.innerHTML = '
Нет плотностей по текущим фильтрам
'; + } else { + container.appendChild(fragment); + } +} + +/* favorites */ +function addFavorite() { + const input = document.getElementById("fav-input"); + const vals = input.value.split(",").map(v => v.trim().toUpperCase()).filter(v => v && !favorites.includes(v)); + favorites.push(...vals); + localStorage.setItem("favorites", JSON.stringify(favorites)); + input.value = ""; + renderFavorites(); +} + +function removeFavorite(sym) { + favorites = favorites.filter(f => f !== sym); + localStorage.setItem("favorites", JSON.stringify(favorites)); + renderFavorites(); +} + +function isFavoriteSymbol(symbol) { + const base = symbol.replace(/USDT$/i, "").replace(/USD$/i, ""); + return favorites.some(f => f === base.toUpperCase() || f === symbol.toUpperCase()); +} + +function toggleCardFavorite(symbol) { + const base = symbol.replace(/USDT$/i, "").replace(/USD$/i, "").toUpperCase(); + if (favorites.includes(base)) { + favorites = favorites.filter(f => f !== base); + } else { + favorites.push(base); + } + localStorage.setItem("favorites", JSON.stringify(favorites)); + renderFavorites(); + renderCards(); +} + +function renderFavorites() { + const el = document.getElementById("favorites-list"); + el.innerHTML = ""; + favorites.forEach(sym => { + const tag = document.createElement("span"); + tag.className = "tag"; + tag.innerHTML = `${sym} `; + el.appendChild(tag); + }); +} + +/* hide cards */ +function toggleHideCard(key) { + if (hiddenCards.has(key)) hiddenCards.delete(key); + else hiddenCards.add(key); + localStorage.setItem("hiddenCards", JSON.stringify([...hiddenCards])); + renderCards(); +} + +/* formatters */ +function formatUsd(v) { + if (v >= 1e9) return (v / 1e9).toFixed(2) + "B$"; + if (v >= 1e6) return (v / 1e6).toFixed(2) + "M$"; + if (v >= 1e3) return (v / 1e3).toFixed(1) + "K$"; + return v.toFixed(0) + "$"; +} + +function formatPrice(p) { + if (p >= 1000) return p.toLocaleString("en-US", {minimumFractionDigits: 2, maximumFractionDigits: 2}); + if (p >= 1) return p.toFixed(4); + if (p >= 0.001) return p.toFixed(6); + return p.toFixed(8); +} + +function formatAge(seconds) { + if (seconds < 60) return seconds + "с"; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + if (m < 60) return m + "м " + s + "с"; + const h = Math.floor(m / 60); + const rm = m % 60; + return h + "ч " + rm + "м"; +}