ESPN DeportesClaves de la victoria del Barcelona vs Valencia en LaLigaThe Jerusalem PostJerusalem man shot during attempted robbery, in serious conditionRTP DesportoPedro Silva conquista Grande Prémio TSF/JNוואלהחשד לירי בגבעת שאול בירושלים; גבר כבן 30 נפצע קשהESPNAntonelli comes from 19th to win home Italian GPDaily MaverickEMERGENCY MODE: 197 more ambulances, but Eastern Cape’s EMS crisis is still criticalScreen RantAll 13 Mandalorian Clans, Houses & Groups In Star Wars Canonn-tvNur noch in fünf Parlamenten: Chancenlose Kubicki-FDP fliegt aus dem nächsten LandtagMintUS trade war: What if Russian oil is excluded from markets? Envoy Alipov warns ‘world will not cope'VanguardPolice barracks: IGP sets December-January target for officers’ returnESPN CricinfoSamarawickrama's composed knock seals SL's semi-final berthTagesschau++ Liveticker zur Sachsen-Anhalt-Wahl: FDP-Spitzenkandidatin Hüskens kündigt Rücktritt an ++
The Daily Newsstand · Free, Always
Sunday, September 6, 2026

Сканер альткоинов на Python: данные пяти бирж и Telegram Mini App

Translate

Перед сделкой с альткоином мне обычно нужно быстро ответить на два вопроса: есть ли у цены выраженное направление и происходит ли в монете что‑то заметное прямо сейчас. Смотреть для этого десяток страниц на разных биржах неудобно, поэтому я собрал небольшой сканер.

Пользователь отправляет тикер и получает три результата:

  • направление от -100 до 100;

  • активность от 0 до 100;

  • итоговый статус: FLAT, NEUTRAL, LONG, SHORT или усиленный вариант сигнала.

Сканер не предсказывает точную цену и не принимает торговые решения. Он сводит данные с нескольких площадок в компактный отчёт и показывает, из каких метрик получилась оценка.

Основные источники здесь биржевые: открытый интерес, ставки финансирования, объём и соотношение покупок и продаж. Открытый интерес, или OI, показывает объём незакрытых контрактов. Переводы крупных кошельков и потоки монет на биржи сканер не отслеживает. Работа с блокчейном есть, но в отдельном модуле проверки USDT‑переводов.

Стек проекта: Python 3.12, aiogram 3, aiohttp, SQLite и обычный JavaScript без фреймворка.

Тестовые файлы проекта: github

Что требовалось от первой версии

Я начинал с минимального набора требований:

  • сканировать любой контракт, доступный на BingX Perpetual;

  • по возможности дополнять результат данными Binance, Bybit, OKX и Gate;

  • не считать отсутствие одной метрики ошибкой всего скана;

  • поддерживать список наблюдения и фоновые уведомления;

  • запускать массовую проверку крупных альткоинов;

  • использовать один расчётный модуль и для бота, и для Mini App;

  • не превышать лимиты биржевых API при одновременных запросах.

Пользовательские лимиты отделены от расчётного модуля. Они ограничивают частоту запросов, размер списка наблюдения и массовое сканирование, но не меняют формулу оценки.

Архитектура

Система состоит из двух интерфейсов и общего расчётного ядра:

Telegram-бот (aiogram) ──┐
                         ├── Scanner ── клиенты бирж
Mini App (aiohttp + JS) ─┘      │
                                └── SQLite

CoinGecko ── список монет для массового скана
JSON-RPC ── проверка переводов в EVM-сетях

Основные модули:

Файл

Ответственность

metrics.py

Получение и подготовка рыночных данных

scoring.py

Расчёт направления, активности и итогового статуса

exchanges.py

Клиенты бирж и резервные источники

api_guard.py

Кэш, блокировки и контроль лимитов API

payments.py

Проверка переводов по данным JSON‑RPC

storage.py

Работа с SQLite

auth.py

Проверка initData от Telegram

bot.py

Команды бота и фоновые уведомления

webapp_server.py

HTTP API для Mini App

Бот и веб‑сервер запускаются отдельными процессами, но испол

ьзуют одну базу пользователей и настроек.

