Большинство рассказов о машинном обучении в трейдинге начинаются с модели. Берут свечи, добавляют индикаторы, запускают обучение и получают красивую кривую. Проблема обычно появляется раньше. В выборку могла попасть незакрытая свеча, пропуск мог превратиться в нарисованную цену, а соседние сделки могли использовать один и тот же участок будущего рынка.
В AltScanner мы пошли с другого конца. Сначала система научилась фиксировать состояние рынка так, чтобы его можно было воспроизвести. Затем появился механизм отложенной разметки. И только после этого мы добавили простую интерпретируемую модель.
ML‑контур сейчас работает с 13 токенами на бессрочном рынке USDT: LINK, AAVE, FET, FIL, ETH, LTC, PENGU, HYPE, DOT, BNB, DOGE, BCH и SUI. Горизонты соответствуют внутридневной торговле: 15, 60 и 240 минут.
Задача этого контура не состоит в замене сканера одним магическим числом. У AltScanner остаётся фиксированная двухосевая оценка. D показывает направление от −100 до +100, а A показывает рыночную активность от 0 до 100. Машинное обучение строится рядом с этой системой. Оно изучает связь признаков с последующим движением цены, но не переписывает веса D и A.
Блок 1. Архитектура: как данные проходят через систему
Общая схема сбора, хранения, разметки и обучения.

Источники данных
У рынка нет одного ответа на вопрос «что происходит сейчас». Свечи показывают результат торгов за интервал. Поток сделок говорит, кто бил по рынку. Стакан даёт представление о цене исполнения. Ставка финансирования и открытый интерес добавляют контекст по позициям участников.
Сборщик использует несколько независимых каналов:
BingX REST для закрытых свечей и снимков показателей;
BingX WebSocket для сделок, стакана и текущих событий;
Binance REST и WebSocket как дополнительный источник цены и потока;
публичные точки других бирж для ставки финансирования, открытого интереса и позиционирования.
У каждого ответа есть время получения. Биржа может прислать событие со своим временем, но модель не могла увидеть его до того, как пакет оказался на нашем сервере. Поэтому система хранит биржевое время и локальный received_ms, а снимок получает started_ms и observed_ms.
Асинхронный сборщик
Сбор не выполнен одним большим циклом. Внутри процесса одновременно работают свечи, восстановление истории, WebSocket‑потоки, снимки, разметка и обучение. Упрощённый фрагмент реального запуска выглядит так:
jobs = [ self.candles_loop(), self.history_loop(), self.maintenance(), self.learning_loop(), self.streams_manager(), self.research_loop(), ] jobs.extend( self.snapshots_loop( token, i * self.settings.snapshot_seconds / len(self.configured["BingX"]), ) for i, token in enumerate(self.configured["BingX"]) ) tasks = [asyncio.create_task(job) for job in jobs]
Снимки разных монет разнесены внутри пятиминутного интервала. Если отправить запросы по 13 инструментам одновременно, получится искусственный всплеск нагрузки, а время наблюдения у крайних ответов начнёт заметно отличаться.
Ограничения бирж учитываются до запроса. Для каждого узла действует свой интервал между обращениями, а общий бюджет резервируется через хранилище лимитов. Две копии сборщика не могут незаметно писать в одну папку: таблица leases хранит владельца блокировки и время её истечения.
Два слоя хранения
Сырые и нормализованные данные решают разные задачи, поэтому они не смешиваются.
Слой | Что хранится | Зачем |
|---|---|---|
| Ответы REST и сообщения WebSocket | Повторный разбор и расследование ошибок |
| Свечи, потоки, снимки, метки, модели и состояние | Выборки, обучение и контроль процесса |
Запись сырых сообщений проходит через отдельную очередь. Медленный диск не должен задерживать сеть. При нехватке свободного места журнал останавливается явно. Автоматического удаления истории нет, потому что тихая потеря данных для исследовательской системы опаснее остановки с понятной ошибкой.
SQLite работает в режиме WAL:
self.conn = sqlite3.connect(path, timeout=30) self.conn.execute("PRAGMA journal_mode=WAL") self.conn.execute("PRAGMA busy_timeout=30000")
Ключевые сущности базы разделены по смыслу:
snapshots(id, token, started_ms, observed_ms, feature_version, config_hash, features, metrics, signal, quality, eligible) labels(snapshot_id, horizon_min, entry_ms, end_ms, return_bps, mfe_bps, mae_bps, created_ms) models(id, horizon_min, created_ms, trained_until_ms, active, artifact, report) predictions(snapshot_id, horizon_min, model_id, predicted_bps, signal, created_ms)
Кроме них есть candles, flows, health, incidents и leases. Некачественный снимок тоже сохраняется. По нему можно понять, какой источник отстал и почему строка не попала в обучение.
Контроль закрытых свечей
В базу принимаются только закрытые интервалы с корректной геометрией OHLCV:
if t % (interval * 60_000): continue if t + interval * 60_000 > received: continue # свеча ещё не закрыта if not all(math.isfinite(x) for x in values): continue if min(o, h, l, c) <= 0 or v < 0: continue if h < max(o, c, l) or l > min(o, c): continue
Конфликт по ключу (source, token, interval_min, open_ms) не обновляет старую строку. Уже принятое наблюдение остаётся неизменным. Если биржа позже отдаст другую версию той же свечи, она не перепишет прошлое в датасете.
Блок 2. Логика: путь одного наблюдения до модели
Архитектура отвечает на вопрос, где живут данные. Логика отвечает на другой вопрос: когда запись получает право участвовать в обучении и как проверяется результат.
Дальше разберем поочередно всю логику и ее блоки. Общая схема софта выглядит следующим образом:

