П/ВИН

ArbScanner: калькулятор ставок и фильтры по дате

·8 мин чтения
ArbScanner: калькулятор ставок и фильтры по дате

Когда смотришь на арбитражный сканер и видишь строчку «Stake on Polymarket: $52.10» — это абсолютно бесполезная информация. Куда ставить? На какой исход? Что будет, если выиграет другая сторона? Сколько это в годовых, если позиция висит до 2027 года? Именно с этих вопросов начался большой рефакторинг калькулятора и системы фильтрации в проекте arbscanner.

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

Проблема: калькулятор без смысла

Исходный detail-panel.tsx показывал примерно следующее:

Stake on Polymarket: $52.10
Stake on Kalshi:     $47.90
Total:               $100.00
Payout:              $103.20
Fee:                 $1.50
Profit:              $1.70

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

  • Непонятно, на какой исход ставить (Yes или No)
  • Непонятно, что произойдёт, если выиграет каждая из сторон
  • Нет ссылок на конкретные маркеты
  • Нет понимания, когда истекает позиция
  • Нет метрики годовой доходности (APY), а это критично для долгосрочных ставок

Вторая проблема была ещё острее. В таблице с арбитражными возможностями спокойно висели ставки с датой истечения в 2027 году. Пользователь видит «профит 1.8%» и думает — отлично. Но не понимает, что капитал будет заморожен на полтора года, а вилка скорее всего перестанет работать за это время по десятку причин: изменится ликвидность, поменяются правила резолюции, один из маркетов уйдёт в паузу.

Про это — отдельный риск. Самая большая угроза в арбитраже на предикшн-маркетах — не движение цены, а matching risk: два рынка могут выглядеть как зеркало, но на деле иметь разные формулировки, разные дедлайны резолюции и разные условия. Никакой калькулятор не защитит от этого автоматически, но хотя бы показывать пользователю всю информацию — обязанность инструмента.

Решение: пошаговый калькулятор со сценариями

Первым делом — полная переделка detail-panel.tsx. Концепция простая: показывать не абстрактные числа, а инструкцию к действию.

Два шага вместо списка

Калькулятор теперь состоит из двух карточек-шагов:

// Шаг 1
<StepCard>
  <Platform>Polymarket</Platform>
  <Action>Buy NO</Action>
  <Amount>$52.10</Amount>
  <Detail>Цена контракта: $0.521 × ~100 шт.</Detail>
  <Link href={marketUrl}>Открыть маркет →</Link>
</StepCard>
 
// Шаг 2
<StepCard>
  <Platform>Kalshi</Platform>
  <Action>Buy YES</Action>
  <Amount>$47.90</Amount>
  <Detail>Цена контракта: $0.479 × ~100 шт.</Detail>
  <Link href={marketUrl}>Открыть маркет →</Link>
</StepCard>

Это уже отвечает на вопрос «куда и что». Но самое важное — таблица сценариев.

Таблица сценариев: что будет, если...

Каждая арбитражная позиция имеет два возможных исхода. Мы показываем оба:

┌──────────────────┬────────────┬──────────┬────────────┐
│ Сценарий         │ Выплата    │ Комиссия │ Нетто      │
├──────────────────┼────────────┼──────────┼────────────┤
│ YES выигрывает   │ $100.00    │ -$1.50   │ +$1.50     │
│ NO выигрывает    │ $103.20    │ -$1.80   │ +$1.40     │
└──────────────────┴────────────┴──────────┴────────────┘
Гарантированный профит (worst case): +$1.40 / ROI: 1.4%

Теперь пользователь видит: независимо от исхода событий, он зарабатывает от $1.40 до $1.50. Это и есть суть арбитража — безрисковый профит. Именно слово «безрисковый» в скобках, потому что matching risk никуда не делся.

Под таблицей — блок с экспирацией, confidence score и предупреждением о рисках.

Фильтр по дате истечения

Вторая большая задача — добавить end_date в весь pipeline от бэкенда до UI.

Бэкенд: прокидываем end_date

Поле end_date уже было в сырых данных с маркетов, но не попадало в тип Opportunity. Исправляем:

// types.ts
export interface Opportunity {
  id: string
  profit_pct: number
  // ... другие поля
  end_date: string | null  // ISO 8601
  market_status: 'active' | 'stale' | 'closing_soon'
  apy: number | null
  match_type: 'exact' | 'probable' | 'speculative'
  max_executable: number | null
  capital_lock_days: number | null
}

В scan/route.ts — прокидываем end_date из NormalizedMarket в оба метода (pred↔sb и cross-pred):

const opportunity: Opportunity = {
  // ...
  end_date: marketA.end_date ?? marketB.end_date ?? null,
  apy: computeAPY(profitPct, marketA.end_date),
  market_status: getMarketStatus(marketA, marketB),
  match_type: classifyMatchType(marketA, marketB),
}
 
function computeAPY(profitPct: number, endDate: string | null): number | null {
  if (!endDate) return null
  const daysToResolution = Math.max(
    1,
    (new Date(endDate).getTime() - Date.now()) / 86400000
  )
  return (profitPct / daysToResolution) * 365
}

АPY — это ключевая метрика. Профит 1.4% за 3 дня — это 170% годовых. Профит 1.4% за 500 дней — это 1% годовых. Очевидно, что первое интереснее.

Фильтрация expired маркетов

Параллельно обнаружили баг: в результатах показывались маркеты с уже прошедшей датой окончания. Добавили фильтр на уровне каждого venue-адаптера:

// В каждом venue модуле
function filterExpired(markets: NormalizedMarket[]): NormalizedMarket[] {
  const now = new Date()
  return markets.filter(m => {
    if (!m.end_date) return true  // без даты — оставляем
    return new Date(m.end_date) > now
  })
}

