П/ВИН

Arbscanner: детальный калькулятор и фильтр по дате

·9 мин чтения

Когда занимаешься арбитражем на предикшн-маркетах, информация «ставка на Polymarket: $52» — это почти ничто. Куда именно? На YES или NO? Что произойдёт, если выиграет сторона A? А если сторона B? И вообще — зачем открывать вилку, которая закрывается в 2027 году, если за это время рынок успеет переписаться пять раз? Именно эти вопросы привели меня к двум конкретным задачам: полной переработке калькулятора в боковой панели и добавлению фильтрации по дате окончания ставки.

Контекст: что такое arbscanner

Arbscanner — мой инструмент для автоматического поиска арбитражных возможностей между предикшн-маркетами (Polymarket, Kalshi, PredictIt) и спортивными букмекерами. Он сканирует рынки, матчит похожие вопросы, считает net arb percent и выводит список вилок в веб-интерфейс.

Архитектура: Next.js 14 на фронте, API route /api/scan на беке, TypeScript везде. Вилка — это ситуация, когда сумма implied probabilities двух противоположных исходов на разных площадках меньше 100%. Берёшь обе стороны — гарантированный профит.

Но вот в чём проблема: знать, что вилка существует — недостаточно. Нужно понять как именно её разыграть. И желательно не на рынках, которые завершатся через полтора года.

Проблема №1: калькулятор был слишком абстрактным

Старый калькулятор в detail-panel.tsx выводил примерно следующее:

Stake on Polymarket: $52.10
Stake on Kalshi:     $47.90
Total invested:      $100.00
Payout:              $103.20
Fee:                 $1.80
Net profit:          $1.40

Звучит неплохо, но на практике это вызывало кучу вопросов. На какой исход ставить на Polymarket — YES или NO? Если я ставлю $52 на YES на Polymarket и YES на Kalshi — это не вилка, это удвоенный риск. Надо чётко видеть: на одной площадке YES, на другой NO. А ещё — что будет с деньгами в каждом сценарии.

Пользователь (то есть я) в какой-то момент написал: «распиши более подробно — сколько поставить, куда, на какой итог, чтобы видел наглядно». Это и стало техническим заданием.

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

Переработал калькулятор по принципу «два шага + таблица исходов». Вот как выглядит новая структура компонента:

// components/detail-panel.tsx (после)
 
interface CalcStep {
  platform: string
  action: 'Buy YES' | 'Buy NO'
  stake: number
  price: number
  contracts: number
}
 
interface Scenario {
  winner: string
  payoutA: number
  payoutB: number
  totalReturn: number
  netProfit: number
}
 
function Calculator({ opportunity, bankroll }: CalculatorProps) {
  const steps = computeSteps(opportunity, bankroll)
  const scenarios = computeScenarios(opportunity, steps)
 
  return (
    <div className="calculator">
      <BankrollInput value={bankroll} onChange={setBankroll} />
      
      <div className="steps">
        <Step number={1} step={steps[0]} />
        <Step number={2} step={steps[1]} />
      </div>
 
      <ScenariosTable scenarios={scenarios} totalInvested={bankroll} />
    </div>
  )
}

Каждый шаг теперь выглядит так:

┌─────────────────────────────────────────────────┐
│  ШАГ 1: Polymarket — Купи YES                   │
│  Ставка:    $52.10                              │
│  Цена:      $0.521 за контракт                  │
│  Кол-во:    ~100 контрактов                     │
│  При выигрыше получишь: $100.00                 │
└─────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────┐
│  ШАГ 2: Kalshi — Купи NO                        │
│  Ставка:    $47.90                              │
│  Цена:      $0.479 за контракт                  │
│  Кол-во:    ~100 контрактов                     │
│  При выигрыше получишь: $100.00                 │
└─────────────────────────────────────────────────┘

И под этим — таблица сценариев:

│ Сценарий          │ Получаешь  │ Профит  │
├───────────────────┼────────────┼─────────┤
│ YES побеждает     │ $98.20     │ +$1.40  │
│ NO побеждает      │ $98.20     │ +$1.40  │
└───────────────────┴────────────┴─────────┘
Ставишь: $100.00  │  Профит в любом случае: +1.4%

