Arbscanner: детальный калькулятор и фильтр по дате
Когда занимаешься арбитражем на предикшн-маркетах, информация «ставка на 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 году».
Логика была такая: цены сейчас расходятся, значит вилка существует. Формально — да. Но на практике:
- За год рынок переоценится десятки раз
- Цены на обеих платформах выровняются
- Один из маркетов может быть вообще разрезолвен иначе (разные resolution criteria)
- Деньги заморожены на год без возможности ребалансировки
Пользователь это интуитивно почувствовал: «там есть такие ставки что они завершаются в 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 задач:
- Типы —
end_dateвOpportunity,maxExpiryвActiveFilters - Scan route — прокинуть
end_dateизNormalizedMarket - Sidebar — новый фильтр Max Expiry
- Таблица — колонка Expires с форматированием
- Калькулятор — полная переработка
- Тесты — покрыть новую логику
- Верификация — убедиться что билд проходит
Таски 1+2 шли последовательно (зависимость), 3+4+5 параллельно. Это ускорило реализацию заметно.
После каждого субагента — двухэтапное ревью: сначала соответствие спецификации, потом качество кода. Это звучит бюрократично, но на практике экономит время: не нужно переписывать потом.
Результат
После деплоя калькулятор стал читаемым для человека, а не для дата-аналитика. Теперь сразу видно:
- Куда ставить: платформа + конкретный исход (YES/NO)
- Сколько: точная сумма в долларах + количество контрактов
- Что будет: таблица с двумя сценариями и финансовым результатом в каждом
- Когда заканчивается: колонка Expires прямо в таблице
Фильтр по дате сразу убрал шум — при выставлении «до 1 месяца» список сокращается раза в три, зато все оставшиеся вилки реально actionable здесь и сейчас.
Параллельно в той же сессии были исправлены 8 багов в ядре сканера, включая некорректную формулу комиссий и проблемы с матчингом. После этих фиксов + добавления API ключей сканер начал показывать реальные данные вместо демо.
Выводы
Главный урок этого спринта: UI арбитражного инструмента — это не вторичная деталь, это половина ценности. Можно найти идеальную вилку алгоритмически, но если пользователь не понимает что именно делать — вилка потеряна. Калькулятор должен давать инструкцию, а не просто цифры.
Второй урок касается работы с временными горизонтами. В мире предикшн-маркетов дата окончания контракта — это критически важный параметр, не менее важный чем сам размер арбитража. Вилка в 5% на рынке с закрытием через два года менее привлекательна, чем вилка в 1.5% на событии следующей недели: меньше контрпартийный риск, меньше вероятность изменения resolution criteria, деньги работают быстрее.
Третий урок — про фильтры по умолчанию. Пустой фильтр («показать всё») создаёт информационный шум. Разумный дефолт (maxExpiry = 3m в нашем случае) делает инструмент практически применимым сразу, без настройки. Пользователь может расширить, если нужно — но стартовое состояние уже полезное.
Наконец, про процесс: разбивка на типы → бекенд → UI-компоненты → тесты, с параллельным выполнением независимых задач — это работает даже в маленьком проекте. Когда задачи 3, 4 и 5 не зависят друг от друга, нет смысла их сериализовать. Это снижает общее время итерации, что особенно важно когда хочется быстро проверить гипотезу на реальных данных.
Технологии и документация:
- Next.js App Router — роутинг и API routes
- React компоненты и хуки — детальная панель и калькулятор
- TypeScript Utility Types — типизация фильтров и opportunity
- Polymarket CLOB API — источник данных по рынкам
- date-fns форматирование дат — умное форматирование колонки Expires

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