При такой схеме стоит проверить настройки SQLite: режим WAL позволяет читателям и писателю меньше блокировать друг друга, но одновременно писать всё равно может только одно соединение. Поэтому нужны короткие транзакции и обработка занятости базы, например через busy_timeout. Сам по себе WAL не устраняет все блокировки. Это ограничение описано в документации SQLite.

Почему у оценки две оси

Один рейтинг плохо объясняет состояние малоликвидной монеты. Например, в стакане может быть заметный перевес заявок на покупку, но при э

том почти нет сделок. Направленная оценка получается высокой, а движения нет.

Поэтому я разделил направление и активность. Это две отдельные оценки, хотя часть исходных данных у них общая.

Направление

В формуле ниже s_i обозначает нормированную оценку блока от -1 до 1, а w_i его вес. Положительное значение даёт вклад в сценарий роста, отрицательное в сценарий снижения. Итог переводится в шкалу от -100 до 100:

D = 100 * sum(w_i * s_i) / sum(w_i)

В сумму входят только доступные блоки. Если биржа не вернула, например, историю открытого интереса, метрика исключается вместе со своим весом. Остальные веса пересчитываются относительно доступного набора. Если не осталось ни одного блока, делить на сумму весов нельзя: результатом должно быть «нет данных», а не нейтральный сигнал.

Активность

Активность измеряется от 0 до 100. В неё входят:

  • изменение открытого интереса;

  • текущий объём относительно недельного фона;

  • ADX как оценка силы тренда;

  • режим волатильности;

  • абсолютное значение CVD;

  • абсолютный дисбаланс стакана.

Итоговый статус получается на пересечении двух осей:

if activity < 40:
    signal = "FLAT"
elif abs(direction) < 22:
    signal = "NEUTRAL"
elif abs(direction) >= 45 and activity >= 60:
    signal = "STRONG LONG" if direction > 0 else "STRONG SHORT"
else:
    signal = "LONG" if direction > 0 else "SHORT"

При низкой активности статус остаётся FLAT, даже если направленная оценка велика.

Веса метрик

Текущая конфигурация выглядит так:

Блок

Вес

Зачем он нужен

Связка открытого интереса и цены

18

Сопоставляет изменение объёма открытых контрактов с движением цены

ADX, DI и EMA

18

Описывает направление и силу текущего движения

Ставка финансирования

17

Показывает стоимость удержания позиции и возможную перегруженность одной стороны

CVD

16

Отражает соотношение агрессивных покупок и продаж

Соотношение длинных и коротких позиций

12

Даёт контекст по разным группам участников

Сектор

8

Сравнивает монету с группой похожих активов

Стакан

4

Используется с небольшим весом из‑за короткого срока жизни сигнала

Базис

4

Дополняет ставку финансирования

TVL

3

Учитывает стоимость активов, заблокированных в протоколе, если эти данные доступны

Первую версию весов я выбрал вручную. Позже, например, уменьшил вес стакана с 8 до 4: его состав быстро меняется, а сканер рассчитан на более длинный горизонт.

Сейчас собираю статистику и пересматриваю расчёты. Этого пока недостаточно, чтобы считать веса проверенными. Кроме того, изменение формулы во время наблюдений мешает сравнению: результаты разных версий нужно учитывать отдельно.

Проверка знака ставки финансирования

В этом блоке легко перепутать знак. Отрицательная ставка означает, что короткие позиции платят длинным. В моей модели используется контртрендовая трактовка: отрицательная ставка даёт положительный вклад в направление, положительная даёт отрицательный.

Это допущение модели, а не правило движения цены. Сама по себе отрицательная ставка не обещает роста.

def dir_funding(m):
    funding = m.get("funding")
    if funding is None:
        return None

    if abs(funding) < FUNDING_NEUTRAL:
        return 0.0, "ставка около нуля"

    score = _clamp(-funding / FUNDING_SCALE)
    side = "положительный вклад" if funding < 0 else "отрицательный вклад"
    return score, f"ставка {funding * 100:+.4f}%: {side}"

Правило проверяется в self_test() при запуске бота. Такой тест ловит случайную смену знака после правок, но не проверяет рыночную полезность самого правила.

Сбор данных с нескольких бирж