Функция computeSteps разбирает opportunity.side_a и opportunity.side_b, определяет на какой платформе какой исход нужен:

function computeSteps(
  opp: Opportunity,
  bankroll: number
): [CalcStep, CalcStep] {
  // Kelly criterion или пропорциональное распределение
  const stakeA = bankroll * (1 - opp.prob_a_adj)
  const stakeB = bankroll * (1 - opp.prob_b_adj)
 
  return [
    {
      platform: opp.platform_a,
      action: opp.action_a, // 'Buy YES' или 'Buy NO'
      stake: stakeA,
      price: opp.prob_a_adj,
      contracts: Math.floor(stakeA / opp.prob_a_adj),
    },
    {
      platform: opp.platform_b,
      action: opp.action_b,
      stake: stakeB,
      price: opp.prob_b_adj,
      contracts: Math.floor(stakeB / opp.prob_b_adj),
    },
  ]
}

Формула комиссий тоже была исправлена — в предыдущей версии fee вычиталась с обеих ног одновременно, хотя в реальности платишь комиссию только с выигравшей ноги:

// Было (неверно):
const adjProbA = probA + (1 - probA) * feeA
const adjProbB = probB + (1 - probB) * feeB
const netCost = adjProbA + adjProbB
 
// Стало (корректно):
// Если A выигрывает: возврат = 1 - feeA, потеря = probB (ставка на B)
const profitIfA = (1 - feeA) - (adjCostA + adjCostB)
// Если B выигрывает: возврат = 1 - feeB, потеря = probA
const profitIfB = (1 - feeB) - (adjCostA + adjCostB)
const netArbPct = Math.min(profitIfA, profitIfB) / (adjCostA + adjCostB)

Эта ошибка занижала net_arb_pct на 1-3%, что могло отфильтровывать реальные вилки.

Проблема №2: вилки с датой окончания в 2027 году

Вторая боль — в таблице появлялись арбитражные возможности с маркетами, которые закрываются через год-полтора. Например, «Кто выиграет президентские выборы в X стране в 2027 году».

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

  1. За год рынок переоценится десятки раз
  2. Цены на обеих платформах выровняются
  3. Один из маркетов может быть вообще разрезолвен иначе (разные resolution criteria)
  4. Деньги заморожены на год без возможности ребалансировки

Пользователь это интуитивно почувствовал: «там есть такие ставки что они завершаются в 2027 году и вилка так долго не будет работать же».

Решение: добавить end_date в тип Opportunity и прокинуть его через весь стек + добавить фильтр в sidebar.

Реализация фильтра по дате

Сначала обновил тип:

// types/opportunity.ts
interface Opportunity {
  // ... существующие поля
  end_date?: string | null  // ISO 8601, например '2026-04-15T23:59:00Z'
}
 
// types/filters.ts
interface ActiveFilters {
  minArb: number
  maxExpiry: MaxExpiryOption  // новое
}
 
type MaxExpiryOption =
  | '1w'   // 1 неделя
  | '1m'   // 1 месяц
  | '3m'   // 3 месяца
  | '6m'   // 6 месяцев
  | '1y'   // 1 год
  | null   // без ограничения

Затем в scan route прокинул end_date из NormalizedMarket:

// app/api/scan/route.ts
 
// В функции buildOpportunity:
return {
  // ...
  end_date: marketA.end_date ?? marketB.end_date ?? null,
}

Фильтрация происходит на фронте перед рендером таблицы:

// lib/filters.ts
function applyMaxExpiry(
  opportunities: Opportunity[],
  maxExpiry: MaxExpiryOption
): Opportunity[] {
  if (!maxExpiry) return opportunities
 
  const now = Date.now()
  const limits: Record<string, number> = {
    '1w': 7 * 24 * 60 * 60 * 1000,
    '1m': 30 * 24 * 60 * 60 * 1000,
    '3m': 90 * 24 * 60 * 60 * 1000,
    '6m': 180 * 24 * 60 * 60 * 1000,
    '1y': 365 * 24 * 60 * 60 * 1000,
  }
 
  const limit = limits[maxExpiry]
 
  return opportunities.filter((opp) => {
    if (!opp.end_date) return true // без даты — показываем
    const expiry = new Date(opp.end_date).getTime()
    return expiry - now <= limit
  })
}

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

