Skip to content

[Phase 3] Arbitrage Detection #9

@WFord26

Description

@WFord26

Overview

Detect arbitrage opportunities (guaranteed profit by betting all sides across different bookmakers) and middle opportunities (a chance to win both sides of a bet).

Business Value

  • Guaranteed Profit: Arbitrage is risk-free money
  • Market Inefficiency: Shows when bookmakers disagree significantly
  • User Value: Premium feature for advanced users
  • Real-time Alerts: Time-sensitive opportunities

Scoping caveat — odds-sync cadence

sync-odds.job currently refreshes odds roughly every 10 minutes (see the 2026.05.14 changelog). A 30-second arbitrage scan is only ever as fresh as the last sync, so detected arbs may already be stale by the time a user sees them. Before (or alongside) this feature, decide one of:

  1. Increase odds-sync frequency for games inside a short pre-game window (cost implications — The Odds API bills per request), or
  2. Accept that arb freshness == sync freshness and surface the snapshot age prominently in the UI.

The scan job and risk assessment below assume option 2 unless the cadence is changed.

Prerequisite — notification infrastructure

The original spec lists "push notification service" as a dependency. No backend notification/push infrastructure exists — there is only a frontend Notifications.tsx page. For v1, alerts are delivered in-app (a polled /api/analytics/arbitrage/live endpoint feeding the existing Notifications page). Browser push, email, SMS, and webhook delivery are split into a separate prerequisite/follow-up issue.

Technical Requirements

Database Changes

model ArbitrageOpportunity {
  id                String   @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  gameId            String   @map("game_id") @db.Uuid
  detectedAt        DateTime @default(now()) @map("detected_at") @db.Timestamptz(6)
  expiresAt         DateTime @map("expires_at") @db.Timestamptz(6)

  // Opportunity type
  arbType           String   @map("arb_type") @db.VarChar(20)     // two-way, three-way, middle
  marketType        String   @map("market_type") @db.VarChar(20)  // h2h, spreads, totals

  // Profitability
  profitPercentage  Decimal  @map("profit_percentage") @db.Decimal(5,2)
  stake             Decimal  @db.Decimal(10,2)
  expectedProfit    Decimal  @map("expected_profit") @db.Decimal(10,2)

  // Legs — [{ bookmaker, selection, odds, stake, toWin }]
  legs              Json

  // Risk assessment
  riskLevel         String   @map("risk_level") @db.VarChar(20)   // low, medium, high
  limitRisk         Boolean  @map("limit_risk")
  oddsDrift         Decimal? @map("odds_drift") @db.Decimal(5,2)
  oddsSnapshotAge   Int      @map("odds_snapshot_age")            // Seconds since the odds were synced

  // Status
  status            String   @default("active") @db.VarChar(20)
  takenAt           DateTime? @map("taken_at") @db.Timestamptz(6)
  userId            String?  @map("user_id") @db.Uuid
  actualProfit      Decimal? @map("actual_profit") @db.Decimal(10,2)

  game Game  @relation(fields: [gameId], references: [id], onDelete: Cascade)
  user User? @relation(fields: [userId], references: [id])

  @@index([gameId])
  @@index([status])
  @@index([profitPercentage])
  @@index([detectedAt])
  @@map("arbitrage_opportunities")
}

Required back-relations (add to the existing models):

model Game {
  // ...existing relations...
  arbitrageOpportunities ArbitrageOpportunity[]
}

model User {
  // ...existing relations...
  arbitrageOpportunities ArbitrageOpportunity[]
}

Backend Service

File: src/services/arbitrage.service.ts (exported as a singleton arbitrageService).

class ArbitrageService {
  async scanForArbitrage(): Promise<ArbitrageOpportunity[]>
  calculateOptimalStakes(odds: number[], totalStake: number): number[]
  assessRisk(opportunity: Opportunity): RiskAssessment
  async expireStaleOpportunities(): Promise<void>
  async notifyUsers(opportunity: ArbitrageOpportunity): Promise<void> // in-app for v1
}

Arbitrage Detection Algorithm