Независимые запросы выполняются параллельно через asyncio.gather(). У некоторых метрик есть цепочки резервных источников:

  • история открытого интереса: Binance, затем Bybit, OKX и локальные снимки;

  • CVD: Binance, затем OKX;

  • базис: Binance, затем BingX;

  • ставка финансирования: агрегат доступных данных с пяти бирж.

Локальная задача сохраняет открытый интерес в SQLite каждые 15 минут. Это помогает для контрактов, по которым биржа не отдаёт достаточную историю.

Если получить метрику не удалось, функция возвращает None, и расчёт продолжается без неё. При таком подходе полезно показывать число доступных метрик: оценка по трём блокам и такая же оценка по девяти не должны выглядеть одинаково убедительно. Отсутствие данных нельзя подменять нулевым значением.

Есть и ещё одна проблема: одноимённые метрики разных бирж не всегда полностью сопоставимы. Отличаются типы контрактов, валюта расчёта, глубина рынка и период финансирования. Поэтому открытый интерес перед агрегацией нужно привести к общей денежной величине, а ставку финансирования привести к одному временному интервалу.

Один запрос свечей вместо шести

Отдельный HTTP‑запрос для каждого индикатора не нужен. Из 192 часовых свечей можно получить:

  • последнюю цену;

  • изменение за 24 часа;

  • CVD;

  • отношение текущего объёма к недельному;

  • режим волатильности;

  • ADX, DI и EMA.

Все индикаторы считаются локально. Ниже приведены сокращённые фрагменты: сетевые тайм‑ауты, настройки и обработка ошибок остаются за их пределами.

Для фьючерсных свечей Binance поле с индексом 10 содержит объём агрессивных покупок в котируемой валюте, а поле 7 содержит общий объём в той же валюте. Нормированная дельта за 24 часа считается так:

# покупки - продажи = tbuy - (volume - tbuy)
total_volume = sum(quote_volume[-24:])
delta = sum(
    2 * tbuy - volume
    for tbuy, volume in zip(taker_buy_quote[-24:], quote_volume[-24:])
)
cvd24 = delta / total_volume if total_volume > 0 else None

Название cvd24 сохранено из кода. Строго говоря, здесь получается нормированная дельта объёма за сутки, а не накопительная линия CVD. В примере объёмы уже преобразованы из строк в числа.

Индексы полей относятся именно к ответу Binance. Переносить этот разбор на другую биржу без проверки формата нельзя. Также стоит заранее решить, входит ли в расчёт незакрытая свеча: её объём продолжает меняться.

ADX рассчитывается по Уайлдеру с RMA‑сглаживанием. Дополнительных запросов к бирже для этого не требуется.

Агрегация ставки финансирования

Ставки на разных площадках могут заметно расходиться. Простое среднее даёт слишком большой вес небольшой бирже, поэтому используется среднее с весом по открытому интересу:

F = sum(f_i * OI_i) / sum(OI_i)

Формула имеет смысл только для сопоставимых данных: ставки должны относиться к одному периоду, а OI выражаться в одной валюте. Эту нормализацию нужно проверять в клиентах бирж.

В текущем расчёте вес Binance дополнительно умножается на 1,5. Это ручной коэффициент, который тоже требует проверки на статистике.

Стакан агрегируется похожим образом. Я учитываю заявки в полосе 0,5% от средней цены. Вес Binance здесь умножается на два, но общий вес стакана в итоговой модели остаётся небольшим.

Как не упереться в лимиты API

Самый неприятный сценарий выглядит так: несколько пользователей одновременно запрашивают один и тот же тикер, а приложение повторяет один набор HTTP‑запросов для каждого из них.

Защита состоит из трёх частей.

TTL‑кэш

Часовые свечи хранятся 120 секунд, ставка финансирования 300 секунд. Это компромисс между свежестью результата и числом запросов. Незакрытая часовая свеча меняется внутри часа, поэтому кэш действительно задерживает обновление данных.

Блокировка на ключ

Одновременные запросы одного символа используют общий asyncio.Lock. Первый запрос идёт на биржу, остальные ждут и затем получают результат из кэша.

async def fetch(self, key, ttl, weight, upstream):
    cached = self.cache_get(key, ttl)
    if cached is not None:
        return cached

    lock = self._locks.setdefault(key, asyncio.Lock())

    async with lock:
        cached = self.cache_get(key, ttl)
        if cached is not None:
            return cached

        if not self.allow(weight):
            return None

        value = await upstream()
        return self.cache_put(key, value)