function formatExpiry(endDate: string | null | undefined): string {
  if (!endDate) return '—'
  
  const diff = new Date(endDate).getTime() - Date.now()
  const days = Math.floor(diff / (1000 * 60 * 60 * 24))
 
  if (days < 0) return 'Истёк'
  if (days === 0) return 'Сегодня'
  if (days <= 7) return `${days}d`
  if (days <= 60) return format(new Date(endDate), 'MMM d')
  return format(new Date(endDate), "MMM d ''yy") // Mar 15 '27
}

Сайдбар получил новый select:

<select
  value={filters.maxExpiry ?? ''}
  onChange={(e) =>
    setFilters((f) => ({
      ...f,
      maxExpiry: (e.target.value as MaxExpiryOption) || null,
    }))
  }
>
  <option value="">Любая дата</option>
  <option value="1w">До 1 недели</option>
  <option value="1m">До 1 месяца</option>
  <option value="3m">До 3 месяцев</option>
  <option value="6m">До 6 месяцев</option>
  <option value="1y">До 1 года</option>
</select>

По умолчанию выставил 3m — это разумный горизонт для арбитража на предикшн-маркетах.

Процесс разработки: субагентная архитектура

Интересный момент с точки зрения процесса: эти изменения разрабатывались с помощью Claude через паттерн Subagent-Driven Development. Сначала мы составляли дизайн в режиме brainstorming (один шаг за раз, без реализации), потом Writing Plans генерировал детальный план с разбивкой по файлам и задачам, а потом каждая задача отдавалась свежему субагенту.

План из docs/plans/2026-03-06-calculator-enddate.md включал 7 задач:

  1. Типы — end_date в Opportunity, maxExpiry в ActiveFilters
  2. Scan route — прокинуть end_date из NormalizedMarket
  3. Sidebar — новый фильтр Max Expiry
  4. Таблица — колонка Expires с форматированием
  5. Калькулятор — полная переработка
  6. Тесты — покрыть новую логику
  7. Верификация — убедиться что билд проходит

Таски 1+2 шли последовательно (зависимость), 3+4+5 параллельно. Это ускорило реализацию заметно.

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

Результат

После деплоя калькулятор стал читаемым для человека, а не для дата-аналитика. Теперь сразу видно:

  • Куда ставить: платформа + конкретный исход (YES/NO)
  • Сколько: точная сумма в долларах + количество контрактов
  • Что будет: таблица с двумя сценариями и финансовым результатом в каждом
  • Когда заканчивается: колонка Expires прямо в таблице

Фильтр по дате сразу убрал шум — при выставлении «до 1 месяца» список сокращается раза в три, зато все оставшиеся вилки реально actionable здесь и сейчас.

Параллельно в той же сессии были исправлены 8 багов в ядре сканера, включая некорректную формулу комиссий и проблемы с матчингом. После этих фиксов + добавления API ключей сканер начал показывать реальные данные вместо демо.

Выводы

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

Второй урок касается работы с временными горизонтами. В мире предикшн-маркетов дата окончания контракта — это критически важный параметр, не менее важный чем сам размер арбитража. Вилка в 5% на рынке с закрытием через два года менее привлекательна, чем вилка в 1.5% на событии следующей недели: меньше контрпартийный риск, меньше вероятность изменения resolution criteria, деньги работают быстрее.

Третий урок — про фильтры по умолчанию. Пустой фильтр («показать всё») создаёт информационный шум. Разумный дефолт (maxExpiry = 3m в нашем случае) делает инструмент практически применимым сразу, без настройки. Пользователь может расширить, если нужно — но стартовое состояние уже полезное.

Наконец, про процесс: разбивка на типы → бекенд → UI-компоненты → тесты, с параллельным выполнением независимых задач — это работает даже в маленьком проекте. Когда задачи 3, 4 и 5 не зависят друг от друга, нет смысла их сериализовать. Это снижает общее время итерации, что особенно важно когда хочется быстро проверить гипотезу на реальных данных.


Технологии и документация:

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

Фулстек-разработчик, строю SaaS-продукты и автоматизации на Next.js, Python и AI. Пишу о реальных кейсах из продакшена.

Связанный проект

Смотреть в портфолио →