П/ВИН

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

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

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

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

Контекст: что такое arbscanner и зачем он нужен

ArbScanner — это инструмент для поиска арбитражных возможностей между предсказательными рынками: Polymarket, Kalshi, PredictIt и букмекерскими конторами через The Odds API. Идея простая: если на Polymarket событие котируется по 0.52, а на Kalshi по 0.51 — суммарная вероятность меньше 1.0, что означает гарантированную прибыль при правильном распределении ставок.

Проект построен на Next.js с TypeScript на фронтенде, серверная часть — API routes в том же Next.js приложении. Данные тянутся с нескольких площадок, нормализуются, матчатся по смыслу и проверяются на арбитражность.

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

Проблема 1: калькулятор, который ничего не объясняет

Когда видишь строку «Stake on Polymarket: $52.10» — что с ней делать? Купить Yes или No? Что произойдёт, если Polymarket выиграет? А если Kalshi? Какой итоговый профит в каждом сценарии?

Старый detail-panel.tsx выводил сухой список без контекста:

// БЫЛО: просто числа без объяснений
<div>Stake on Polymarket: ${stakeA.toFixed(2)}</div>
<div>Stake on Kalshi: ${stakeB.toFixed(2)}</div>
<div>Total: ${total.toFixed(2)}</div>
<div>Payout: ${payout.toFixed(2)}</div>
<div>Profit: ${profit.toFixed(2)}</div>

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

Решение: два шага + таблица сценариев

Переработали панель в трёхчастную структуру:

Шаг 1 и Шаг 2 — конкретные действия на каждой платформе с указанием исхода (Yes/No), суммой ставки и количеством контрактов:

// СТАЛО: пошаговая инструкция
interface CalcStep {
  platform: string        // "Polymarket"
  action: string          // "Buy YES"
  stake: number           // 52.10
  price: number           // 0.521
  contracts: number       // ~100
  fee: number             // 0.02 (2%)
}
 
// Таблица сценариев
interface Scenario {
  winner: string          // "Polymarket YES wins"
  payout: number          // 100.00
  netProfit: number       // 3.21
  roi: number             // 3.1%
}

Визуально это выглядит так:

┌─────────────────────────────────────────────────┐
│  Bankroll: [$100____]                           │
├─────────────────────────────────────────────────┤
│  ШАГ 1: Polymarket — Купи YES                  │
│  Ставка: $52.10  │  Цена: $0.521               │
│  Контракты: ~100  │  Комиссия: $1.04 (2%)       │
├─────────────────────────────────────────────────┤
│  ШАГ 2: Kalshi — Купи NO                       │
│  Ставка: $47.90  │  Цена: $0.468               │
│  Контракты: ~102  │  Комиссия: $0.96 (2%)       │
├─────────────────────────────────────────────────┤
│  СЦЕНАРИИ:                                      │
│  Если YES выиграет: выплата $100 → профит $3.21 │
│  Если NO выиграет:  выплата $100 → профит $2.14 │
│  Гарантированный минимум: +$2.14 (2.14% ROI)   │
└─────────────────────────────────────────────────┘

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

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

В таблице возможностей были позиции с end_date через полтора-два года. Технически арбитраж существует прямо сейчас — разница в ценах есть. Но практически: рынок живёт полтора года, цены изменятся десятки раз, ликвидность пересохнет, одна из площадок может закрыть этот рынок. Долгосрочные вилки — это иллюзия.

Проблема была в том, что поле end_date вообще не прокидывалось на фронт. Оно было в NormalizedMarket из API источников, но терялось при создании Opportunity.

Решение: сквозная передача end_date + фильтр

Шаг 1: добавляем поле в типы

// types/opportunity.ts
interface Opportunity {
  id: string
  platform_a: string
  platform_b: string
  net_arb_pct: number
  // ... существующие поля
  end_date?: string      // НОВОЕ: ISO строка или null
}
 
// types/filters.ts  
interface ActiveFilters {
  minArb: number
  maxExpiry?: number     // НОВОЕ: максимум дней до истечения
  sortBy: 'arb' | 'expiry' | 'profit'
}

Шаг 2: прокидываем из scan route