Повторная проверка кэша внутри блокировки обязательна. Пока корутина ждала, другой запрос мог уже получить данные.

Такая блокировка объединяет запросы только внутри одного процесса. У бота и веб‑сервера будут отдельные кэши и отдельные лимитеры. Если они выходят с одного IP, их суммарную нагрузку нужно учитывать вместе.

Бюджет запросов

Методы Binance имеют разный вес запроса. Актуальные ограничения USDⓈ‑M Futures доступны в /fapi/v1/exchangeInfo, а использованный вес возвращается в заголовках ответа. Это описано в документации API.

В сканере используется собственный бюджет: 240–300 единиц веса за скользящее окно в 60 секунд. Это настройка приложения, а не заявленный лимит Binance.

Если бюджет исчерпан, вызов не падает с ошибкой. Источник пропускается, после чего код пробует следующую биржу.

Проверка на искусственных ответах выглядит так:

10 одновременных вызовов ARB: 1 HTTP-запрос и 9 ожидающих корутин
повтор внутри TTL: новых HTTP-запросов нет
локальный бюджет исчерпан: запрос переходит к резервному источнику

Дополнительно используется отрицательный кэш. Если символа нет на Binance, этот ответ запоминается на час. Без такого кэша каждый скан снова получает одну и ту же ошибку 400.

Если свежий ответ получить не удалось, предусмотрен возврат данных возрастом до 15 минут. Это лучше полного пропуска не для всех метрик: стакан за это время может сильно измениться. Возраст результата стоит явно показывать, а допустимый срок задавать отдельно для каждого блока.

Список наблюдения и уведомления

Фоновый цикл раз в десять минут получает уникальные тикеры из SQLite, сканирует каждый один раз и затем рассылает результат всем подписанным пользователям.

SELECT DISTINCT token FROM watchlist;

Если пятьдесят человек следят за одной монетой, биржа получает один набор запросов за цикл.

Уведомление отправляется при появлении направленного сигнала или заметном изменении активности:

def watch_should_alert(prev, event):
    if prev is None:
        return False, "состояние сохранено"

    reasons = []

    if event["signal"] != prev["signal"] and event["signal"] in DIRECTIONAL:
        reasons.append(f"сигнал: {prev['signal']} -> {event['signal']}")

    if abs(event["activity"] - prev["activity"]) > 10:
        reasons.append(
            f"активность: {prev['activity']} -> {event['activity']}"
        )

    return bool(reasons), "; ".join(reasons)

Первая проверка только сохраняет состояние. Иначе пользователь получит уведомление сразу после добавления монеты, хотя на рынке ничего не изменилось.

Между тикерами выдерживается пауза восемь секунд, чтобы снизить нагрузку от фонового цикла. При большом числе уникальных монет полный обход может занять больше десяти минут: период запуска и время обновления конкретного тикера здесь не одно и то же.

Проверка USDT‑перевода через JSON‑RPC

Отдельный модуль принимает хеш транзакции и проверяет перевод USDT в BSC, Ethereum, Polygon или Arbitrum. Он обращается к публичным JSON‑RPC, без API обозревателей блокчейна.

Здесь есть ограничение, не связанное с кодом: для продажи цифрового доступа внутри ботов и Mini Apps Telegram требует использовать Stars. Криптовалюта не является разрешённой заменой. Поэтому ниже разбирается именно проверка ERC-20-перевода, а не рекомендуемый способ продажи подписки в Telegram. См. правила платежей за цифровые услуги.

При проверке токена недостаточно смотреть на transaction.value: это сумма в нативной валюте сети. Для USDT нужны журналы события Transfer из результата eth_getTransactionReceipt.

Сокращённый разбор квитанции:

def parse_receipt(receipt, chain, wallet):
    if not receipt or receipt.get("status") != "0x1":
        return None

    wallet_topic = "0x" + "0" * 24 + wallet.lower().removeprefix("0x")
    total_units = 0

    for log in receipt.get("logs", []):
        if log["address"].lower() != CHAINS[chain]["usdt"].lower():
            continue

        topics = log.get("topics", [])
        if len(topics) != 3 or topics[0].lower() != TRANSFER_TOPIC:
            continue

        if topics[2].lower() != wallet_topic:
            continue

        total_units += int(log["data"], 16)

    return total_units or None