Шаг 1. Снимок фиксируется после завершения расчёта
Раз в пять минут сканер рассчитывает рыночные метрики, фиксированную оценку D/A и признаки для ML. observed_ms ставится после получения всех ответов. Это консервативная граница: модель считается знающей данные только после завершения снимка.
Допуск в выборку выражен прямо в коде:
eligible = ( len(rows) >= 60 and 0 <= features.get("candle_age_s", 1000) <= 120 and metrics.get("model_version") == MODEL_VERSION and metrics.get("quality", {}).get("valid", False) and signal.get("valid", False) ) # Слишком долгий расчёт уже плохо описывает единый момент рынка. eligible = eligible and observed_ms - started_ms <= 150_000
Если внутри минутного ряда есть разрыв, система берёт только последний непрерывный участок. Она не заполняет отсутствующую минуту предыдущей ценой. Такое заполнение уменьшило бы измеренную волатильность и исказило бы объём и диапазон.
Шаг 2. D/A рассчитываются независимо от ML
Для каждого горизонта направление складывается из четырёх нормализованных компонентов:
D = round(40M + 25T + 25F + 10R)
M означает движение относительно обычной волатильности, T описывает структуру быстрых и медленных EMA, F показывает аномалию агрессивного потока, R измеряет остаточное движение относительно рыночного ориентира. Каждый компонент ограничен диапазоном от −1 до +1.
Активность считается отдельно:
A = round(55Pvolume + 45Penergy)
Pvolume представляет процентиль объёма, а Penergy процентиль реализованного движения внутри свечей. Оба значения находятся в диапазоне от 0 до 1.
При пропаже компонента оставшиеся веса не растягиваются до 100 процентов:
direction = round(sum(score * weight for score, _, weight in parts.values() if score is not None)) activity = round(sum(score * weight for score, _, weight in activity_parts.values() if score is not None))
Иначе отсутствие потока могло бы искусственно усилить цену. Для направления требуется покрытие не менее 75 из 100, для активности нужны все 100. Затем применяются пороги:
if reasons: status = "INSUFFICIENT_DATA" elif activity < 30: status = "INACTIVE" elif direction >= 20: status = "BULLISH" elif direction <= -20: status = "BEARISH" else: status = "BALANCED"
Шаг 3. Признаки описывают текущий рынок
Модель получает более подробную картину, чем два итоговых балла:
Цена: доходность, диапазон, тело свечи и волатильность на окнах 1, 5, 15, 60, 240 и 1440 минут.
Поток: агрессивные сделки, открытый интерес, финансирование, базис и соотношения сторон.
Исполнение: спред, достижимая глубина и оценка стоимости полного круга.
Контекст: D/A и их части, режим рынка, время суток, токен и движение к ориентиру.
Ценовой импульс не сравнивается с постоянным процентным порогом:
ret = math.log(close[-1] / close[-n - 1]) sigma = max(statistics.pstdev(prior_returns), 1e-7) momentum = math.tanh(ret / (2 * sigma * math.sqrt(n)))
Одинаковое движение на один процент для ETH и менее ликвидного токена после такой нормализации получает разный смысл. Относительная сила строится на остаточной доходности к ориентиру. Поток сравнивается с непересекающимися прошлыми окнами через медиану и устойчивую оценку масштаба.
Для объёма при достаточной истории используется сезонная база: то же время суток, отдельно будни и выходные. До накопления истории работает обычная скользящая база.
Шаг 4. Метка появляется только после наступления будущего
Снимок сам по себе не содержит ответа. Пусть расчёт закончен в observed_ms. Вход назначается на открытие следующей полной минуты после запаса в две секунды:
entry_ms = ((observed_ms + 2000) // 60_000 + 1) * 60_000 end_ms = entry_ms + horizon_minutes * 60_000
Процедура ждёт end_ms, затем требует полный минутный ряд:
bars = db.bars(token, entry_ms, end_ms) continuous = ( len(bars) == horizon_minutes and all(bar["open_ms"] == entry_ms + i * 60_000 for i, bar in enumerate(bars)) ) if not continuous: continue # метка не создаётся
Целевая величина и траектория записываются в базисных пунктах:
return_bps = (bars[-1]["c"] / bars[0]["o"] - 1) * 10_000 mfe_bps = (max(bar["h"] for bar in bars) / bars[0]["o"] - 1) * 10_000 mae_bps = (min(bar["l"] for bar in bars) / bars[0]["o"] - 1) * 10_000
Пока модель учится на конечной доходности. MFE и MAE остаются материалом для будущего анализа риска и выхода.
observed_ms entry_ms end_ms │ │ │ └ признаки готовы └ первая допустимая цена входа └ известна метка
Шаг 5. Версия признаков защищает смысл истории
Изменение расчётной позиции меняет проход по стакану. Новые веса меняют rule_direction. Поэтому одинаковый тип столбца ещё не означает одинаковый смысл.
Снимок хранит feature_version и config_hash. Первый идентификатор описывает структуру признаков, второй фиксирует математику и условия исполнения:
MODEL_CONFIG = { key: getattr(config, key) for key in ( "MODEL_VERSION", "FEATURE_VERSION", "DIR_WEIGHTS", "ACT_WEIGHTS", "HORIZONS", "MIN_DIRECTION_COVERAGE", "MIN_ACTIVITY", "DIRECTION_THRESHOLD", "MIN_CLOSED_BARS", "EXECUTION_EXCHANGE", "REFERENCE_NOTIONAL_USDT", "TAKER_FEE_BPS", "MAX_SPREAD_BPS", ) } CONFIG_HASH = hashlib.sha256( json.dumps(MODEL_CONFIG, sort_keys=True).encode() ).hexdigest()
Обучение выбирает только строки текущего поколения:
WHERE snapshots.eligible = 1 AND snapshots.feature_version = :feature_version AND snapshots.config_hash = :config_hash AND labels.horizon_min = :horizon
Старые данные остаются доступны для исследования, но не смешиваются с новой математикой.
Шаг 6. Матрица готовится по обучающей части
Нулевая ставка финансирования и неизвестная ставка финансирования означают разные вещи. Поэтому пропуски не заменяются нулём:
# Удаляем столбцы, пустые более чем наполовину. keep = np.mean(np.isnan(x), axis=0) <= 0.5 x = x[:, keep] # Медиана считается на обучающей части. medians = np.nanmedian(x, axis=0) missing = np.isnan(x).astype(float) x = np.where(np.isnan(x), medians, x) # Факт пропуска становится отдельным признаком. x = np.column_stack([x, missing]) center, scale = x.mean(axis=0), x.std(axis=0) scale[scale < 1e-9] = 1 z = np.clip((x - center) / scale, -8, 8)
Для каждого токена добавляется двоичный столбец. Медианы, центры и масштабы сохраняются внутри модели. На новых данных используются именно они, иначе нормализация увидит распределение будущего.
Шаг 7. Elastic Net отбирает слабые признаки
На раннем этапе размеченных данных немного, а соседние снимки связаны. Поэтому первой моделью выбрана линейная регрессия Elastic Net:
model = ElasticNet( alpha=alpha, l1_ratio=0.85, max_iter=10_000, tol=1e-5, ) model.fit(z, y)
min (1 / 2N) · ||y − Xw||² + alpha · rho · ||w||₁ + alpha · (1 − rho) / 2 · ||w||₂²
L1-часть обнуляет слабые коэффициенты. L2-часть сдерживает веса коррелирующих показателей. В артефакте сохраняются коэффициенты, доли абсолютного влияния и список отключённых признаков. На этом этапе прозрачность полезнее сложности.
Шаг 8. Проверка идёт по времени
Перемешивать снимки нельзя. Соседние записи используют общую историю и часто имеют пересекающиеся горизонты. Разделение дополнительно удаляет из обучения строки, чьи метки заканчиваются после начала проверки:
def purged_split(rows, boundary, end=None): train = [row for row in rows if row["end_ms"] < boundary] test = [row for row in rows if row["observed_ms"] >= boundary and (end is None or row["observed_ms"] < end)] return train, test
Внутри первых 85 процентов времени используются три последовательных окна проверки. На них выбирается alpha из 0.1, 1, 5 и 15. Последние 15 процентов остаются финальным участком и не участвуют в выборе параметра.
После проверки модель повторно не обучается на тесте. В файл попадает именно экземпляр, который этого участка не видел.
Обучение начинается после накопления минимум 14 дней и 2000 размеченных строк текущего поколения. Финальный отрезок должен содержать не менее 200 наблюдений. Рабочее окно ограничено последними 90 днями.
Шаг 9. Одной ошибки прогноза недостаточно
Основная статистическая метрика кандидата представляет собой среднюю абсолютную ошибку в базисных пунктах. Соперник простой: постоянный прогноз нулевой доходности. Кандидат должен уменьшить MAE хотя бы на 2 процента.
Затем проводится бумажная проверка с приближёнными издержками:
observed_cost = features.get("execution_estimated_round_trip_bps") cost = max(14.0, observed_cost) if observed_cost is not None else 14.0 threshold = max(20.0, cost) if abs(prediction_bps) <= threshold: skip_trade()
Условные позиции по одному токену не пересекаются. После входа следующий сигнал по этой монете игнорируется до end_ms. Для прохождения проверки требуется не менее 30 условных сделок и положительный средний результат после издержек.
Если уже есть действующая модель, кандидат сравнивается с ней только на данных, появившихся после создания старой версии.
Шаг 10. Кандидат не получает власть автоматически
По умолчанию включена такая настройка:
{ "auto_promote": false }
Ежедневное обучение сохраняет модель и отчёт, но не делает кандидата действующим. Фиксированные D/A продолжают формировать пользовательскую оценку.
Здесь есть тонкость текущей реализации. predict_snapshot() выбирает только модели с active = 1. При auto_promote = false система сохраняет кандидата и результаты проверки, но не накапливает для него непрерывный ряд новых прогнозов автоматически. Для полноценного теневого режима нужен отдельный статус shadow или явная команда включения записи прогнозов без влияния на интерфейс.
сохранённый кандидат ≠ действующая модель ≠ модель теневого наблюдения
При любой из этих схем ML не должен менять веса D/A. Его прогноз хранится отдельно и может стать дополнительным фильтром только после проверки на новых данных.
Что эта система пока не доказывает
Даже правильная временная проверка не воспроизводит биржу до последней детали. Снимок стакана не знает будущую очередь заявок. Оценка глубины не учитывает изменение ликвидности во время отправки ордера. Ставка финансирования попадает в признаки, но её будущая выплата пока не включена в бумажный результат.
Две недели дают минимальный допуск к обучению, но не представляют все состояния рынка. Тренд, боковик, паника и резкое высыхание ликвидности могут выглядеть как разные задачи. Положительный результат на одном наборе режимов ещё не говорит, что связь сохранится на следующем.
Главная ценность текущего этапа не в выборе Elastic Net. Эксперимент оставляет проверяемый след: исходный ответ, время получения, версию признаков, причину отказа, будущую цену и отчёт на независимом отрезке.
Сложную модель можно добавить позже. Восстановить не записанный пакет или убрать из готового результата подсмотренную свечу уже не получится.
Практический запуск и проверка
# Запустить сборщик bash run.sh ml # Проверить источники, таблицы, метки и модели bash run.sh status # Запустить обучение вручную .venv/bin/python -m mlbot train # Выгрузить набор для горизонта 60 минут .venv/bin/python -m mlbot export \ --horizon 60 \ --output dataset_60m.csv
Основные пути:
ml_config.json настройки ML-контура data/ml/collector.log журнал процесса data/ml/raw/ сырые JSONL.gz data/ml/market.sqlite3 нормализованные данные data/ml/models/ модели и отчёты
Следующий полезный этап состоит из трёх работ: реализовать настоящий статус shadow, накопить несколько разных рыночных режимов и изучить устойчивость коэффициентов отдельно по времени и токенам. После этого можно сравнивать Elastic Net с более сложными алгоритмами, не меняя правила получения меток и финальной проверки.

