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