ProbBrain 📖 Method

Methodology

How the ProbBrain arbitrage scanner finds cross-platform mispricings on Polymarket and Kalshi. Every claim on the dashboard maps back to something on this page. Source is on GitHub.

01What this is & isn't

The scanner is a Node script that fetches every active market from Polymarket and Kalshi every 15 minutes, normalizes them into a canonical shape (underlying, direction, strike, resolution date, resolution type), and runs a fixed set of detection passes. Each pass surfaces a particular flavor of mispricing. Results are written to opportunities.json and rendered on /arbitrage.

What it claims: "as of the last scan, these venues had this stated price gap on this canonical question."

What it doesn't claim:

  • That the spread is fillable at the size shown — depth is an estimate from the venue's reported liquidity, not a guarantee.
  • That the spread will still exist when you click "open" — Polymarket and Kalshi orderbooks move every second.
  • That a Tier-1 opportunity has been profitable historically. Until the v1.5 settlement layer ships, the scanner shows spread evolution from its own scan history, not realized fills.
  • Financial advice. None of it.

02The honesty layer

Most arbitrage scanners over-report. They use fuzzy matching, optimistic fee assumptions, and headline gross-edge numbers — surfacing dozens of "arbs" that wouldn't survive contact with reality. This one is built around four hard constraints:

  1. No fuzzy matching at the normalize layer. "Bitcoin" doesn't match "BCH"; "Ethereum" doesn't match "ETHE"; "above $80,000" doesn't match "above $79,999.99" except inside a hand-curated bucket where the operator has manually verified equivalence (Pass 4 / 6).
  2. Every card carries a weakest_link_summary. If we can't articulate the single thing most likely to make a Tier-1 trade unprofitable, it's not Tier 1. The summary is rendered as a yellow callout on every card.
  3. Fee model errs conservative. Capital cost, gas, bridge friction, spread round-trip, and the worst-case fee at the leg's price are all subtracted before the "net edge" number is shown. A card whose net is negative is automatically capped at Tier 2 with a fee_tight flag.
  4. "Left feed" never means "closed." The track-record panel labels each disappeared opportunity with a specific reason (spread_closed / leg_expired / leg_delisted / unknown) instead of conflating observation with realized outcome.
Open source for a reason. If a number on the dashboard looks wrong, the exact code path that produced it is in the public repo. scanner/.

03Tier system

Each opportunity is assigned a tier. Each filter can only worsen the tier — never improve it.

Tier 1 Risk-free arb. Gross edge ≥ 8%, every leg fresh and deep, fees survive, settlement times aligned, weakest-link summary articulated. Rare. Often zero per scan. That's correct — true Tier-1s get arbed away in seconds.
Tier 2 Inconsistency / fee-thin. Gross edge between 4% and 8%, OR a depth/freshness/offset penalty was applied, OR net edge is negative after the fee model. Real signals, not guaranteed money.
Tier 3 Signal only. Stale price, low volume, thin depth, long-horizon capital cost, or gross edge under 4%. Useful for understanding where venues disagree; not directly tradeable at retail size.

The downgrade conditions

TriggerEffectFlag
Leg's last_trade_at older than the recency threshold for its resolution_typeTier ≥ 2 (≥ 3 if past acceptable)stale_price
Settlement times across venues differ by > toleranceTier downgraded one leveloffset_warning
Settlement times differ by > 4× toleranceSkipped entirely
Min-leg depth < $200Tier downgraded one leveldepth_limited
Net edge after fees ≤ 0Tier ≥ 2fee_tight
Either leg has v24 = 0Tier ≥ 3low_volume
Pass 4/6 emit without strict canonical-date matchTier ≥ 2 unless sameResolutionWindow=trueresolution_mismatch

04Fee model

The "Net est" number on every card is gross edge minus this:

net_edge_pct = gross_edge_pct
             − Σ poly_taker(leg.price)        ← Polymarket dynamic taker
             − Σ kalshi_fee(leg.price)        ← 0.07·P·(1−P) per leg
             − 1.5%                           ← spread round-trip
             − 0.5%                           ← Polygon gas + USDC bridge
             − (days_to_resolution / 365) × 8%  ← capital tie-up

Poly taker fee

Polymarket's dynamic taker fee peaks around 1.8% near 50/50 and is lower at extremes. We model it as 0.02 × p × (1−p) / 0.25 per leg. Conservative — slightly overstates fees on far-off-balance legs.

Kalshi fee

Kalshi charges 0.07 × P × (1−P) per contract, capped at $0.07. Their public API doesn't expose per-market fee overrides, so we use this uniform rate for every leg. Worst-case ~1.75% near 50/50.

Why capital cost?

On a year-end market 245 days out, money is locked up between order and settlement. At 8% annualized — roughly the risk-free rate plus a small risk premium — that's 5.4% of notional. It's the reason most BTC year-end ladder gaps are flagged Tier 3 even when gross looks juicy: capital cost alone eats most of the spread.

05Canonical key

Every market — Polymarket child market or Kalshi binary — gets normalized to:

canonical = {
  underlying:       BTC | ETH | SOL | FED_RATE | CPI | NFP | …
  direction:        above | below
  strike:           150000   (number, decimal-clean)
  resolution_date:  2026-12-31  or  2026-12-31T15:00:00Z (hourly)
  resolution_type:  hourly | daily | weekly | monthly | quarterly
}

Two markets are the "same question" iff their canonical keys are identical. No fuzzy matching — adding a new underlying is intentionally a manual code change in scanner/dicts/underlyings.js.

Strikes are sanity-checked against per-underlying ranges. A "MegaETH market cap $800M" market won't accidentally match BTC just because the event title mentioned bitcoin tangentially — the strike $800M is outside BTC's plausible range and the market is rejected with a strike_out_of_plausible_range skip.

Canonical-key matching alone (Pass 5) finds essentially zero cross-platform pairs in practice — Polymarket and Kalshi schedule their ladders on different calendars. Pass 4 (curated, same-strike) and Pass 6 (curated, bracket) bridge the gap with hand-validated buckets where the operator has manually confirmed two venues resolve to the same observable.

06Detection passes

The scanner runs six passes per scan. Each surfaces a different flavor of inefficiency.

PassTypeWhat it finds
1 poly_sum_violation Polymarket negRisk events whose YES prices sum to ≠1.0 (within 1.5pp). Buy-all-YES if sum < 1, buy-all-NO if sum > 1.
2 poly_monotonicity Within a Polymarket event with multiple strikes: "above" prices should drop as strike rises (and vice versa for "below"). Adjacent-strike pairs only — combinatorial blowup is intentionally avoided.
3 kalshi_monotonicity Same drill as Pass 2 on Kalshi using yes_bid_dollars / yes_ask_dollars midpoint.
4 cross_platform (curated) Hand-validated (Poly bucket, Kalshi bucket) entries from dicts/curated-pairs.js. Bridges the calendar mismatch Pass 5 misses (Poly uses round strikes; Kalshi uses $X,XXX.99 strikes). Tagged with curated_pair.
5 cross_platform Same canonical key on both venues priced differently. Buy YES on the cheaper venue, NO on the expensive one. Strict canonical match → Tier 1 candidate; strike-only match → capped at Tier 2 with resolution_mismatch.
6 cross_platform_bracket Bracket monotonicity violations across venues with mismatched granularities. For "above" markets, stricter (higher) strike must have ≤ probability than looser (lower) strike. When violated cross-venue, buy YES on the looser side + NO on the stricter side for a guaranteed $1 minimum payout. Tagged with bracket_arb.

Pass 4 and Pass 6 both run before Pass 5 and feed a covered-pairs Set into Pass 5 so the same physical (poly_market, kalshi_ticker) pair never emits twice. Curated context wins.

07Persistence & track record

Every scan, each emitted opportunity gets a compact sample appended to a daily JSONL file in the scanner's local history/ directory. History is gitignored — it's per-machine state, not a public artifact. The public artifact is the rolled-up summary embedded in opportunities.json.

Per-card persistence pill

Each card shows how long the spread has persisted and whether it's moving:

Slope of last 10 scansTrend
≥ +0.3pp/scanwidening (green)
≤ -0.3pp/scantightening (red)
|slope| < 0.3stable (gray)
< 3 samplesnew (blue)

Track-record panel

The panel above the tier sections is a 14-day aggregate. For each opportunity ID seen in that window:

  • Still active in latest scan → spread evolution classified by last_gross / first_gross:
    • closed_substantially — ratio < 0.5
    • tightened — ratio 0.5–0.8
    • stable — ratio 0.8–1.2
    • widened — ratio > 1.2
  • Gone from latest scan → labeled with a reason:
    • leg_expired — resolution_date is in the past
    • leg_delisted — at least one leg's market_id is missing from the current Poly/Kalshi fetch
    • spread_closed — all legs alive in fetch, opp gone → spread fell below the 2pp emit threshold
    • unknown — legacy sample without leg_ids; we can't disambiguate
None of these labels confirm a fill. Spread evolution is what the scanner saw, not what a trade would have done. Realized P&L (settlement layer) is v1.5+.

08What we don't do

  • No auto-execution. The scanner reads, summarizes, links out. It never sends an order to a venue.
  • No fuzzy matching. Aliases are word-boundary-aware exact substrings; categories are exact equality; series tickers are strict prefixes; strikes outside the underlying's plausible range are rejected.
  • No private feeds. Both Polymarket Gamma and Kalshi public markets endpoints are used — no API keys, no privileged data. Anyone can verify.
  • No retroactive numbers. Every value on the dashboard came from the most recent scan, with timestamps. Persistence summaries are rolled up from the scanner's own scan history (which only goes back to when persistence shipped).
  • No fee-model fudging. The fee components are documented above and live in scanner/lib/tiering.js. If a number in production deviates, the source is the truth.

Scanner cron runs every 15 minutes via a local systemd timer. Everything that ships to the public dashboard is in the probbrain-accuracy GitHub repo — frontend, scanner, dicts, tests. Live.