ArbScanner: калькулятор арбитража и фильтр по датам

Когда видишь в таблице арбитражную вилку с профитом 3.5% — это ещё не значит, что надо немедленно жать «купить». Нужно понять: сколько именно ставить на каждую платформу, на какой исход (YES или NO), и главное — когда эта вилка вообще закрывается. Именно эти две проблемы мы решали в последнем большом апдейте ArbScanner.
Контекст: что такое ArbScanner
ArbScanner — это инструмент для поиска арбитражных возможностей между prediction markets: Polymarket, Kalshi, PredictIt и спортивными букмекерами через The Odds API. Идея простая: на разных платформах одно и то же событие оценивается по-разному, и если сумма вероятностей меньше 1 — есть арбитраж.
Например: Polymarket оценивает «Байден выиграет выборы» в 52¢ за YES, Kalshi — в 44¢ за NO. Итого 0.52 + 0.44 = 0.96, то есть теоретически можно гарантированно заработать ~4% минус комиссии. Но чтобы это реально сработало, нужно точно знать: сколько ставить на каждую платформу, на какой конкретно исход, и успеет ли вилка закрыться до экспирации контракта.
До этого обновления калькулятор показывал что-то вроде «Stake on Polymarket: $52.10» — и всё. Ни исхода, ни сценариев, ни дат.
Проблема 1: калькулятор был слепым
Представь: ты видишь две строки — «Stake on Polymarket: $52.10» и «Stake on Kalshi: $47.90». Но не видишь:
- На какой исход ставить на каждой платформе (YES или NO?)
- Что произойдёт если выиграет сторона A, а что если B
- Сколько контрактов покупать при текущей цене
- Какова реальная комиссия каждой платформы в долларах
Это как рецепт без граммовки — теоретически понятно, практически бесполезно.
Кроме этого, была серьёзная ошибка в самой формуле расчёта арбитража. В arbitrage.ts комиссии вычитались из обеих ног одновременно:
// БЫЛО (неправильно):
const adjProbA = probA + (1 - probA) * feeA
const adjProbB = probB + (1 - probB) * feeB
const netCost = adjProbA + adjProbBНо в реальности выигрывает только одна нога за раз. Если исход A случился — комиссию ты платишь только по позиции на платформе A, позиция B просто истекает. Правильная формула:
// СТАЛО (правильно):
// Если A выигрывает:
const profitIfA = (1 - feeA) * payoutA - totalCost
// Если B выигрывает:
const profitIfB = (1 - feeB) * payoutB - totalCost
// Гарантированный профит = минимум из двух сценариев
const guaranteedProfit = Math.min(profitIfA, profitIfB)
const net_arb_pct = guaranteedProfit / totalCostЭта ошибка занижала net_arb_pct на 1-3%, что могло отфильтровывать реальные пограничные арбитражи.
Решение: калькулятор с пошаговой инструкцией
Переделали detail-panel.tsx полностью. Теперь вместо сухого списка — три блока:
Блок 1 — Ввод банкролла. Поле для суммы, которую готов вложить. Всё остальное пересчитывается автоматически.
Блок 2 — Два шага с конкретными инструкциями:
┌─────────────────────────────────────────────────┐
│ Bankroll: [$100____] │
├─────────────────────────────────────────────────┤
│ ШАГ 1: Polymarket — Купить YES │
│ Ставка: $52.10 │
│ Цена контракта: $0.52 │
│ Количество контрактов: ~100 шт. │
│ Комиссия платформы: $0.52 (1%) │
├─────────────────────────────────────────────────┤
│ ШАГ 2: Kalshi — Купить NO │
│ Ставка: $47.90 │
│ Цена контракта: $0.44 │
│ Количество контрактов: ~108 шт. │
│ Комиссия платформы: $0.43 (0.9%) │
└─────────────────────────────────────────────────┘
Блок 3 — Таблица сценариев:
| Исход | Выплата | Затраты | Профит | ROI | |-------|---------|---------|--------|-----| | Если YES победит | $99.48 | $100.00 | -$0.52 | -0.5% | | Если NO победит | $102.31 | $100.00 | +$2.31 | +2.3% | | Гарантированный минимум | | | -$0.52 | -0.5% |
Да, иногда гарантированного профита нет — и это важно показывать честно. Пользователь сам решает, стоит ли рисковать.
В коде это выглядит примерно так:
interface CalcScenario {
outcome: string // "YES wins" | "NO wins"
platform: string // "Polymarket" | "Kalshi"
payout: number
cost: number
profit: number
roi: number
}
function calculateScenarios(
bankroll: number,
opportunity: Opportunity
): CalcScenario[] {
const { stakeA, stakeB, payoutA, payoutB, feeA, feeB } = calcStakes(bankroll, opportunity)
return [
{
outcome: `${opportunity.outcomeA} wins`,
platform: opportunity.venueA,
payout: payoutA * (1 - feeA),
cost: bankroll,
profit: payoutA * (1 - feeA) - bankroll,
roi: (payoutA * (1 - feeA) - bankroll) / bankroll
},
{
outcome: `${opportunity.outcomeB} wins`,
platform: opportunity.venueB,
payout: payoutB * (1 - feeB),
cost: bankroll,
profit: payoutB * (1 - feeB) - bankroll,
roi: (payoutB * (1 - feeB) - bankroll) / bankroll
}
]
}Дополнительно добавили учёт лимита PredictIt в $850 — это реальное ограничение платформы на позицию в одном контракте, его важно учитывать при расчёте максимальной ставки.
Проблема 2: вилки со сроком до 2027 года
В таблице появлялись арбитражные возможности, где контракт заканчивался через год или даже больше. Вот почему это проблема:
-
Ликвидность. Цены на контракты с дальним сроком нестабильны и могут сильно меняться. Вилка, которую ты видишь сегодня, через неделю может исчезнуть — просто потому что одна из платформ переоценила рынок.
-
Opportunity cost. Деньги заморожены на год+ ради 2-3% профита. Это хуже банковского депозита с учётом рисков.
-
Платформенный риск. За год может произойти что угодно: платформа может изменить правила резолюции, заморозить вывод, закрыться.
-
Resolution mismatch. Две платформы могут по-разному определить исход — особенно в долгосрочных политических рынках.
Поэтому пользователю критически важно видеть дату окончания и уметь фильтровать по ней.
Решение: end_date через весь стек
Филд end_date существовал в данных от Polymarket и Kalshi, но... не передавался дальше. Пришлось прокинуть его через весь стек.
Шаг 1: Типы
// types/opportunity.ts
interface Opportunity {
id: string
venueA: string
venueB: string
// ... существующие поля ...
end_date?: string // ISO 8601, добавлено
}
interface ActiveFilters {
minProfit: number
// ... существующие ...
maxExpiry?: Date // добавлено
}Шаг 2: Scan route
В app/api/scan/route.ts добавили передачу end_date при маппинге NormalizedMarket в Opportunity. Также добавили инференс даты для выборов — если платформа не даёт дату, но в вопросе есть год (например, «2026 Midterms»), вытаскиваем дату из текста:
function inferEndDate(question: string, explicitDate?: string): string | undefined {
if (explicitDate) return explicitDate
// Для выборов — инферим из года в названии
const yearMatch = question.match(/\b(202[4-9]|203\d)\b/)
if (yearMatch && /election|midterm|primary/i.test(question)) {
return `${yearMatch[1]}-11-30` // примерная дата выборов
}
return undefined
}Шаг 3: Фильтр в sidebar
Добавили select «Max Expiry» с вариантами: 1 неделя, 1 месяц, 3 месяца, 6 месяцев, 1 год, без лимита. И сортировку по дате окончания (ближайшие сначала / дальние сначала).
Шаг 4: Колонка в таблице
Добавили колонку «Expires» с умным форматированием:
- До 7 дней: «3d» (горит красным)
- До 30 дней: «Mar 15» (оранжевый)
- Дальше: «Mar 15 '27» (серый)
function formatExpiry(dateStr?: string): { label: string; urgency: 'urgent' | 'soon' | 'distant' | 'unknown' } {
if (!dateStr) return { label: '—', urgency: 'unknown' }
const days = differenceInDays(new Date(dateStr), new Date())
if (days <= 7) return { label: `${days}d`, urgency: 'urgent' }
if (days <= 30) return { label: format(new Date(dateStr), 'MMM d'), urgency: 'soon' }
return { label: format(new Date(dateStr), "MMM d ''yy"), urgency: 'distant' }
}Попутно: полное ревью и восемь багфиксов
Пока разбирались с датами и калькулятором, провели полное ревью кода — и нашли 8 проблем, которые объясняли, почему сканер иногда не показывал ни одного сигнала.
Помимо уже упомянутой ошибки в формуле комиссий:
HTTP ошибки игнорировались. Все четыре venue-модуля делали fetch() без проверки resp.ok. Если API отвечало 429 или 503 — код падал на парсинге JSON, но тихо. Добавили явную проверку:
const resp = await fetch(url)
if (!resp.ok) {
console.error(`[${venue}] HTTP ${resp.status} for ${url}`)
return []
}Порог матчинга был слишком высоким. Для non-sports рынков порог similarity стоял на 0.4 — слишком жёстко для вопросов вроде «Will X happen before Y date?» vs «X by end of Y». Снизили до 0.3 и добавили веса для чисел и entity-имён.
Kalshi и PredictIt fallback. Некоторые рынки Kalshi отдают null вместо цены — добавили fallback на lastPrice. У PredictIt аналогично — lastTradePrice мог быть null, теперь берём bestBuyYesCost.
Polymarket — мало рынков. REST запрос брал только первую страницу (100 рынков). Добавили пагинацию до 500 активных рынков.
После всех фиксов — 82 теста, все зелёные, билд прошёл.
Архитектурный момент: убрали fallback на демо-данные
Обнаружили неприятную вещь: в app/api/scan/route.ts был скрытый fallback — если все реальные источники возвращали 0 результатов, сервер автоматически подставлял демо-данные. Пользователь думал, что видит реальные вилки, а на самом деле — фейковые.
Убрали это. Теперь если источники ничего не нашли — пустая таблица с реальными статусами источников и ошибками. Честнее и полезнее для отладки.
// БЫЛО:
if (opportunities.length === 0) {
return NextResponse.json({ opportunities: DEMO_DATA, is_demo: true })
}
// СТАЛО:
return NextResponse.json({
opportunities,
sources: sourceStatuses, // реальные статусы каждого источника
is_demo: false
})Результат
Что в итоге получил пользователь ArbScanner после этого обновления:
- Калькулятор — конкретные инструкции: на какую платформу, на какой исход, сколько контрактов, таблица сценариев с двумя исходами
- Фильтр по экспирации — можно отсечь все вилки дольше месяца и работать только с краткосрочными
- Сортировка по дате — ближайшие к экспирации вилки наверху, самые срочные видно сразу
- Исправленная формула — реальные цифры профита без занижения
- Честная пустота — если вилок нет, то нет, а не демо-заглушка
Git-история показывает, что параллельно с этим работала более широкая перестройка: добавили paper trading, миграцию данных в Supabase, стратегии с конфигами — но это уже отдельная история.
Выводы
Первый урок: UX арбитражного инструмента — это не красота, это точность инструкций. Пользователь принимает финансовое решение за секунды. Если он должен самостоятельно догадываться «на какой исход ставить» — он ошибётся или просто закроет страницу. Каждый лишний мыслительный шаг — это риск.
Второй урок: временной контекст обязателен для любого финансового инструмента. Дата экспирации — это не просто метаданные. Это фундаментальная характеристика арбитражной возможности. Вилка со сроком 2 дня и вилка со сроком 2 года — это абсолютно разные продукты с разным профилем риска. Показывать их одинаково и без фильтрации — значит вводить пользователя в заблуждение.
Третий урок: тихие ошибки хуже громких. Когда fetch() не проверяет resp.ok, а fallback подставляет демо-данные вместо пустоты — система создаёт иллюзию работы. Пользователь уверен, что видит реальные данные, принимает решения, теряет деньги. Явные ошибки и честная пустота — это уважение к пользователю.
Четвёртый урок: формулы стоит проверять на граничных случаях. Ошибка с двойным вычетом комиссий выглядела «почти правильной» — интуитивно кажется, что надо учитывать комиссии обеих сторон. Но в арбитраже выигрывает только одна нога, и это меняет математику. Такие ошибки особенно опасны: система работает, тесты проходят, но цифры немного неправильные — и это стоит реальных денег.

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.