TRANSFER_TOPIC содержит keccak256("Transfer(address,address,uint256)"). Переводы нужного токена на нужный адрес суммируются. В примере результат оставлен целым числом минимальных единиц токена: так сумму можно сравнивать без погрешности float. Для отображения учитывается decimals конкретного контракта.

На уровне всего обработчика нужны дополнительные проверки:

  • сеть и адрес контракта должны совпадать с конфигурацией;

  • сумма должна соответствовать ожидаемому платежу;

  • транзакция должна набрать достаточно подтверждений;

  • один перевод нельзя засчитать дважды;

  • платёж должен быть связан с конкретным заказом и пользователем.

В текущей реализации использованные хеши хранятся в SQLite. Но одной предварительной проверки через SELECT недостаточно для защиты от двух одновременных запросов. Нужны ограничение уникальности и одна транзакция базы для записи платежа и изменения доступа.

Сам хеш публичен. Если принимать любой ещё не использованный перевод на общий кошелёк, его может первым предъявить не тот человек, который платил. Проверка квитанции подтверждает перевод, но не принадлежность платежа Telegram‑пользователю. Привязку к заказу нужно решать отдельно.

В прототипе используется порог в три подтверждения. Переносить его на все сети как универсальную гарантию нельзя: условия окончательности транзакций различаются. Если квитанции ещё нет или подтверждений недостаточно, проверку можно повторить позже, не помечая хеш использованным.

Публичные RPC иногда задерживают ответы. Для платёжного обработчика это означает «пока не удалось подтвердить», а не повод выдать доступ по неполным данным.

Авторизация Mini App

Фронтенд отправляет на сервер строку Telegram.WebApp.initData. Полям из initDataUnsafe доверять нельзя. Проверка выполняется локально по алгоритму Telegram, без запроса к его API. Основная часть проверки подписи:

from hashlib import sha256
import hmac
from urllib.parse import parse_qsl


def validate_init_data(init_data: str, bot_token: str) -> bool:
    pairs = dict(parse_qsl(init_data, keep_blank_values=True))
    their_hash = pairs.pop("hash", "")

    check_string = "\n".join(
        f"{key}={value}"
        for key, value in sorted(pairs.items())
    )

    secret = hmac.new(
        b"WebAppData",
        bot_token.encode(),
        sha256,
    ).digest()

    calculated = hmac.new(
        secret,
        check_string.encode(),
        sha256,
    ).hexdigest()

    return hmac.compare_digest(calculated, their_hash)

Помимо подписи, сервер проверяет auth_date. В текущей конфигурации строки старше суток отклоняются, успешная проверка кэшируется на пять минут по хешу исходной строки. Важно, чтобы кэш не продлевал допустимый срок жизни данных. Сам HMAC дёшев, поэтому такой кэш не является обязательной частью авторизации.

Идентификатор пользователя для проверки доступа следует брать из проверенных данных, а не из отдельного поля запроса. Полную строку initData не стоит записывать в логи. В тестах отдельно проверяются чужой токен бота, подмена поля, просроченные и некорректные данные.

Массовое сканирование

Для массового скана берётся первая сотня монет по капитализации из метода CoinGecko /coins/markets. Список кэшируется на шесть часов. После исключения стейблкоинов, обёрнутых активов и неподдерживаемых контрактов монет в обходе может оказаться меньше ста.

Полный проход занимает несколько минут, поэтому HTTP‑запрос только запускает задачу:

POST /api/mass_scan
{"status": "started"}

GET /api/mass_status
{"status": "running", "progress": 34, "total": 95}

GET /api/mass_status
{"status": "done", "result": {...}}

Внутри одного процесса действует общий asyncio.Lock, поэтому одновременно выполняется только один массовый скан. Свежий результат сохраняется на 30 минут и может быть отдан следующему пользователю без повторного обхода бирж.

Такой замок работает только в одном процессе. Если запустить несколько копий веб‑сервера, понадобится внешняя координация через Redis или таблицу задач в базе данных.

Облегчённый режим массового скана не рассчитывает корреляции и TVL, а данные стакана и ставки финансирования берёт только с Binance и BingX. Это сокращает количество запросов. При сравнении с одиночным сканом нужно учитывать, что набор источников и метрик отличается.