UI: колонка Expires и фильтр в сайдбаре

В таблице появилась колонка «Expires» с умным форматированием:

function formatExpiry(endDate: string): string {
  const diff = new Date(endDate).getTime() - Date.now()
  const days = Math.floor(diff / 86400000)
  
  if (days < 0) return 'Expired'
  if (days === 0) return 'Today'
  if (days <= 7) return `${days}d`
  if (days <= 365) return format(new Date(endDate), 'MMM d')
  return format(new Date(endDate), "MMM d ''yy")
}

Результат: «3d», «Mar 15», «Jan 5 '27» — сразу понятно, насколько долгосрочная позиция.

В сайдбар добавили фильтр Max Expiry:

Max Expiry: [1 неделя ▼]
  • 1 неделя
  • 1 месяц  
  • 3 месяца
  • 6 месяцев
  • 1 год
  • Без лимита

По умолчанию — 3 месяца. Это разумный дефолт: вилка с горизонтом больше 3 месяцев несёт слишком много неопределённости.

Status badges и Match type

Вместе с датами добавили несколько важных визуальных индикаторов.

Market status badges:

  • STALE — мало ликвидности, bid-ask spread слишком широкий
  • SOON — маркет закрывается меньше чем через 24 часа

Match type badges вместо числового confidence:

  • ✓ exact — формулировки рынков идентичны
  • ≈ probable — высокая вероятность совпадения условий
  • ? speculative — рынки могут резолвиться по-разному

Это прямо адресует главный риск арбитража на предикшн-маркетах. Видишь ? speculative — читай описания рынков вручную перед входом.

Max Executable и Capital Lock

Две дополнительные метрики, которые сильно влияют на реальную ценность арбитражной возможности:

Max Executable — максимальная сумма, которую можно поставить до исчезновения арба. Считается по глубине ордербука. Смысл: арб на $200 при максимальном исполнении $15 — это не очень интересно.

Capital Lock — сколько дней капитал будет заморожен до резолюции. Вместе с APY это даёт полную картину эффективности использования капитала.

interface Opportunity {
  max_executable: number | null   // в долларах
  capital_lock_days: number | null // количество дней
}

В UI эти поля показываются в карточке детали и в тултипе при наведении на строку таблицы.

Деплой и инфраструктура

После реализации — стандартный цикл: npm run build, все 83 теста зелёные, коммит, деплой через Dokploy.

Кстати, обнаружили проблему: GitHub webhook к Dokploy возвращал 401 — refreshToken устарел. Деплой пришлось тригернуть вручную через API:

curl -X POST https://dokploy.pashavin.ru/api/application.deploy \
  -H "Authorization: Bearer [СКРЫТО]" \
  -H "Content-Type: application/json" \
  -d '{"applicationId": "[APP_ID]"}'

Статус вернул done, https://arbscanner.pashavin.ru/ — 200 OK. Webhook нужно починить отдельно, обновив токен в настройках.

Git история: путь проекта

За последние недели проект прошёл большой путь. Если смотреть по коммитам:

  • Сначала появился базовый сканер с таблицей
  • Добавлен калькулятор (тот самый, который мы переделали)
  • Появилась история сделок с P&L и цветовой кодировкой
  • Добавлен real bid price из CLOB ордербука вместо фейкового mid price
  • Отдельные конфиги стратегий для Real и Paper режимов
  • Paper trading модуль с виртуальным балансом $1000
  • Миграция всех данных в Supabase для выживания между деплоями
  • И наконец — переделка калькулятора и система фильтрации по датам

Проект растёт в сторону полноценного торгового инструмента с автоматическим исполнением сделок.

Технологии

Проект построен на стандартном стеке:

  • Next.js — фронтенд и API routes в одном приложении
  • React — компоненты UI, в том числе detail-panel и таблица
  • TypeScript — строгая типизация, что особенно важно при работе с финансовыми данными
  • Supabase — хранение позиций и истории торгов, переживает рестарты контейнера
  • Dokploy — деплой через Docker на VPS

Выводы

Главный урок этой итерации: информация без контекста — это не информация. «Stake on Polymarket: $52.10» технически верно, но бесполезно. «Купи NO на Polymarket за $52.10, открой этот маркет, если NO выиграет — получишь $103.20 минус комиссию $1.80, нетто +$1.40» — это уже инструкция к действию. Разница огромная, а кода нужно написать не так много.

Второй урок: дата истечения — это не просто поле для отображения. Это ключевой фактор для оценки возможности. Арбитраж с горизонтом год+ — это совсем другой инструмент, чем арбитраж на неделю. APY позволяет сравнивать их по единой метрике и принимать осознанные решения. Без этого числа пользователь просто не может адекватно оценить привлекательность возможности.

Третий урок: matching risk нельзя решить алгоритмически, но можно хотя бы честно показать. Бейджи exact / probable / speculative не защищают от ошибки, но дают пользователю сигнал: вот здесь нужно думать самому. Хороший инструмент не притворяется, что решил проблему, которую не решил.

Четвёртый урок: фильтрация expired маркетов должна быть на уровне бэкенда, а не только на фронте. Показывать просроченные возможности — это не просто UX-баг, это активно вводящая в заблуждение информация. Такие вещи нужно чистить у источника, в каждом venue-адаптере отдельно, потому что каждый источник данных имеет свою специфику форматов дат и edge cases.

Дальше проект движется в сторону автоматического исполнения сделок через Polymarket CLOB API и Kalshi Trading API. Это уже другой уровень сложности — там появляются вопросы идемпотентности, kill switch, управления позициями при частичном исполнении. Но фундамент — понятный калькулятор и честная фильтрация данных — теперь на месте.

Паша Вин
Паша Вин

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