// app/api/scan/route.ts
function normalizedToOpportunity(
  marketA: NormalizedMarket,
  marketB: NormalizedMarket,
  arbResult: ArbResult
): Opportunity {
  return {
    // ... существующие поля
    end_date: marketA.end_date ?? marketB.end_date ?? null,
  }
}

Шаг 3: фильтр и сортировка в UI

// components/sidebar.tsx
const EXPIRY_OPTIONS = [
  { label: '1 неделя',  days: 7   },
  { label: '1 месяц',   days: 30  },
  { label: '3 месяца',  days: 90  },
  { label: '6 месяцев', days: 180 },
  { label: '1 год',     days: 365 },
  { label: 'Без лимита', days: null },
]
 
// Фильтрация
const filtered = opportunities.filter(opp => {
  if (!filters.maxExpiry || !opp.end_date) return true
  const daysLeft = differenceInDays(new Date(opp.end_date), new Date())
  return daysLeft <= filters.maxExpiry
})

Шаг 4: колонка Expires в таблице

Форматирование даты зависит от срочности: если меньше 7 дней — показываем «3d», если в этом году — «Mar 15», если в другом году — «Mar 15 '27»:

function formatExpiry(end_date: string): string {
  const days = differenceInDays(new Date(end_date), new Date())
  if (days < 0)  return 'Expired'
  if (days < 7)  return `${days}d`
  if (days < 365) return format(new Date(end_date), 'MMM d')
  return format(new Date(end_date), "MMM d ''yy")
}

Теперь сразу видно: вот вилка на 3 дня — это реально, вот на Mar 15 '27 — это лучше пропустить.

Проблема 3: сканер не находил ни одного сигнала

После деплоя первой версии пришёл вопрос: «Почему пусто? Ни одного сигнала». Сделал полное ревью кода и нашёл восемь критических багов.

Баг 1: формула комиссий занижала арбитраж

Самый жирный баг — неправильная формула в arbitrage.ts:

// БЫЛО: вычитаем комиссии обеих ног одновременно
const adjProbA = probA + (1 - probA) * feeA
const adjProbB = probB + (1 - probB) * feeB
const netCost = adjProbA + adjProbB

Проблема: в реальности выигрывает только одна нога. Комиссию нужно вычитать только из выигрывающей стороны:

// СТАЛО: корректная формула
// Сценарий A: выигрывает первая площадка
const profitIfA = (1 - feeA) - (probA + probB)
// Сценарий B: выигрывает вторая площадка  
const profitIfB = (1 - feeB) - (probA + probB)
// Гарантированный профит — минимум из двух сценариев
const netProfit = Math.min(profitIfA, profitIfB)
const netArbPct = netProfit / (probA + probB)

Эта ошибка занижала net_arb_pct на 1-3%, что отфильтровывало все пограничные арбитражи. На рынках, где типичная вилка — 2-4%, это означало нулевые результаты.

Баг 2: нет проверки HTTP статуса в fetchJson

Все четыре venue-модуля делали fetch без проверки resp.ok:

// БЫЛО
const data = await fetch(url).then(r => r.json())
 
// СТАЛО
async function fetchJson<T>(url: string): Promise<T | null> {
  const resp = await fetch(url)
  if (!resp.ok) {
    console.error(`[venue] HTTP ${resp.status} from ${url}`)
    return null
  }
  return resp.json() as Promise<T>
}

Без этого ошибка 429 (rate limit) или 503 от API молча превращалась в JSON parse error, и весь источник выпадал из скана без логов.

Баг 3: матчинг был слишком строгим

Алгоритм матчинга вопросов между площадками использовал порог similarity 0.5 для non-sports рынков. Это слишком высоко — политические вопросы формулируются по-разному на каждой площадке:

  • Polymarket: «Will Donald Trump win the 2024 election?»
  • Kalshi: «Presidential Election Winner: Republican»
  • PredictIt: «Republican Presidential Nominee Wins?»

Это один и тот же вопрос с similarity ~0.35 по токенам. Снизили порог до 0.3, добавили стоп-слова (will, the, who, when), усилили вес именованных сущностей и чисел:

// matcher.ts — до
const SIMILARITY_THRESHOLD = 0.5
 
// После
const THRESHOLD_PREDICTION = 0.30  // для pred markets
const THRESHOLD_SPORTS = 0.55      // для спорта — строже
 