Reconciled to the real CurrentOdds shape. CurrentOdds is keyed @@unique([gameId, bookmaker, marketType]) with market-specific price fields, not a flat homePrice for every market:

marketType Side A Side B
h2h homePrice awayPrice
spreads homeSpreadPrice (+ homeSpread) awaySpreadPrice (+ awaySpread)
totals overPrice (+ totalLine) underPrice (+ totalLine)

Use calculateImpliedProbability(americanOdds) from src/utils/odds-calculator.ts (do not re-implement).

Two-Way Arbitrage:

import { calculateImpliedProbability } from '../utils/odds-calculator';

function detectTwoWayArbitrage(
  oddsRows: CurrentOdds[],   // all books for one game + one marketType
  marketType: 'h2h' | 'spreads' | 'totals',
): Opportunity | null {
  const [priceA, priceB] = pricePair(marketType); // field selectors per the table above

  const bestA = maxBy(oddsRows, r => r[priceA]);
  const bestB = maxBy(oddsRows, r => r[priceB]);
  if (!bestA?.[priceA] || !bestB?.[priceB]) return null;

  const totalProb =
    calculateImpliedProbability(bestA[priceA]) +
    calculateImpliedProbability(bestB[priceB]);

  if (totalProb < 1.0) {
    return {
      arbType: 'two-way',
      marketType,
      profitPercentage: ((1 / totalProb) - 1) * 100,
      legs: [
        { side: 'A', odds: bestA[priceA], bookmaker: bestA.bookmaker },
        { side: 'B', odds: bestB[priceB], bookmaker: bestB.bookmaker },
      ],
    };
  }
  return null;
}

Stake Calculation (guarantee equal profit):

function calculateOptimalStakes(odds: number[], totalStake: number): number[] {
  const probs = odds.map(calculateImpliedProbability);
  const totalProb = probs.reduce((a, b) => a + b, 0);
  return probs.map(p => (p / totalProb) * totalStake);
}

Middle Detection — compare spreads rows across books; a middle exists when Math.abs(rowA.homeSpread - rowB.awaySpread) >= 2.0 and the estimated both-win probability exceeds ~10%.

Real-time Scanning

File: src/jobs/arbitrage-scan.job.ts, started from src/server.ts (startArbitrageScanJob()).

  • Runs every 30 seconds (see Scoping caveat re: actual data freshness).
  • For each game with multiple-bookmaker odds: run detectTwoWayArbitrage for h2h/spreads/totals + middle detection.
  • Persist ArbitrageOpportunity rows; stamp oddsSnapshotAge.
  • Mark rows past expiresAt as status = 'expired'.
async function scanAllGames(): Promise<Opportunity[]> {
  const games = await getGamesWithMultiBookOdds(); // joins CurrentOdds, status = scheduled
  const results = await Promise.all(games.map(async game => {
    const odds = await prisma.currentOdds.findMany({ where: { gameId: game.id } });
    const byMarket = groupBy(odds, o => o.marketType);
    return [
      detectTwoWayArbitrage(byMarket.h2h ?? [], 'h2h'),
      detectTwoWayArbitrage(byMarket.spreads ?? [], 'spreads'),
      detectTwoWayArbitrage(byMarket.totals ?? [], 'totals'),
      detectMiddle(byMarket.spreads ?? []),
    ];
  }));
  return results.flat().filter((o): o is Opportunity => o !== null);
}

Risk Assessment

function assessRisk(opp: Opportunity): RiskAssessment {
  let risk: 'low' | 'medium' | 'high' = 'low';
  const factors: string[] = [];

  if (opp.oddsSnapshotAge > 300)        { risk = 'medium'; factors.push('Odds may be stale (>5 min since sync)'); }
  if ((opp.oddsDrift ?? 0) > 5.0)       { risk = 'medium'; factors.push('Fast-moving odds'); }
  if (opp.profitPercentage > 10.0)      { risk = 'high';   factors.push('Suspiciously high profit'); }
  if (getMinutesToStart(opp.gameId) < 15) { risk = 'medium'; factors.push('Very close to start time'); }

  return { risk, factors, limitRisk: estimateLimitRisk(opp) };
}

