diff --git a/dashboard/backend/CHANGELOG.md b/dashboard/backend/CHANGELOG.md index 9c90f1d..07f6b46 100644 --- a/dashboard/backend/CHANGELOG.md +++ b/dashboard/backend/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Market Consensus & Deviations — Phase 2**: Expanded market consensus analytics with richer consensus and dispersion metrics plus explicit best-value fields for bookmaker outlier workflows. + - `prisma/schema.prisma`: Enhanced `MarketConsensus` model with `consensusPrice`, `medianLine`, `meanLine`, `modeLine`, `range`, `interquartileRange`, `bestValueSide`, `bestValueBookmaker`, `bestValueLine`, `sharpBookWeight`, and a `marketType` index. + - `prisma/migrations/20260514000001_enhance_market_consensus_phase2/migration.sql`: Adds/backfills new `market_consensus` columns and creates an index on `market_type`. + - `src/services/market-consensus.service.ts`: `calculateConsensus()` now computes additional phase-2 market-truth metrics and persists them; added `identifyOutliers(gameId)` for per-market outlier discovery. + - `tests/market-consensus.service.test.ts`: New unit tests covering phase-2 spreads consensus calculations and outlier identification behavior. + --- ## [0.3.10] - 2026-05-14 diff --git a/dashboard/backend/prisma/migrations/20260514000001_enhance_market_consensus_phase2/migration.sql b/dashboard/backend/prisma/migrations/20260514000001_enhance_market_consensus_phase2/migration.sql new file mode 100644 index 0000000..ffd6579 --- /dev/null +++ b/dashboard/backend/prisma/migrations/20260514000001_enhance_market_consensus_phase2/migration.sql @@ -0,0 +1,31 @@ +-- AlterTable +ALTER TABLE "market_consensus" + ADD COLUMN "consensus_price" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "median_line" DECIMAL(8,2) NOT NULL DEFAULT 0, + ADD COLUMN "mean_line" DECIMAL(8,2) NOT NULL DEFAULT 0, + ADD COLUMN "mode_line" DECIMAL(8,2), + ADD COLUMN "range" DECIMAL(8,2) NOT NULL DEFAULT 0, + ADD COLUMN "interquartile_range" DECIMAL(8,2) NOT NULL DEFAULT 0, + ADD COLUMN "best_value_side" VARCHAR(20) NOT NULL DEFAULT 'home', + ADD COLUMN "best_value_bookmaker" VARCHAR(50) NOT NULL DEFAULT '', + ADD COLUMN "best_value_line" DECIMAL(8,2) NOT NULL DEFAULT 0, + ADD COLUMN "sharp_book_weight" DECIMAL(5,2) NOT NULL DEFAULT 0; + +-- Backfill new columns from existing values where possible +UPDATE "market_consensus" +SET + "consensus_price" = CASE + WHEN "market_type" = 'h2h' THEN ROUND("consensus_line")::INTEGER + ELSE 0 + END, + "median_line" = "consensus_line", + "mean_line" = "consensus_line", + "range" = 0, + "interquartile_range" = 0, + "best_value_side" = COALESCE("best_value"->>'side', 'home'), + "best_value_bookmaker" = COALESCE("best_value"->>'bookmaker', ''), + "best_value_line" = COALESCE(NULLIF("best_value"->>'line', '')::DECIMAL, "consensus_line"), + "sharp_book_weight" = 0; + +-- CreateIndex +CREATE INDEX "market_consensus_market_type_idx" ON "market_consensus"("market_type"); diff --git a/dashboard/backend/prisma/schema.prisma b/dashboard/backend/prisma/schema.prisma index c5007e6..f772884 100644 --- a/dashboard/backend/prisma/schema.prisma +++ b/dashboard/backend/prisma/schema.prisma @@ -178,17 +178,26 @@ model MarketConsensus { // Consensus metrics consensusLine Decimal @map("consensus_line") @db.Decimal(8, 2) + consensusPrice Int @map("consensus_price") + medianLine Decimal @map("median_line") @db.Decimal(8, 2) + meanLine Decimal @map("mean_line") @db.Decimal(8, 2) + modeLine Decimal? @map("mode_line") @db.Decimal(8, 2) standardDeviation Decimal @map("standard_deviation") @db.Decimal(8, 2) + range Decimal @db.Decimal(8, 2) + interquartileRange Decimal @map("interquartile_range") @db.Decimal(8, 2) outlierBookmakers Json @map("outlier_bookmakers") // [{bookmaker, line, deviation}] + bestValueSide String @map("best_value_side") @db.VarChar(20) + bestValueBookmaker String @map("best_value_bookmaker") @db.VarChar(50) + bestValueLine Decimal @map("best_value_line") @db.Decimal(8, 2) bookmakerCount Int @map("bookmaker_count") - - // Value indicators + sharpBookWeight Decimal @map("sharp_book_weight") @db.Decimal(5, 2) disagreementScore Int @map("disagreement_score") // 1-100 - bestValue Json @map("best_value") // {side, bookmaker, line, impliedProb} + bestValue Json @map("best_value") // compatibility payload game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) @@index([gameId]) + @@index([marketType]) @@index([disagreementScore]) @@index([calculatedAt]) @@map("market_consensus") diff --git a/dashboard/backend/src/services/market-consensus.service.ts b/dashboard/backend/src/services/market-consensus.service.ts index 8715771..f96dd7f 100644 --- a/dashboard/backend/src/services/market-consensus.service.ts +++ b/dashboard/backend/src/services/market-consensus.service.ts @@ -28,13 +28,25 @@ export interface BestValue { impliedProb: number; } +type MarketType = 'h2h' | 'spreads' | 'totals'; + export interface ConsensusResult { gameId: string; - marketType: string; + marketType: MarketType; consensusLine: number; + consensusPrice: number; + medianLine: number; + meanLine: number; + modeLine: number | null; standardDeviation: number; + range: number; + interquartileRange: number; outlierBookmakers: OutlierBookmaker[]; + bestValueSide: BestValue['side']; + bestValueBookmaker: string; + bestValueLine: number; bookmakerCount: number; + sharpBookWeight: number; disagreementScore: number; bestValue: BestValue; } @@ -69,6 +81,55 @@ function stdDev(values: number[]): number { return Math.sqrt(variance); } +function mean(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((sum, v) => sum + v, 0) / values.length; +} + +function mode(values: number[]): number | null { + if (values.length === 0) return null; + const counts = new Map(); + for (const value of values) { + counts.set(value, (counts.get(value) ?? 0) + 1); + } + + let topCount = 0; + let topValue: number | null = null; + let tie = false; + + for (const [value, count] of counts.entries()) { + if (count > topCount) { + topCount = count; + topValue = value; + tie = false; + } else if (count === topCount) { + tie = true; + } + } + + return topCount > 1 && !tie ? topValue : null; +} + +function quantile(values: number[], percentile: number): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = (sorted.length - 1) * percentile; + const lower = Math.floor(index); + const upper = Math.ceil(index); + if (lower === upper) return sorted[lower]; + return sorted[lower] + (sorted[upper] - sorted[lower]) * (index - lower); +} + +function interquartileRange(values: number[]): number { + if (values.length < 2) return 0; + return quantile(values, 0.75) - quantile(values, 0.25); +} + +function valueRange(values: number[]): number { + if (values.length === 0) return 0; + return Math.max(...values) - Math.min(...values); +} + /** * Convert American moneyline to implied probability (no vig) */ @@ -93,6 +154,14 @@ function impliedProbToAmerican(prob: number): number { export class MarketConsensusService { private readonly MIN_BOOKMAKERS = 2; // Minimum bookmakers required for consensus + private readonly SHARP_BOOKMAKERS = new Set([ + 'pinnacle', + 'circa', + 'bookmaker', + 'bookmaker.eu', + 'betcris', + 'betonlineag', + ]); /** * Calculate consensus and disagreement score for one game + market type. @@ -100,7 +169,7 @@ export class MarketConsensusService { */ async calculateConsensus( gameId: string, - marketType: 'h2h' | 'spreads' | 'totals' + marketType: MarketType ): Promise { const odds = await prisma.currentOdds.findMany({ where: { gameId, marketType }, @@ -129,6 +198,9 @@ export class MarketConsensusService { const homeProbs = homeEntries.map((e) => e.value); const awayProbs = awayEntries.map((e) => e.value); + const homeAmericanPrices = odds + .filter((o) => o.homePrice !== null) + .map((o) => o.homePrice as number); const consensusHomeProb = median(homeProbs); const consensusAwayProb = median(awayProbs); @@ -187,13 +259,33 @@ export class MarketConsensusService { impliedProb: americanToImpliedProb(bestHomeOdds!.homePrice!), }; + const consensusPrice = consensusAmerican; + const outlierBookmakers = outliers; + const sharpBookWeight = this.calculateSharpBookWeight(odds.map((o) => o.bookmaker)); + const medianPrice = homeAmericanPrices.length ? median(homeAmericanPrices) : consensusAmerican; + const meanPrice = homeAmericanPrices.length ? mean(homeAmericanPrices) : consensusAmerican; + const modePrice = mode(homeAmericanPrices); + const combinedProbabilityRange = [...homeProbs, ...awayProbs]; + return { gameId, marketType, consensusLine: consensusAmerican, + consensusPrice, + medianLine: parseFloat(medianPrice.toFixed(2)), + meanLine: parseFloat(meanPrice.toFixed(2)), + modeLine: modePrice === null ? null : parseFloat(modePrice.toFixed(2)), standardDeviation: parseFloat((reportedDev * 100).toFixed(2)), // as percentage points - outlierBookmakers: outliers, + range: parseFloat(valueRange(combinedProbabilityRange).toFixed(2)), + interquartileRange: parseFloat( + Math.max(interquartileRange(homeProbs), interquartileRange(awayProbs)).toFixed(2) + ), + outlierBookmakers, + bestValueSide: bestValue.side, + bestValueBookmaker: bestValue.bookmaker, + bestValueLine: bestValue.line, bookmakerCount: odds.length, + sharpBookWeight, disagreementScore: score, bestValue, }; @@ -247,13 +339,35 @@ export class MarketConsensusService { : 0.5, }; + const medianLine = consensusLine; + const meanLine = mean(homeLines); + const modeLine = mode(homeLines); + const dispersionRange = valueRange(homeLines); + const iqr = interquartileRange(homeLines); + const consensusPrice = Math.round(median( + odds + .filter((o) => o.homeSpreadPrice !== null) + .map((o) => o.homeSpreadPrice as number) + ) || 0); + const sharpBookWeight = this.calculateSharpBookWeight(odds.map((o) => o.bookmaker)); + return { gameId, marketType, consensusLine, + consensusPrice, + medianLine: parseFloat(medianLine.toFixed(2)), + meanLine: parseFloat(meanLine.toFixed(2)), + modeLine: modeLine === null ? null : parseFloat(modeLine.toFixed(2)), standardDeviation: parseFloat(dev.toFixed(2)), + range: parseFloat(dispersionRange.toFixed(2)), + interquartileRange: parseFloat(iqr.toFixed(2)), outlierBookmakers: outliers, + bestValueSide: bestValue.side, + bestValueBookmaker: bestValue.bookmaker, + bestValueLine: bestValue.line, bookmakerCount: odds.length, + sharpBookWeight, disagreementScore: score, bestValue, }; @@ -304,13 +418,35 @@ export class MarketConsensusService { impliedProb: bestOver?.overPrice ? americanToImpliedProb(bestOver.overPrice) : 0.5, }; + const medianLine = consensusLine; + const meanLine = mean(totalLines); + const modeLine = mode(totalLines); + const dispersionRange = valueRange(totalLines); + const iqr = interquartileRange(totalLines); + const consensusPrice = Math.round(median( + odds + .filter((o) => o.overPrice !== null) + .map((o) => o.overPrice as number) + ) || 0); + const sharpBookWeight = this.calculateSharpBookWeight(odds.map((o) => o.bookmaker)); + return { gameId, marketType, consensusLine, + consensusPrice, + medianLine: parseFloat(medianLine.toFixed(2)), + meanLine: parseFloat(meanLine.toFixed(2)), + modeLine: modeLine === null ? null : parseFloat(modeLine.toFixed(2)), standardDeviation: parseFloat(dev.toFixed(2)), + range: parseFloat(dispersionRange.toFixed(2)), + interquartileRange: parseFloat(iqr.toFixed(2)), outlierBookmakers: outliers, + bestValueSide: bestValue.side, + bestValueBookmaker: bestValue.bookmaker, + bestValueLine: bestValue.line, bookmakerCount: odds.length, + sharpBookWeight, disagreementScore: score, bestValue, }; @@ -343,6 +479,15 @@ export class MarketConsensusService { .filter((e) => Math.abs(e.deviation) > 2); } + private calculateSharpBookWeight(bookmakers: string[]): number { + if (bookmakers.length === 0) return 0; + const normalized = new Set(bookmakers.map((b) => b.toLowerCase())); + const sharpCount = Array.from(normalized).filter((b) => + this.SHARP_BOOKMAKERS.has(b) + ).length; + return parseFloat(((sharpCount / normalized.size) * 100).toFixed(2)); + } + /** * Persist a ConsensusResult to the database (upsert per game+market). */ @@ -352,9 +497,19 @@ export class MarketConsensusService { gameId: result.gameId, marketType: result.marketType, consensusLine: result.consensusLine, + consensusPrice: result.consensusPrice, + medianLine: result.medianLine, + meanLine: result.meanLine, + modeLine: result.modeLine, standardDeviation: result.standardDeviation, + range: result.range, + interquartileRange: result.interquartileRange, outlierBookmakers: result.outlierBookmakers as any, + bestValueSide: result.bestValueSide, + bestValueBookmaker: result.bestValueBookmaker, + bestValueLine: result.bestValueLine, bookmakerCount: result.bookmakerCount, + sharpBookWeight: result.sharpBookWeight, disagreementScore: result.disagreementScore, bestValue: result.bestValue as any, }, @@ -380,6 +535,26 @@ export class MarketConsensusService { } } + /** + * Identify consensus outliers for all core market types in a game. + */ + async identifyOutliers(gameId: string): Promise< + { marketType: MarketType; outlierBookmakers: OutlierBookmaker[] }[] + > { + const results = await Promise.all( + (['h2h', 'spreads', 'totals'] as const).map(async (marketType) => { + const consensus = await this.calculateConsensus(gameId, marketType); + if (!consensus) return null; + return { + marketType, + outlierBookmakers: consensus.outlierBookmakers, + }; + }) + ); + + return results.filter((r): r is { marketType: MarketType; outlierBookmakers: OutlierBookmaker[] } => r !== null); + } + /** * Run consensus calculation for all upcoming games (next 48 hours). * Called by the scheduled job every 15 minutes. @@ -477,13 +652,30 @@ export class MarketConsensusService { const existing = gameMap.get(r.gameId); const consensus: ConsensusResult = { gameId: r.gameId, - marketType: r.marketType, + marketType: r.marketType as MarketType, consensusLine: parseFloat(r.consensusLine.toString()), + consensusPrice: r.consensusPrice, + medianLine: parseFloat(r.medianLine.toString()), + meanLine: parseFloat(r.meanLine.toString()), + modeLine: r.modeLine !== null ? parseFloat(r.modeLine.toString()) : null, standardDeviation: parseFloat(r.standardDeviation.toString()), + range: parseFloat(r.range.toString()), + interquartileRange: parseFloat(r.interquartileRange.toString()), outlierBookmakers: r.outlierBookmakers as unknown as OutlierBookmaker[], + bestValueSide: r.bestValueSide as BestValue['side'], + bestValueBookmaker: r.bestValueBookmaker, + bestValueLine: parseFloat(r.bestValueLine.toString()), bookmakerCount: r.bookmakerCount, + sharpBookWeight: parseFloat(r.sharpBookWeight.toString()), disagreementScore: r.disagreementScore, - bestValue: r.bestValue as unknown as BestValue, + bestValue: { + side: r.bestValueSide as BestValue['side'], + bookmaker: r.bestValueBookmaker, + line: parseFloat(r.bestValueLine.toString()), + impliedProb: + (r.bestValue as unknown as { impliedProb?: number } | null)?.impliedProb ?? + 0.5, + }, }; if (existing) { @@ -530,13 +722,30 @@ export class MarketConsensusService { }) .map((r) => ({ gameId: r.gameId, - marketType: r.marketType, + marketType: r.marketType as MarketType, consensusLine: parseFloat(r.consensusLine.toString()), + consensusPrice: r.consensusPrice, + medianLine: parseFloat(r.medianLine.toString()), + meanLine: parseFloat(r.meanLine.toString()), + modeLine: r.modeLine !== null ? parseFloat(r.modeLine.toString()) : null, standardDeviation: parseFloat(r.standardDeviation.toString()), + range: parseFloat(r.range.toString()), + interquartileRange: parseFloat(r.interquartileRange.toString()), outlierBookmakers: r.outlierBookmakers as unknown as OutlierBookmaker[], + bestValueSide: r.bestValueSide as BestValue['side'], + bestValueBookmaker: r.bestValueBookmaker, + bestValueLine: parseFloat(r.bestValueLine.toString()), bookmakerCount: r.bookmakerCount, + sharpBookWeight: parseFloat(r.sharpBookWeight.toString()), disagreementScore: r.disagreementScore, - bestValue: r.bestValue as unknown as BestValue, + bestValue: { + side: r.bestValueSide as BestValue['side'], + bookmaker: r.bestValueBookmaker, + line: parseFloat(r.bestValueLine.toString()), + impliedProb: + (r.bestValue as unknown as { impliedProb?: number } | null)?.impliedProb ?? + 0.5, + }, })); } diff --git a/dashboard/backend/tests/market-consensus.service.test.ts b/dashboard/backend/tests/market-consensus.service.test.ts new file mode 100644 index 0000000..9993950 --- /dev/null +++ b/dashboard/backend/tests/market-consensus.service.test.ts @@ -0,0 +1,228 @@ +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import { MarketConsensusService } from '../src/services/market-consensus.service'; + +jest.mock('../src/config/database', () => ({ + prisma: { + currentOdds: { + findMany: jest.fn(), + }, + marketConsensus: { + create: jest.fn(), + }, + }, +})); + +jest.mock('../src/config/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +import { prisma } from '../src/config/database'; + +const mockPrisma = prisma as jest.Mocked; + +describe('MarketConsensusService', () => { + let service: MarketConsensusService; + + beforeEach(() => { + service = new MarketConsensusService(); + jest.clearAllMocks(); + }); + + describe('calculateConsensus', () => { + it('calculates phase-2 consensus metrics for spreads', async () => { + mockPrisma.currentOdds.findMany.mockResolvedValue([ + { + bookmaker: 'pinnacle', + marketType: 'spreads', + homeSpread: -3, + homeSpreadPrice: -110, + awaySpread: 3, + awaySpreadPrice: -110, + }, + { + bookmaker: 'draftkings', + marketType: 'spreads', + homeSpread: -3.5, + homeSpreadPrice: -105, + awaySpread: 3.5, + awaySpreadPrice: -112, + }, + { + bookmaker: 'fanduel', + marketType: 'spreads', + homeSpread: -3, + homeSpreadPrice: -115, + awaySpread: 3, + awaySpreadPrice: -109, + }, + { + bookmaker: 'circa', + marketType: 'spreads', + homeSpread: -2.5, + homeSpreadPrice: -120, + awaySpread: 2.5, + awaySpreadPrice: -101, + }, + { + bookmaker: 'betmgm', + marketType: 'spreads', + homeSpread: -7, + homeSpreadPrice: -108, + awaySpread: 7, + awaySpreadPrice: -107, + }, + ] as any); + + const result = await service.calculateConsensus('game-1', 'spreads'); + + expect(result).not.toBeNull(); + expect(result!.consensusLine).toBe(-3); + expect(result!.consensusPrice).toBe(-110); + expect(result!.medianLine).toBe(-3); + expect(result!.meanLine).toBe(-3.8); + expect(result!.modeLine).toBe(-3); + expect(result!.range).toBe(4.5); + expect(result!.interquartileRange).toBe(0.5); + expect(result!.bestValueSide).toBe('away'); + expect(result!.bestValueBookmaker).toBe('circa'); + expect(result!.bestValueLine).toBe(2.5); + expect(result!.sharpBookWeight).toBe(40); + expect(result!.outlierBookmakers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ bookmaker: 'betmgm' }), + ]) + ); + expect(result!.disagreementScore).toBeGreaterThan(0); + }); + + it('calculates phase-2 consensus metrics for h2h', async () => { + mockPrisma.currentOdds.findMany.mockResolvedValue([ + { bookmaker: 'pinnacle', marketType: 'h2h', homePrice: -120, awayPrice: 105 }, + { bookmaker: 'fanduel', marketType: 'h2h', homePrice: -115, awayPrice: 100 }, + { bookmaker: 'draftkings', marketType: 'h2h', homePrice: -110, awayPrice: -102 }, + ] as any); + + const result = await service.calculateConsensus('game-1', 'h2h'); + + expect(result).not.toBeNull(); + expect(result!.marketType).toBe('h2h'); + expect(result!.consensusLine).toBe(-115); + expect(result!.consensusPrice).toBe(-115); + expect(result!.medianLine).toBe(-115); + expect(result!.meanLine).toBe(-115); + expect(result!.modeLine).toBeNull(); + expect(result!.bestValueBookmaker).toBe('pinnacle'); + expect(result!.bestValueLine).toBe(105); + }); + + it('calculates phase-2 consensus metrics for totals', async () => { + mockPrisma.currentOdds.findMany.mockResolvedValue([ + { bookmaker: 'pinnacle', marketType: 'totals', totalLine: 46.5, overPrice: -110, underPrice: -110 }, + { bookmaker: 'fanduel', marketType: 'totals', totalLine: 47, overPrice: -108, underPrice: -112 }, + { bookmaker: 'draftkings', marketType: 'totals', totalLine: 46.5, overPrice: -105, underPrice: -115 }, + ] as any); + + const result = await service.calculateConsensus('game-1', 'totals'); + + expect(result).not.toBeNull(); + expect(result!.marketType).toBe('totals'); + expect(result!.consensusLine).toBe(46.5); + expect(result!.consensusPrice).toBe(-108); + expect(result!.modeLine).toBe(46.5); + expect(result!.interquartileRange).toBe(0.25); + expect(result!.bestValueSide).toBe('over'); + expect(result!.bestValueBookmaker).toBe('draftkings'); + }); + }); + + describe('identifyOutliers', () => { + it('returns per-market outlier lists for a game', async () => { + mockPrisma.currentOdds.findMany.mockImplementation(async ({ where }: any) => { + if (where.marketType === 'h2h') { + return [ + { bookmaker: 'pinnacle', homePrice: -110, awayPrice: 110 }, + { bookmaker: 'fanduel', homePrice: -108, awayPrice: 108 }, + ] as any; + } + + if (where.marketType === 'spreads') { + return [ + { + bookmaker: 'pinnacle', + homeSpread: -3, + homeSpreadPrice: -110, + awaySpread: 3, + awaySpreadPrice: -110, + }, + { + bookmaker: 'draftkings', + homeSpread: -3, + homeSpreadPrice: -110, + awaySpread: 3, + awaySpreadPrice: -110, + }, + { + bookmaker: 'fanduel', + homeSpread: -3, + homeSpreadPrice: -111, + awaySpread: 3, + awaySpreadPrice: -109, + }, + { + bookmaker: 'circa', + homeSpread: -3, + homeSpreadPrice: -108, + awaySpread: 3, + awaySpreadPrice: -112, + }, + { + bookmaker: 'caesars', + homeSpread: -3, + homeSpreadPrice: -110, + awaySpread: 3, + awaySpreadPrice: -110, + }, + { + bookmaker: 'betmgm', + homeSpread: -12, + homeSpreadPrice: -108, + awaySpread: 12, + awaySpreadPrice: -108, + }, + ] as any; + } + + return [ + { + bookmaker: 'pinnacle', + totalLine: 46.5, + overPrice: -110, + underPrice: -110, + }, + { + bookmaker: 'fanduel', + totalLine: 46.5, + overPrice: -108, + underPrice: -112, + }, + ] as any; + }); + + const outliers = await service.identifyOutliers('game-1'); + + expect(outliers).toHaveLength(3); + const spreads = outliers.find((o) => o.marketType === 'spreads'); + expect(spreads).toBeDefined(); + expect(spreads!.outlierBookmakers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ bookmaker: 'betmgm' }), + ]) + ); + }); + }); +});