Как у меня НЕ получился торговый бот на Polymarket. Часть 1
Бумажный бот дал +9% за два дня. Разбивка по концентрации показала, что весь профит — в одном почти решённом рынке, а формула edge оказалась тавтологией из уверенности LLM.Видел в интернете кейс, где сделали бота с 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 (нет). Меня привлекла, так же, его прозрачность.
$0 ──────────── $1 нет да
Гипотеза, запускающая 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/):
news_ingestion → забор новостей, дедуп по хэшу
llm_analysis → LLM оценивает новость: relevance, impact_direction,
impact_strength, confidence
signal_engine → превращал анализ в "edge" и сигнал (v1–v4)
paper_trading → открывал/вёл/закрывал бумажные сделки (v1–v4)
llm_trader → LLM сам принимает ВСЕ торговые решения (v5)
llm_scorer → измерительный инструмент: грейдит решения (v5)
analytics → агрегаты для дашборда
market_data → клиент Polymarket (Gamma / CLOB / Data API)
telegram_ui → бот-меню
В 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. Весь профит — в двух рынках.
2 рынка +$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.
|0.10, 0.15) +$10.33 394 сделки остальные −$1.98 660 сделок
Так что тут произошло? Бот нашёл рынок, который почти наверняка резолвится в 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.
Соблазн назвать это растущим трендом — огромный. Особенно если ты только что выпустил релиз и хочется верить. И я даже кликбейтовый пост в канале писал. Тут разбивка — это суперпозиция двух процессов:
equity 100 → 93 → 104 → 96 → 108 → 98 → 111 распад +$20.1 полоса 0.10–0.15 движок −$12.0 настоящие ставки
- conc_band (полоса 0.10–0.15, та самая иранская машинка распада): +$20.1 за 6 дней, ноль стоп-лоссов в большие дни — но нерепрезентативно и истекает 31 мая вместе с резолюцией.
- 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.