# Бэктест показал 92,8% выигрышей, а стратегия не работала: детектор, который превратился в утечку

Источник: https://blog.xsk.in/kak-moj-bektest-vral/
Опубликовано: 2026-11-03 · Рубрики: IT, Трейдинг


Весной у меня был хобби-проект вокруг Polymarket, биржи предсказаний. На ней торгуют контрактами на исход события: «случится ли X до такой-то даты». Контракт стоит от 0 до 1 доллара. Когда исход определён (рынок разрешился), контракт платит 1, если событие случилось, и 0, если нет. Поэтому цена примерно равна вероятности, которую рынок даёт событию: контракт за 0,10 рынок считает исходом с шансом 10%.

Купил контракт за 0,10. Событие случилось: получил 1, заработал 0,90, то есть 900% на вложенное. Не случилось: потерял 0,10. Доля выигрышных сделок называется win rate (WR), а ROI показывает, сколько процентов ты заработал или потерял на всём, что вложил.

## Детектор инсайдеров

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

```sql
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.

{{< inline-svg src="posts/backtest/market.svg" caption="Цена исхода «да» на рынке Polymarket «Мадуро потеряет власть до 31 января 2026», по часам, время UTC. Данные из публичного API Polymarket." >}}

Допустим, кто-то вечером 2 января купил «да» на крупную сумму. Детектор смотрит на эту сделку потом, когда исход уже известен, и честно отмечает её как подозрительную. Живая система вечером 2 января видит только покупку по 6,5 цента: сбудется ли исход, ей неизвестно. Бэктест разыгрывает тот же вечер 2 января, но берёт данные из базы, где исход уже записан. Если он хоть где-то на него опирается, он видит больше, чем увидела бы живая система.

## Стратегия и первая утечка

Потом я захотел сделать из детектора стратегию: если скорер отметил кошелёк, покупай ту же сторону. В первом бэктесте вход считался в момент первой дешёвой покупки кошелька и по её цене. Это уже идеализация: скорер замечает сделку только после того, как она прошла. Дальше прибыль фиксируется частями, когда цена вырастает в 2, 3 и 5 раз, по стоп-лоссу позиция закрывается, а остаток держится до разрешения рынка. Отбор сделок для бэктеста делал тот же скорер.

Проблема в последней строке запроса. Скорер отбирает только те сделки, которые уже выиграли. В бэктесте по истории поле `resolved_outcome` заполнено, и стратегия «повторяй за подозрительными» на деле означала «покупай то, что выиграло». В живой торговле исход в момент сделки неизвестен, и такой подсказки нет. В машинном обучении это называется target leakage: целевая переменная просочилась в признаки. Тут она просочилась прямо в `WHERE`.

{{< inline-svg src="posts/backtest/timeline.svg" caption="Детектор смотрит из будущего в прошлое. Бэктест решает в момент сделки, но через resolved_outcome видит исход. Живая торговля его не видит." >}}

Упрощённо это выглядит так. Сто тысяч покупок дешёвых контрактов по цене от 0,02 до 0,25, как в запросе, и никакого инсайда: каждая сделка выигрывает ровно с вероятностью, равной своей цене. Без весов, стоп-лоссов и частичной фиксации, только отбор:

```python
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% в эту границу укладываются: преимущества у стратегии нет. Повторять за крупными ставками на дешёвые исходы невыгодно. Были ли среди них инсайдеры, такой тест не говорит.

## Ещё три способа соврать

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

1. Незакрытые позиции. Одна стратегия фиксировала прибыль частями. Если рынок не успевал разрешиться до конца окна бэктеста, позиция засчитывалась по уже снятой прибыли, а оставшиеся доли не учитывались. Почти любая такая позиция выглядела выигрышной. После исправления оставшиеся доли считаются потерянными. Это осторожная оценка: рынок к концу окна не разрешился, и чем он кончится, бэктест не знает. С ней 98,1% выигрышей при ROI +287% превратились в 62,9% и −9,9%.
2. Время входа. Новостная стратегия в бэктесте входила в момент публикации новости, а живая система открывала позицию, когда новость обработана. Только 8% новостей обрабатывались в течение часа, а 12% уже после того, как рынок разрешился. Бэктест покупал по ценам, которых у живой системы не было.
3. Фильтр по датам. Условие `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%.

{{< inline-svg src="posts/backtest/breakeven.svg" caption="Без комиссии выходишь в ноль, когда доля выигрышей равна цене контракта. Дешёвым контрактам хватает редких выигрышей, дорогим нужны почти все." >}}

## Что вынес

1. Код, который ищет закономерности в прошлом, нельзя без проверки переносить в прогноз. Будущее просачивается не только через признаки, но и через отбор строк, и через то, как устроены сами данные: свечи «выигравшей стороны» уже знают исход.
2. Бэктест должен видеть мир так же, как живая система: с задержками, комиссиями и позициями, которые ещё не закрылись.
3. Доля выигрышей без размера выигрыша и проигрыша ничего не говорит, смотреть надо на ROI.

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

*Черновик подготовлен с помощью ИИ по заметкам и коду автора; цифры сверены с репозиторием проекта, пример на синтетике проверен запуском.*

