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:
- Increase odds-sync frequency for games inside a short pre-game window (cost implications — The Odds API bills per request), or
- 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
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
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
Scoping caveat — odds-sync cadence
sync-odds.jobcurrently 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: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.tsxpage. For v1, alerts are delivered in-app (a polled/api/analytics/arbitrage/liveendpoint 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
Required back-relations (add to the existing models):
Backend Service
File:
src/services/arbitrage.service.ts(exported as a singletonarbitrageService).Arbitrage Detection Algorithm
Reconciled to the real
CurrentOddsshape.CurrentOddsis keyed@@unique([gameId, bookmaker, marketType])with market-specific price fields, not a flathomePricefor every market:marketTypeh2hhomePriceawayPricespreadshomeSpreadPrice(+homeSpread)awaySpreadPrice(+awaySpread)totalsoverPrice(+totalLine)underPrice(+totalLine)Use
calculateImpliedProbability(americanOdds)fromsrc/utils/odds-calculator.ts(do not re-implement).Two-Way Arbitrage:
Stake Calculation (guarantee equal profit):
Middle Detection — compare
spreadsrows across books; a middle exists whenMath.abs(rowA.homeSpread - rowB.awaySpread) >= 2.0and the estimated both-win probability exceeds ~10%.Real-time Scanning
File:
src/jobs/arbitrage-scan.job.ts, started fromsrc/server.ts(startArbitrageScanJob()).detectTwoWayArbitrageforh2h/spreads/totals+ middle detection.ArbitrageOpportunityrows; stampoddsSnapshotAge.expiresAtasstatus = 'expired'.Risk Assessment
API Endpoints
Mounted under
/api/analytics/arbitrage(newsrc/routes/analytics-arbitrage.routes.ts, guarded byrequireSessionAuth):GET /api/analytics/arbitrage/live— current opportunities (also feeds in-app notifications)GET /api/analytics/arbitrage/history— historical opportunitiesGET /api/analytics/arbitrage/:id— opportunity detailPOST /api/analytics/arbitrage/:id/take— mark as taken (setstakenAt,userId)GET /api/analytics/arbitrage/stats— user's arbitrage statsPOST /api/analytics/arbitrage/calculator— stateless stake calculatorFrontend Components
ArbitrageDashboard.tsx— live opportunities list with snapshot-age badge; new page route/analytics/arbitrageArbitrageCalculator.tsx— total stake + odds in → per-leg stakes + guaranteed profit out (2-way and 3-way)MiddleFinder.tsx— middle opportunities with both-win probability + EVArbitrageAlerts.tsx— alert thresholds (min profit %, max stake, preferred books, sports)arbitrageSlice.ts+src/services/arbitrage.service.ts; alerts surface through the existingNotifications.tsxpageNotifications (v1 = in-app)
Notifications.tsxpolls/api/analytics/arbitrage/liveAcceptance Criteria
arbitrage_opportunitiesmigration completed, withGame/Userback-relations addedCurrentOddsfields, usingodds-calculator.tsserver.ts; stale opportunities expiredoddsSnapshotAgefactor/api/analytics/arbitrage/*endpoints documented (OpenAPI spec indocs/api/)Dependencies
odds-sync.service.ts,CurrentOdds(per game/bookmaker/market)src/utils/odds-calculator.tsmarket-consensus.service.ts(used foroddsDrift)Estimated Effort
Success Metrics
Compliance & Legal
Future Enhancements