При сортировке направленные сигналы получают приоритет перед FLAT, затем учитывается модуль направления. Иначе монета с оценкой -60, но низкой активностью окажется выше активного SHORT.

Пользовательские попытки учитываются отдельно от самой задачи. Если обход завершается ошибкой, попытки участвовавших пользователей возвращаются. Совместный расчёт при этом не должен обходить проверку прав доступа каждого участника.

Интерфейс без отдельной сборки

Mini App сделан одним HTML‑файлом с обычным JavaScript. Сейчас в нём около 900 строк. Отдельная сборка клиентской части не нужна.

Из Telegram используются параметры темы, виброотклик и отступы для системных элементов экрана. Во время загрузки показываются заглушки. В отчёте можно посмотреть итоговый сигнал и разбор по отдельным блокам.

Если клиентская часть продолжит расти, один файл станет неудобен. Тогда её стоит разбить хотя бы на модули API, состояния и представления. Пока размер проекта позволяет обойтись без фреймворка.

Запуск

Для пробного запуска используются два Python‑процесса и cloudflared:

nohup python3 webapp_server.py > webapp.log 2>&1 &
nohup ./cloudflared tunnel \
  --url http://localhost:8080 \
  --protocol http2 \
  > tunnel.log 2>&1 &

grep -o 'https://[a-z0-9-]*\.trycloudflare\.com' tunnel.log | tail -1

export WEBAPP_URL="https://example.trycloudflare.com"
nohup python3 bot.py > bot.log 2>&1 &

Telegram открывает Mini App только по HTTPS. Быстрый туннель Cloudflare удобен для тестирования, но его адрес меняется после перезапуска. Для постоянного развёртывания нужен именованный туннель или собственный домен.

Флаг --protocol http2 пригодился на VPS, где UDP‑соединение для QUIC периодически переставало работать. Процесс оставался запущенным, но внешний адрес отвечал ошибкой. Переход на HTTP/2 поверх TCP устранил проблему в этой конфигурации.

Проверять лучше по частям:

curl http://localhost:8080/health
curl https://example.trycloudflare.com/health

Первый запрос проверяет приложение, второй добавляет к проверке туннель. Токен бота и адрес кошелька передаются через переменные окружения и не хранятся в репозитории. Для постоянной работы вместо nohup стоит использовать менеджер процессов с перезапуском при сбое и настройкой ротации логов.

Что пока не решено

У текущей версии есть ограничения.

  1. Пороги направления и активности ещё не проверены на достаточной исторической и отложенной выборке.

  2. Изменение весов во время сбора статистики мешает сравнивать версии модели. Нужна версия конфигурации в каждой записи сигнала.

  3. Рыночные данные относятся в основном к деривативам. Переводы крупных кошельков и потоки на биржи не учитываются.

  4. Резервные источники повышают доступность, но не делают данные разных бирж полностью взаимозаменяемыми.

  5. Публичные RPC иногда задерживают ответы. Пока платёж не подтверждён, доступ не выдаётся.

  6. asyncio.Lock и память процесса не подходят для горизонтального масштабирования.

  7. Сигнал показывает рассчитанный перевес, но ничего не говорит о допустимом размере позиции, проскальзывании и риске конкретной сделки.

Для дальнейшей проверки модели нужно сохранять:

  • версию алгоритма и конфигурации;

  • время и цену сигнала;

  • значения всех доступных метрик;

  • покрытие источников;

  • доходность через несколько фиксированных интервалов;

  • максимальное движение в обе стороны после сигнала.

Только после накопления этих данных можно обсуждать устойчивость порогов, а не отдельные удачные примеры.

Что получилось

Больше всего запросов удалось убрать без усложнения архитектуры: один набор свечей используется для нескольких индикаторов, одинаковые обращения объединяются через кэш и блокировку, а список наблюдения обходится по уникальным тикерам.

Сейчас сканер решает мою исходную задачу: собирает данные по монете в одном месте. Следующая задача менее наглядная, но необходимая: зафиксировать версию формулы и проверить её на накопленных сигналах. Пока такой проверки нет, итоговый статус стоит воспринимать как краткую сводку расчётов, а не готовую команду открыть сделку.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.