// Веса токенов
function tokenWeight(token: string): number {
  if (/^\d+$/.test(token)) return 3.0        // числа важны
  if (isNamedEntity(token))  return 2.5       // имена важны
  if (STOP_WORDS.has(token)) return 0.1       // стоп-слова игнорируем
  return 1.0
}

Остальные пять багов

Помимо трёх главных, нашли и починили:

  • Kalshi: контракты без цены fallback-ились на undefined вместо пропуска
  • PredictIt: lastTradePrice мог быть null при отсутствии торгов, что ломало формулу
  • Polymarket: фетч ограничивался 100 рынками, добавили пагинацию до 500+
  • Демо-режим: при пустом результате сканер подменял данные демо-данными без предупреждения — убрали этот fallback
  • PredictIt fee: была захардкожена комиссия 5% вместо реальных 10%, что давало ложно-оптимистичные расчёты

После всех восьми фиксов 82 теста прошли, билд успешен, задеплоили.

Диагностическое логирование

Чтобы в следующий раз не гадать «почему пусто», добавили структурированные логи на каждом этапе сканирования:

// app/api/scan/route.ts
console.log('[scan] venues fetched', {
  polymarket: polymarkets.length,
  kalshi: kalshiMarkets.length,
  predictit: predictitMarkets.length,
  oddsapi: sportsMarkets.length,
})
 
console.log('[scan] matching stats', {
  totalPairs: pairs.length,
  matched: matchedPairs.length,
  arbFound: opportunities.length,
  topArb: opportunities[0]?.net_arb_pct,
})

Теперь в Dokploy logs сразу видно: 245 маркетов с Polymarket, 189 с Kalshi, 43 пары прошли матчинг, 3 арбитражные возможности найдены.

Результаты

После всех изменений:

  • Калькулятор стал пошаговой инструкцией: шаг 1 (купи Yes на платформе A), шаг 2 (купи No на платформе B), таблица сценариев с профитом в каждом случае
  • Фильтр по дате убирает из таблицы вилки, которые заканчиваются слишком далеко — по умолчанию показываем только до 3 месяцев
  • Колонка Expires показывает срочность в читаемом формате: «3d», «Mar 15», «Mar 15 '27»
  • Сканер наконец находит реальные сигналы после исправления формулы комиссий и снижения порога матчинга
  • 82 теста — все зелёные, билд чистый

Коммит e3972fe: 8 файлов, +162/-48 строк изменений в ядре сканера.

Что узнали и чему это учит

Первый урок: формула важнее интерфейса. Можно сделать самый красивый калькулятор в мире, но если в основе лежит неправильная математика — всё остальное бесполезно. Баг с двойным вычетом комиссий был настолько фундаментальным, что сделал весь сканер нерабочим. Покрытие тестами на уровне бизнес-логики (arbitrage.ts) должно идти в первую очередь, до работы над UI.

Второй урок: данные нужно прокидывать сквозь все слои сразу. Поле end_date было в API-ответе с самого начала, но потерялось на одном из слоёв нормализации и три недели не доходило до фронтенда. Когда добавляешь поле в бэкенд-тип — одновременно обновляй интерфейс, route handler, компонент и тесты. Иначе накапливается технический долг, который потом приходится разгребать отдельной задачей.

Третий урок: молчащие ошибки хуже шумных. fetch() без проверки resp.ok, демо-режим как fallback при пустом результате, null значения без явного пропуска — всё это создаёт иллюзию работы. Сканер «работал», просто не находил ничего. Добавь явные логи и явные ошибки на каждом этапе пайплайна — тогда отладка занимает минуты, а не часы ревью кода.

Четвёртый урок: пользовательский контекст — это функциональное требование, не «украшение». «Stake: $52.10» — это не интерфейс, это данные. Интерфейс — это когда пользователь понимает, что делать: открыть Polymarket, найти этот рынок, купить YES за $52.10, потом открыть Kalshi, купить NO за $47.90, и в любом случае получить минимум $2.14 профита. Разница между числом и инструкцией — это разница между инструментом и игрушкой.

Следующий шаг для arbscanner — переход на WebSocket подключение к Polymarket CLOB API для потоковых обновлений цен вместо polling каждые N секунд. Это позволит ловить краткосрочные вилки, которые живут несколько минут и не попадают в текущий snapshot-based сканер.

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

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