Как у меня НЕ получился торговый бот на Polymarket. Часть 1

Николай Носков 8 мин

Видел в интернете кейс, где сделали бота с LLM для торговли на Polymarket и он сделал $10k из $1k за месяц. Ну и чо бы не попробовать что-то такое же сделать! Побочный проект, чисто по приколу. Вооружил его виртуальным стартовым капиталом — $100 нарисованных.

И вот на второй день этот несерьёзный проект показал +9% на счёте.

Сразу дисклеймер: я не трейдер. Вообще! Фрилансер-разработчик, IoT, телеграм-боты — это моё. А в трейдинге я плохо шарю, по-честному, и если в комментариях окажутся настоящие трейдеры и увидят очевидные глупости — это потому что я в этой теме очевидно глуп. Статья не про trading expertise (у меня её нет). Она про методологию измерения и про то, как я четыре раза подряд обманывал себя на ней. Тут у меня получше.

Обычно такой текст заканчивается на «И вот как я поднял иксы на ставках». Но нет! Следующие две недели я доказывал себе, что эти 9% — не прибыль, а артефакт измерения, причём четыре раза подряд. Параметрика, риск-слой, LLM-как-трейдер, FLB-харвест. И каждый раз казалось — вот сейчас, ну вот же оно!.. УВЫ.

TL;DR. Negative result. Если ждёте «как я поднял иксы» — закройте вкладку. Если интересно, как на живых (пусть и бумажных) деньгах отличить настоящий edge от артефакта измерения и почему рынок прогнозов сопротивляется наивным подходам — добро пожаловать. Код открыт, цифры воспроизводятся.

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

0. Что такое Polymarket и почему казалось, что тут есть деньги

Polymarket — рынок предсказаний. Контракт торгуется в диапазоне $0…$1, и цена читается как вероятность: 0.10 = «рынок оценивает шанс в 10%». Резолвится в $1 (да) или $0 (нет). Меня привлекла, так же, его прозрачность.

Гипотеза, запускающая 90% таких проектов (моего в том числе): толпа на рынках предсказаний систематически ошибается, а новостной поток + LLM позволяют поймать ошибку раньше рынка. В литературе даже есть имя для одной такой неэффективности — favorite-longshot bias (FLB): игроки переплачивают за маловероятные исходы. Бесплатный сыр, ну.

Сыр был в мышеловке (конечно жеж), а мышеловка — в моей собственной методике измерения.

1. Архитектура

Сам репозиторий. Стек намеренно скучный:

  • FastAPI — API-слой (api).
  • Aiogram 3 — телеграм-бот как фронт аналитики.
  • PostgreSQL — рынки, снапшоты цен, новости, анализы, сигналы, сделки.
  • Redis + Celery — worker + beat: инжест новостей, снятие снапшотов, генерация сигналов, мониторинг сделок.
  • Docker Compose; seed — единственный источник правды по активной стратегии (dev-режим: БД периодически вайпается, миграций нет).
  • CometAPI — доступ к LLM. Две модели: дешёвая для релевантности новостей, сильная (Sonnet 4.6) для торговых решений.

Модули (app/modules/):

В v1–v4 конвейер линейный: новость → анализ → сигнал → сделка → дашборд. Развалился он не там, где я ожидал.

2. v1–v2: болезнь time_limit

Первая рабочая версия (коммит 3ed8777) имела весь конвейер. v2 закрывала сделки по take-profit, stop-loss либо тайм-ауту удержания. Всё как у людей.

Результат v2 (n=260 закрытых):

  • +$0.21 суммарно, средняя сделка +$0.0008, winrate 49%;
  • 100% сделок закрылись по тайм-ауту. TP/SL не сработали ни разу.

Сижу, смотрю на это и думаю — да быть такого не может! Ну хоть один раз! Замерил фактическую амплитуду цены за окно удержания (120 минут): медиана 0.7 п.п., максимум 7 п.п. А пороги стояли на 10/7 п.п. Пороги были математически недостижимы. Короче — бот физически не мог по ним сработать. Я поставил TP так, чтобы он не мог сработать никогда. БРАВО :)

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

3. v3: +9% за два дня, которые оказались ничем

v3 (коммит 4c2d98d) починил пороги по измеренной амплитуде:

take_profit_abs:        0.02   (было 0.10)
stop_loss_abs:          0.015  (было 0.07)
max_holding_minutes:    240    (было 120)
default_position_size:  1.0    (было 0.1)

И стало красиво. Замер на n=1052 закрытых сделках:

  • механика ожила: TP 49%, SL 33%, time_limit 18%;
  • equity $109.03, +9% за два дня.

Если бы я остановился здесь — была бы статья «как я поднял 9% на Polymarket». Но обычно я двигался так: никогда не верить заголовочному PnL без разбивки по концентрации. И эта разбивка убила всё.

По рынкам: 2 рынка из 17 дали +$14.35, остальные 15 — −$6.10. Весь профит — в двух рынках.

Главный герой:

US x Iran peace by May 31:  NO,  371 сделка,  winrate 94.6%,  +$10.55

По цене входа: весь профит сидел в одной полосе entry_price |0.10, 0.15) = +$10.33 на 394 сделках. Уберём её — оставшиеся 660 сделок дают −$1.98.

Так что тут произошло? Бот нашёл рынок, который почти наверняка резолвится в NO (мира с Ираном к 31 мая не будет), и 371 раз перезаходил в одну и ту же near-resolved позицию, собирая детерминированный календарный распад. winrate 94.6% — не навык, а структура почти решённого рынка.

И тут я сначала предложил гениальное: «v4 = только NO». Неправильно — это бы УСИЛИЛО концентрацию на Иране. Направление — не рычаг, хорошо, что не закодил.

4. Оказалось: один корень на четыре провала

Пора назвать то, что я обещал в эпиграфе. У всех провалов было два сросшихся корня.

Первый: я измерял отметку, а не исход

Бумажная сделка «оценивается» по текущей цене рынка (mark). Открыл NO по 0.10, цена уехала на 0.08 — на бумаге у тебя +$0.02 «прибыли». Но это же не прибыль, а переоценка позиции по текущему марку! Реализованный результат известен только в момент резолюции: $1 или $0.

Почти весь «edge» в v1–v5 был артефактом измерения по марку и по дрейфу цены на горизонте +Nч, а не по тому, чем рынок реально закончился. Календарный распад почти решённого рынка по марку выглядит как стабильная прибыль. На этом и стоят иранские +$10.55.

Корень второй: формула edge была тавтологией

Открываю app/modules/signal_engine/service.py. Смотрю, как считается edge. Вот формула (сохранена в git и в докстринге текущего no-op):

sign  = self._direction_sign(impact_direction)        # +1 / -1
strength = self._clamp01(impact_strength)              # 0..1 из LLM
delta = strength * confidence * 0.5 * sign             # масштаб конвикшена LLM
model_probability = self._clamp01(market_probability + delta)
edge = model_probability - market_probability

Подставляю одно в другое, пока clamp не упёрся в границу:

edge = (market_probability + delta) − market_probability = delta
     = strength · confidence · 0.5 · sign

Ещё разок: «edge» — это буквально собственная уверенность LLM ×0.5. Он НИКОГДА не сравнивается с исходом. То есть замкнутый круг: модель уверена → значит есть edge → потому что модель уверена. Можно сколько угодно крутить пороги, поднимать min_edge, фильтровать по min_confidence. Но это не поможет, потому что величина, которую мы зовём «edge», не содержит информации о реальности. Это тавтология, замаскированная под метрику.

А вырождение clamp на краях ещё коварнее! Когда market_probability + delta упирается в 0 (низковероятный рынок, NO), clamp срезает model_probability в 0:

edge = 0 − market_probability = −market_probability

Самый большой по модулю edge — ровно у границы 0/1. А в paper_trading:

direction = "YES" if edge >= 0 else "NO"

На дешёвых лонгшотах edge всегда отрицательный → бот структурно всегда берёт NO. «100% NO на Иране» — артефакт клампа, а не стратегия: машинка по сбору распада была вшита в формулу. И вот так я её три недели принимал за альфу :)

А как должно было быть?

Edge на рынке предсказаний по определению — расхождение вашей независимой оценки вероятности с ценой:

edge = P_independent(event) − market_price

Оценка P_independent должна строиться БЕЗ участия рыночной цены. Варианты: базовая ставка по историческим аналогам (как часто события такого класса реально случались), внешняя предсказательная модель, ансамбль прогнозов, парсинг условий резолва. Моя формула вместо этого брала рыночную цену и прибавляла к ней конвикшен LLM. То есть P_independent была функцией от market_price, и edge схлопывался в тавтологию. Согласиться с ценой, которую толпа уже загнала в край, — это, вообще-то, не edge…

Если в оценке вероятности фигурирует текущая цена — это не независимая оценка, а дорогой способ согласиться с рынком.

5. v3, шестой день: волна, которую принимают за тренд

Прошло шесть дней. Equity рисовала «бычью» пилу: 100 → 93 → 104 → 96 → 108 → 98 → 111.

Соблазн назвать это растущим трендом — огромный. Особенно если ты только что выпустил релиз и хочется верить. И я даже кликбейтовый пост в канале писал. Тут разбивка — это суперпозиция двух процессов:

  1. conc_band (полоса 0.10–0.15, та самая иранская машинка распада): +$20.1 за 6 дней, ноль стоп-лоссов в большие дни — но нерепрезентативно и истекает 31 мая вместе с резолюцией.
  2. rest (настоящие направленные ставки движка): −$12.0 за 6 дней.

Equity растёт ТОЛЬКО потому, что +$20 распада обгоняют −$12 движка, а как закончится распад — закончится «тренд».

Нюанс: выкинув один день-провал (05-16: 384 стоп-лосса из 501, −$11.56), rest ≈ −$0.45 — на нормальных днях движок около нуля, но регулярно ловит неконтролируемый коррелированный хвост (~раз в 6 дней; YES-лонгшоты — спортивные фавориты + геополитика — двигаются вместе в risk-off дни). Тут нет ни edge, ни контроля хвоста.

Во второй части — риск-слой, LLM-как-трейдер с инверсной калибровкой, честный replay к резолюции, который убил последнюю гипотезу, и технические засады API Polymarket.