П/ВИН

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

·9 мин чтения
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 года

В таблице появлялись арбитражные возможности, где контракт заканчивался через год или даже больше. Вот почему это проблема:

  1. Ликвидность. Цены на контракты с дальним сроком нестабильны и могут сильно меняться. Вилка, которую ты видишь сегодня, через неделю может исчезнуть — просто потому что одна из платформ переоценила рынок.

  2. Opportunity cost. Деньги заморожены на год+ ради 2-3% профита. Это хуже банковского депозита с учётом рисков.

  3. Платформенный риск. За год может произойти что угодно: платформа может изменить правила резолюции, заморозить вывод, закрыться.

  4. 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 в бизнес.