Весной у меня был хобби-проект вокруг Polymarket, биржи предсказаний. На ней торгуют контрактами на исход события: «случится ли X до такой-то даты». Контракт стоит от 0 до 1 доллара. Когда исход определён (рынок разрешился), контракт платит 1, если событие случилось, и 0, если нет. Поэтому цена примерно равна вероятности, которую рынок даёт событию: контракт за 0,10 рынок считает исходом с шансом 10%.
Купил контракт за 0,10. Событие случилось: получил 1, заработал 0,90, то есть 900% на вложенное. Не случилось: потерял 0,10. Доля выигрышных сделок называется win rate (WR), а ROI показывает, сколько процентов ты заработал или потерял на всём, что вложил.
Детектор инсайдеров
Всё началось с вопроса не про торговлю: можно ли по публичным сделкам найти тех, кто знал исход заранее. Признак напрашивается сам: человек покупает дешёвый, маловероятный исход крупной суммой, и этот исход сбывается. Так я и написал скорер, скрипт, который ставит паре «кошелёк и рынок» оценку подозрительности. Вот как в нём отбирались сделки для оценки:
SELECT t.maker, t.condition_id, t.value_usd, t.timestamp
FROM trades_p t
JOIN markets m ON m.condition_id = t.condition_id
WHERE t.side = 'BUY'
AND t.price < 0.25 -- дешёвый исход, шанс меньше 25%
AND m.resolved_outcome = t.outcome -- и этот исход сбылся
Для расследования это правильный запрос: смотрим в прошлое, итог известен, ищем тех, кто угадал маловероятное.
Вот как такой случай выглядит на настоящем рынке. Вечером 2 января контракт «Мадуро потеряет власть до 31 января» стоил 6,5 цента, а утром 3 января исход сбылся и цена ушла к 1.
Допустим, кто-то вечером 2 января купил «да» на крупную сумму. Детектор смотрит на эту сделку потом, когда исход уже известен, и честно отмечает её как подозрительную. Живая система вечером 2 января видит только покупку по 6,5 цента: сбудется ли исход, ей неизвестно. Бэктест разыгрывает тот же вечер 2 января, но берёт данные из базы, где исход уже записан. Если он хоть где-то на него опирается, он видит больше, чем увидела бы живая система.
Стратегия и первая утечка
Потом я захотел сделать из детектора стратегию: если скорер отметил кошелёк, покупай ту же сторону. В первом бэктесте вход считался в момент первой дешёвой покупки кошелька и по её цене. Это уже идеализация: скорер замечает сделку только после того, как она прошла. Дальше прибыль фиксируется частями, когда цена вырастает в 2, 3 и 5 раз, по стоп-лоссу позиция закрывается, а остаток держится до разрешения рынка. Отбор сделок для бэктеста делал тот же скорер.
Проблема в последней строке запроса. Скорер отбирает только те сделки, которые уже выиграли. В бэктесте по истории поле resolved_outcome заполнено, и стратегия «повторяй за подозрительными» на деле означала «покупай то, что выиграло». В живой торговле исход в момент сделки неизвестен, и такой подсказки нет. В машинном обучении это называется target leakage: целевая переменная просочилась в признаки. Тут она просочилась прямо в WHERE.
Упрощённо это выглядит так. Сто тысяч покупок дешёвых контрактов по цене от 0,02 до 0,25, как в запросе, и никакого инсайда: каждая сделка выигрывает ровно с вероятностью, равной своей цене. Без весов, стоп-лоссов и частичной фиксации, только отбор:
import random
random.seed(1)
trades = []
for _ in range(100_000):
price = random.uniform(0.02, 0.25)
trades.append({"price": price, "won": random.random() < price})
def report(name, picked):
staked = sum(t["price"] for t in picked) # платим цену контракта
returned = sum(1.0 for t in picked if t["won"]) # выигрыш платит 1
wins = sum(t["won"] for t in picked) / len(picked)
print(f"{name:12} сделок {len(picked):6} выигрышей {wins:6.1%} "
f"ROI {returned / staked - 1:+8.1%}")
# честно: в момент сделки исход неизвестен, повторяем все такие покупки
report("честный", trades)
# детектор задним числом: «подозрительная» сделка = дешёвая И выигравшая
report("с утечкой", [t for t in trades if t["won"]])
честный сделок 100000 выигрышей 13.6% ROI +0.3%
с утечкой сделок 13551 выигрышей 100.0% ROI +493.9%
Честно выходит около нуля, как и должно быть при справедливых ценах. С утечкой выходит 100%, потому что отбор просто перечисляет выигравшие сделки.
Проверка нашла только половину
Утечку в скорере я заметил и проверил: сделал режим, в котором скорер видит только то, что известно в момент сделки. Это цена входа, размер ставки, насколько кошелёк свежий, на скольких рынках он торгует и сколько ему лет в блокчейне. Убрал и resolved_outcome, и признаки вроде «сколько кошелёк уже выиграл на дешёвых исходах». Прогон дал 90,8% выигрышей на 5646 позициях, на рабочем пороге 92,8% на 9589, и я записал в план «edge is real».
Вторая утечка сидела не в скорере, а в ценах. Бэктест смотрел на почасовые свечи, записи о том, какой была цена контракта за каждый час: максимум и минимум. Хранились они не по исходам «да» и «нет», а по сторонам «выигравшая» и «проигравшая». Симуляция повторения брала для позиции цену выигравшей стороны, даже если повторённая сторона проиграла.
Цена выигравшей стороны к концу почти всегда растёт, поэтому частичная фиксация прибыли срабатывала почти у каждой позиции. Проигрыши оставались только там, где раньше срабатывал стоп-лосс или цена так и не доходила до первой цели. Отсюда и 92,8%.
Третья, помельче: цена входа бралась как лучшая из всех покупок кошелька на этом рынке, включая будущие.
По этому скореру к тому времени уже шла виртуальная торговля: позиции открывались по живым сигналам, но без денег. Первая версия закрыла 635 позиций в минусе на 532 условных доллара. Бэктест обещал 92,8%, виртуальная торговля теряла.
На следующий день я написал новый бэктестер. Свечи в нём хранятся по исходам, вход идёт по цене в момент сигнала с задержкой исполнения, скорер видит только признаки на момент сделки, а в отчёт встроена проверка на утечки. Повторение покупок свежих кошельков дешевле 10 центов дало в нём 5,5% выигрышей. Это не та же выборка, что в первом прогоне. Средней цены и числа сделок этого прогона в записях проекта не осталось, но при цене до 10 центов честная доля выигрышей не выше 10%, и 5,5% в эту границу укладываются: преимущества у стратегии нет. Повторять за крупными ставками на дешёвые исходы невыгодно. Были ли среди них инсайдеры, такой тест не говорит.
Ещё три способа соврать
Новый бэктестер тоже пришлось чинить, уже на других стратегиях:
- Незакрытые позиции. Одна стратегия фиксировала прибыль частями. Если рынок не успевал разрешиться до конца окна бэктеста, позиция засчитывалась по уже снятой прибыли, а оставшиеся доли не учитывались. Почти любая такая позиция выглядела выигрышной. После исправления оставшиеся доли считаются потерянными. Это осторожная оценка: рынок к концу окна не разрешился, и чем он кончится, бэктест не знает. С ней 98,1% выигрышей при ROI +287% превратились в 62,9% и −9,9%.
- Время входа. Новостная стратегия в бэктесте входила в момент публикации новости, а живая система открывала позицию, когда новость обработана. Только 8% новостей обрабатывались в течение часа, а 12% уже после того, как рынок разрешился. Бэктест покупал по ценам, которых у живой системы не было.
- Фильтр по датам. Условие
COALESCE(end_date, updated_at) <= todayвыбрасывало рынки, у которых плановая дата окончания стояла в будущем, хотя они уже разрешились досрочно. Это задело долгие события, на которых работала новостная стратегия: после исправления у неё стало 103 сделки вместо 56, и ROI ушёл в минус, −5,9%.
Высокий WR не значит прибыль
Обратная ловушка: одна из стратегий покупала почти уверенные исходы по 0,90–0,96. На разрешённых рынках она выиграла 179 сделок из 195, это 91,8%, и всё равно теряла около 2%. Вернёмся к сделке из начала, только по цене 0,90. Выиграл, заработал 0,10. Проиграл, потерял 0,90. Без комиссии в ноль выходишь при 9 выигрышах из 10, а при её ценах и с комиссией порог был около 93,5%.
Что вынес
- Код, который ищет закономерности в прошлом, нельзя без проверки переносить в прогноз. Будущее просачивается не только через признаки, но и через отбор строк, и через то, как устроены сами данные: свечи «выигравшей стороны» уже знают исход.
- Бэктест должен видеть мир так же, как живая система: с задержками, комиссиями и позициями, которые ещё не закрылись.
- Доля выигрышей без размера выигрыша и проигрыша ничего не говорит, смотреть надо на ROI.
Ни одна стратегия из этого поста на честном бэктесте не оказалась прибыльной, повторять их не надо.
Черновик подготовлен с помощью ИИ по заметкам и коду автора; цифры сверены с репозиторием проекта, пример на синтетике проверен запуском.