API Endpoints

Mounted under /api/analytics/arbitrage (new src/routes/analytics-arbitrage.routes.ts, guarded by requireSessionAuth):

  • GET /api/analytics/arbitrage/live — current opportunities (also feeds in-app notifications)
  • GET /api/analytics/arbitrage/history — historical opportunities
  • GET /api/analytics/arbitrage/:id — opportunity detail
  • POST /api/analytics/arbitrage/:id/take — mark as taken (sets takenAt, userId)
  • GET /api/analytics/arbitrage/stats — user's arbitrage stats
  • POST /api/analytics/arbitrage/calculator — stateless stake calculator

Frontend Components

  • ArbitrageDashboard.tsx — live opportunities list with snapshot-age badge; new page route /analytics/arbitrage
  • ArbitrageCalculator.tsx — total stake + odds in → per-leg stakes + guaranteed profit out (2-way and 3-way)
  • MiddleFinder.tsx — middle opportunities with both-win probability + EV
  • ArbitrageAlerts.tsx — alert thresholds (min profit %, max stake, preferred books, sports)
  • New arbitrageSlice.ts + src/services/arbitrage.service.ts; alerts surface through the existing Notifications.tsx page

Notifications (v1 = in-app)

  • v1: in-app only — Notifications.tsx polls /api/analytics/arbitrage/live
  • Follow-up (separate prerequisite issue): browser push, email for high-value arbs (>5%), SMS for premium, webhook

Acceptance Criteria

  • arbitrage_opportunities migration completed, with Game/User back-relations added
  • Detection algorithm implemented against real CurrentOdds fields, using odds-calculator.ts
  • 30-second scan job wired into server.ts; stale opportunities expired
  • Stake calculator verified correct (unit-tested against known examples)
  • Risk assessment includes the oddsSnapshotAge factor
  • Dashboard shows live opportunities with snapshot-age badge
  • In-app notifications functional via the Notifications page
  • Calculator tool available
  • /api/analytics/arbitrage/* endpoints documented (OpenAPI spec in docs/api/)
  • Unit tests for detection logic
  • Load testing for scan performance
  • Sync-cadence decision documented (see Scoping caveat)

Dependencies

Dependency Status
Odds sync with multiple bookmakers ✅ Shipped — odds-sync.service.ts, CurrentOdds (per game/bookmaker/market)
Implied-probability / odds math ✅ Shipped — src/utils/odds-calculator.ts
Market consensus calculations (Phase 2 / #7) ✅ Shipped — market-consensus.service.ts (used for oddsDrift)
Fast odds updates (1-2 min) ⚠️ Not met — sync is ~10 min; see Scoping caveat
≥5 bookmakers per game ⚠️ Data/config dependent — verify The Odds API plan covers enough books
Push notification service Prerequisite — does not exist; v1 is in-app only

Estimated Effort

  • Backend: 8 days
  • Frontend: 6 days
  • Testing & optimization: 4 days
  • Total: 18 days (excludes the optional sync-cadence change and the separate push-notification prerequisite)

Success Metrics

  • Detect 5-10 arbitrage opportunities per day
  • 95%+ accuracy (no false positives) when measured against the odds snapshot used
  • In-app alerts surfaced within one scan cycle of detection
  • Average profit 2-5% per opportunity

Compliance & Legal

  • Arbitrage is legal, but bookmakers may limit accounts
  • Disclose risks to users; some books prohibit arbitrage in their ToS
  • Recommend a different book for each leg
  • Track user-reported limits/bans

Future Enhancements

  • Browser push / email / SMS / webhook delivery (the notification-infrastructure prerequisite)
  • Machine learning for opportunity prediction
  • Historical arbitrage ROI tracking
  • Automated bet placement (with explicit user approval)
  • Cross-sport arbitrage detection
  • Arbitrage portfolio optimization

Metadata

Metadata

Assignees

No one assigned

    Labels

    analyticsAdvanced analytics featuresenhancementNew feature or requestphase-3Phase 3: Advanced Features

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions