diff --git a/dashboard/backend/CHANGELOG.md b/dashboard/backend/CHANGELOG.md index 07f6b46..10e935d 100644 --- a/dashboard/backend/CHANGELOG.md +++ b/dashboard/backend/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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. +- **Bookmaker Performance Analytics — Phase 2** (Issue: Bookmaker Analytics): Added persistent bookmaker analytics models and service calculations for value, sharpness, and reliability ranking. + - `prisma/schema.prisma`: Added `BookmakerAnalytics` and `BookmakerMovementEvent` models; linked movement events to `Game`. + - `prisma/migrations/20260514000002_add_bookmaker_analytics_phase2/migration.sql`: Creates `bookmaker_analytics` and `bookmaker_movement_events` tables, indexes, and FK constraints. + - `src/services/bookmaker-analytics.service.ts`: New `BookmakerAnalyticsService` with `calculateBookmakerMetrics(bookmaker)` and `rankBookmakers(criteria)` methods. + - `tests/bookmaker-analytics.service.test.ts`: Added unit tests for metric calculation/upsert behavior and criteria-based ranking. --- diff --git a/dashboard/backend/prisma/migrations/20260514000002_add_bookmaker_analytics_phase2/migration.sql b/dashboard/backend/prisma/migrations/20260514000002_add_bookmaker_analytics_phase2/migration.sql new file mode 100644 index 0000000..16f97c3 --- /dev/null +++ b/dashboard/backend/prisma/migrations/20260514000002_add_bookmaker_analytics_phase2/migration.sql @@ -0,0 +1,63 @@ +-- CreateTable +CREATE TABLE "bookmaker_analytics" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "bookmaker" VARCHAR(50) NOT NULL, + "average_clv_offered" DECIMAL(5,2) NOT NULL, + "best_odds_frequency" DECIMAL(5,2) NOT NULL, + "margin_vs_consensus" DECIMAL(5,2) NOT NULL, + "outlier_frequency" DECIMAL(5,2) NOT NULL, + "first_mover_frequency" DECIMAL(5,2) NOT NULL, + "line_movement_lag" INTEGER NOT NULL, + "sharp_book_rating" INTEGER NOT NULL, + "market_efficiency" DECIMAL(5,2) NOT NULL, + "sports_covered" TEXT[], + "markets_covered" TEXT[], + "uptime_percentage" DECIMAL(5,2) NOT NULL, + "odds_update_frequency" INTEGER NOT NULL, + "average_odds_age" INTEGER NOT NULL, + "limit_profile" VARCHAR(20) NOT NULL, + "estimated_max_bet" DECIMAL(10,2), + "account_limit_reports" INTEGER NOT NULL, + "total_games_offered" INTEGER NOT NULL, + "total_markets_offered" INTEGER NOT NULL, + "average_margin" DECIMAL(5,2) NOT NULL, + "user_rating" DECIMAL(3,2), + "user_review_count" INTEGER NOT NULL DEFAULT 0, + "recommendation_score" INTEGER NOT NULL, + "calculated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "bookmaker_analytics_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "bookmaker_movement_events" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "game_id" UUID NOT NULL, + "market_type" VARCHAR(20) NOT NULL, + "detected_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "first_mover" VARCHAR(50) NOT NULL, + "first_move_time" TIMESTAMPTZ(6) NOT NULL, + "followers" JSONB NOT NULL, + "movement_size" DECIMAL(5,2) NOT NULL, + + CONSTRAINT "bookmaker_movement_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "bookmaker_analytics_bookmaker_key" ON "bookmaker_analytics"("bookmaker"); + +-- CreateIndex +CREATE INDEX "bookmaker_analytics_best_odds_frequency_idx" ON "bookmaker_analytics"("best_odds_frequency"); + +-- CreateIndex +CREATE INDEX "bookmaker_analytics_sharp_book_rating_idx" ON "bookmaker_analytics"("sharp_book_rating"); + +-- CreateIndex +CREATE INDEX "bookmaker_movement_events_first_mover_idx" ON "bookmaker_movement_events"("first_mover"); + +-- CreateIndex +CREATE INDEX "bookmaker_movement_events_detected_at_idx" ON "bookmaker_movement_events"("detected_at"); + +-- AddForeignKey +ALTER TABLE "bookmaker_movement_events" ADD CONSTRAINT "bookmaker_movement_events_game_id_fkey" FOREIGN KEY ("game_id") REFERENCES "games"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/dashboard/backend/prisma/schema.prisma b/dashboard/backend/prisma/schema.prisma index f772884..80501fb 100644 --- a/dashboard/backend/prisma/schema.prisma +++ b/dashboard/backend/prisma/schema.prisma @@ -160,6 +160,7 @@ model Game { playerStats PlayerGameStats[] marketConsensus MarketConsensus[] lineMovements LineMovement[] + bookmakerMovementEvents BookmakerMovementEvent[] sharpMoneyIndicators SharpMoneyIndicator[] @@index([commenceTime]) @@ -348,6 +349,73 @@ model LineMovement { @@map("line_movements") } +// Bookmaker-level performance analytics +model BookmakerAnalytics { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + bookmaker String @unique @db.VarChar(50) + + // Odds quality metrics + averageCLVOffered Decimal @map("average_clv_offered") @db.Decimal(5, 2) + bestOddsFrequency Decimal @map("best_odds_frequency") @db.Decimal(5, 2) + marginVsConsensus Decimal @map("margin_vs_consensus") @db.Decimal(5, 2) + outlierFrequency Decimal @map("outlier_frequency") @db.Decimal(5, 2) + + // Market making behavior + firstMoverFrequency Decimal @map("first_mover_frequency") @db.Decimal(5, 2) + lineMovementLag Int @map("line_movement_lag") + sharpBookRating Int @map("sharp_book_rating") + marketEfficiency Decimal @map("market_efficiency") @db.Decimal(5, 2) + + // Coverage & reliability + sportsCovered String[] @map("sports_covered") + marketsCovered String[] @map("markets_covered") + uptimePercentage Decimal @map("uptime_percentage") @db.Decimal(5, 2) + oddsUpdateFrequency Int @map("odds_update_frequency") + averageOddsAge Int @map("average_odds_age") + + // Limits & accessibility + limitProfile String @map("limit_profile") @db.VarChar(20) + estimatedMaxBet Decimal? @map("estimated_max_bet") @db.Decimal(10, 2) + accountLimitReports Int @map("account_limit_reports") + + // Historical performance + totalGamesOffered Int @map("total_games_offered") + totalMarketsOffered Int @map("total_markets_offered") + averageMargin Decimal @map("average_margin") @db.Decimal(5, 2) + + // Reputation + userRating Decimal? @map("user_rating") @db.Decimal(3, 2) + userReviewCount Int @default(0) @map("user_review_count") + recommendationScore Int @map("recommendation_score") + + // Metadata + calculatedAt DateTime @default(now()) @map("calculated_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + + @@index([bestOddsFrequency]) + @@index([sharpBookRating]) + @@map("bookmaker_analytics") +} + +// Bookmaker movement events (who moved first and how quickly others followed) +model BookmakerMovementEvent { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + gameId String @map("game_id") @db.Uuid + marketType String @map("market_type") @db.VarChar(20) + detectedAt DateTime @default(now()) @map("detected_at") @db.Timestamptz(6) + + firstMover String @map("first_mover") @db.VarChar(50) + firstMoveTime DateTime @map("first_move_time") @db.Timestamptz(6) + followers Json + movementSize Decimal @map("movement_size") @db.Decimal(5, 2) + + game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) + + @@index([firstMover]) + @@index([detectedAt]) + @@map("bookmaker_movement_events") +} + // Users table (for OAuth2 authentication) model User { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid diff --git a/dashboard/backend/src/services/bookmaker-analytics.service.ts b/dashboard/backend/src/services/bookmaker-analytics.service.ts new file mode 100644 index 0000000..b998d83 --- /dev/null +++ b/dashboard/backend/src/services/bookmaker-analytics.service.ts @@ -0,0 +1,405 @@ +import { BookmakerAnalytics, CurrentOdds, MarketConsensus, OddsSnapshot } from '@prisma/client'; +import { Decimal } from '@prisma/client/runtime/library'; +import { prisma } from '../config/database'; +import { logger } from '../config/logger'; + +type RankCriteria = + | 'value' + | 'sharpness' + | 'reliability' + | 'coverage' + | 'limits' + | 'recommendation'; + +interface MovementFollower { + bookmaker?: string; + lagSeconds?: number; +} + +interface ConsensusOutlier { + bookmaker?: string; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function round2(value: number): number { + return parseFloat(value.toFixed(2)); +} + +function toDecimal(value: number): Decimal { + return new Decimal(round2(value).toString()); +} + +function snapshotHourKey(timestamp: Date): string { + return timestamp.toISOString().slice(0, 13); +} + +function getComparableLine(odds: CurrentOdds, consensus: MarketConsensus): number | null { + if (odds.marketType === 'h2h') { + return odds.homePrice ?? null; + } + + if (odds.marketType === 'spreads') { + if (odds.homeSpread == null) return null; + return parseFloat(odds.homeSpread.toString()); + } + + if (odds.marketType === 'totals') { + if (!odds.totalLine) return null; + return parseFloat(odds.totalLine.toString()); + } + + return null; +} + +export class BookmakerAnalyticsService { + private readonly LOOKBACK_DAYS = 30; + private readonly EFFICIENCY_MARGIN_MULTIPLIER = 10; + private readonly EFFICIENCY_OUTLIER_WEIGHT = 0.5; + private readonly SHARPNESS_WEIGHTS = { + firstMoverFrequency: 0.5, + bestOddsFrequency: 0.3, + marketEfficiency: 0.2, + } as const; + private readonly RECOMMENDATION_WEIGHTS = { + value: 0.4, + reliability: 0.35, + coverage: 0.15, + sharpness: 0.1, + } as const; + private readonly COVERAGE_MARKET_TARGET = 200; + private readonly SHARP_RATING_HIGH_THRESHOLD = 8; + private readonly SHARP_RATING_MEDIUM_THRESHOLD = 5; + private readonly MAX_BET_HIGH_LIMIT = 5000; + private readonly MAX_BET_MEDIUM_LIMIT = 1500; + private readonly MAX_BET_LOW_LIMIT = 500; + + async calculateBookmakerMetrics(bookmaker: string): Promise { + const normalizedBookmaker = bookmaker.trim().toLowerCase(); + + if (!normalizedBookmaker) { + throw new Error('bookmaker is required'); + } + + const now = new Date(); + const cutoff = new Date(now.getTime() - this.LOOKBACK_DAYS * 24 * 60 * 60 * 1000); + + const [currentOdds, consensusRows, movementEvents, snapshots] = await Promise.all([ + prisma.currentOdds.findMany({ + where: { + bookmaker: normalizedBookmaker, + game: { + commenceTime: { gte: cutoff }, + }, + }, + include: { + game: { + select: { + sport: { + select: { + key: true, + }, + }, + }, + }, + }, + }), + prisma.marketConsensus.findMany({ + where: { + calculatedAt: { gte: cutoff }, + }, + orderBy: { + calculatedAt: 'desc', + }, + }), + prisma.bookmakerMovementEvent.findMany({ + where: { + detectedAt: { gte: cutoff }, + }, + }), + prisma.oddsSnapshot.findMany({ + where: { + bookmaker: normalizedBookmaker, + capturedAt: { gte: cutoff }, + }, + orderBy: { + capturedAt: 'asc', + }, + }), + ]); + + const offeredKeys = new Set(currentOdds.map((row) => `${row.gameId}:${row.marketType}`)); + const eligibleConsensus = consensusRows.filter((row) => + offeredKeys.has(`${row.gameId}:${row.marketType}`) + ); + + const bestOddsHits = eligibleConsensus.filter( + (row) => row.bestValueBookmaker.toLowerCase() === normalizedBookmaker + ).length; + + const outlierHits = eligibleConsensus.filter((row) => { + const outliers = (row.outlierBookmakers as ConsensusOutlier[]) ?? []; + return outliers.some( + (entry) => entry.bookmaker?.toLowerCase() === normalizedBookmaker + ); + }).length; + + const marginSamples: number[] = []; + const consensusByKey = new Map(); + for (const row of eligibleConsensus) { + const key = `${row.gameId}:${row.marketType}`; + if (!consensusByKey.has(key)) { + consensusByKey.set(key, row); + } + } + + for (const oddsRow of currentOdds) { + const consensus = consensusByKey.get(`${oddsRow.gameId}:${oddsRow.marketType}`); + if (!consensus) continue; + + const bookLine = getComparableLine(oddsRow, consensus); + if (bookLine === null) continue; + + const consensusLine = parseFloat(consensus.consensusLine.toString()); + marginSamples.push(Math.abs(bookLine - consensusLine)); + } + + const marginVsConsensus = + marginSamples.length > 0 + ? marginSamples.reduce((sum, value) => sum + value, 0) / marginSamples.length + : 0; + + const eventsForBookmaker = movementEvents.filter((event) => { + if (event.firstMover.toLowerCase() === normalizedBookmaker) { + return true; + } + + const followers = (event.followers as MovementFollower[]) ?? []; + return followers.some( + (entry) => entry.bookmaker?.toLowerCase() === normalizedBookmaker + ); + }); + + const firstMoverCount = eventsForBookmaker.filter( + (event) => event.firstMover.toLowerCase() === normalizedBookmaker + ).length; + + const lagSamples: number[] = []; + for (const event of eventsForBookmaker) { + const followers = (event.followers as MovementFollower[]) ?? []; + const match = followers.find( + (entry) => entry.bookmaker?.toLowerCase() === normalizedBookmaker + ); + if (typeof match?.lagSeconds === 'number') { + lagSamples.push(match.lagSeconds); + } + } + + const firstMoverFrequency = + eventsForBookmaker.length > 0 + ? (firstMoverCount / eventsForBookmaker.length) * 100 + : 0; + const lineMovementLag = + lagSamples.length > 0 + ? Math.round(lagSamples.reduce((sum, value) => sum + value, 0) / lagSamples.length) + : 0; + + const bestOddsFrequency = + eligibleConsensus.length > 0 + ? (bestOddsHits / eligibleConsensus.length) * 100 + : 0; + const outlierFrequency = + eligibleConsensus.length > 0 + ? (outlierHits / eligibleConsensus.length) * 100 + : 0; + + const totalGamesOffered = new Set(currentOdds.map((row) => row.gameId)).size; + const totalMarketsOffered = currentOdds.length; + + const sportsCovered = Array.from( + new Set( + currentOdds + .map((row) => row.game.sport.key) + .filter((key): key is string => Boolean(key)) + ) + ).sort(); + + const marketsCovered = Array.from( + new Set(currentOdds.map((row) => row.marketType)) + ).sort(); + + const marketEfficiency = clamp( + 100 - + marginVsConsensus * this.EFFICIENCY_MARGIN_MULTIPLIER - + outlierFrequency * this.EFFICIENCY_OUTLIER_WEIGHT, + 0, + 100 + ); + + const sharpBookRating = Math.round( + clamp( + (firstMoverFrequency * this.SHARPNESS_WEIGHTS.firstMoverFrequency + + bestOddsFrequency * this.SHARPNESS_WEIGHTS.bestOddsFrequency + + marketEfficiency * this.SHARPNESS_WEIGHTS.marketEfficiency) / + 10, + 1, + 10 + ) + ); + + const snapshotHourKeys = snapshots.map((snapshot) => + snapshotHourKey(snapshot.capturedAt) + ); + const snapshotHours = new Set(snapshotHourKeys).size; + const earliestSnapshot = snapshots[0]?.capturedAt; + const spanHours = earliestSnapshot + ? Math.max(1, Math.ceil((now.getTime() - earliestSnapshot.getTime()) / (60 * 60 * 1000))) + : 1; + + const uptimePercentage = snapshots.length > 0 ? (snapshotHours / spanHours) * 100 : 0; + + const updateDiffs: number[] = []; + for (let i = 1; i < snapshots.length; i++) { + const diffSeconds = Math.round( + (snapshots[i].capturedAt.getTime() - snapshots[i - 1].capturedAt.getTime()) / 1000 + ); + if (diffSeconds > 0) { + updateDiffs.push(diffSeconds); + } + } + + const oddsUpdateFrequency = + updateDiffs.length > 0 + ? Math.round(updateDiffs.reduce((sum, value) => sum + value, 0) / updateDiffs.length) + : 0; + + const latestSnapshot = snapshots[snapshots.length - 1]?.capturedAt; + const averageOddsAge = latestSnapshot + ? Math.max(0, Math.round((now.getTime() - latestSnapshot.getTime()) / 1000)) + : 0; + + const averageCLVOffered = bestOddsFrequency - outlierFrequency; + + const limitProfile = + sharpBookRating >= this.SHARP_RATING_HIGH_THRESHOLD + ? 'high' + : sharpBookRating >= this.SHARP_RATING_MEDIUM_THRESHOLD + ? 'medium' + : 'low'; + const estimatedMaxBet = + limitProfile === 'high' + ? this.MAX_BET_HIGH_LIMIT + : limitProfile === 'medium' + ? this.MAX_BET_MEDIUM_LIMIT + : this.MAX_BET_LOW_LIMIT; + + const valueScore = + (bestOddsFrequency + + (100 - + clamp( + marginVsConsensus * this.EFFICIENCY_MARGIN_MULTIPLIER, + 0, + 100 + ))) / + 2; + const reliabilityScore = (uptimePercentage + marketEfficiency) / 2; + const coverageScore = clamp( + (totalMarketsOffered / this.COVERAGE_MARKET_TARGET) * 100, + 0, + 100 + ); + const recommendationScore = Math.round( + clamp( + valueScore * this.RECOMMENDATION_WEIGHTS.value + + reliabilityScore * this.RECOMMENDATION_WEIGHTS.reliability + + coverageScore * this.RECOMMENDATION_WEIGHTS.coverage + + sharpBookRating * 10 * this.RECOMMENDATION_WEIGHTS.sharpness, + 1, + 100 + ) + ); + + const analytics = await prisma.bookmakerAnalytics.upsert({ + where: { + bookmaker: normalizedBookmaker, + }, + create: { + bookmaker: normalizedBookmaker, + averageCLVOffered: toDecimal(averageCLVOffered), + bestOddsFrequency: toDecimal(bestOddsFrequency), + marginVsConsensus: toDecimal(marginVsConsensus), + outlierFrequency: toDecimal(outlierFrequency), + firstMoverFrequency: toDecimal(firstMoverFrequency), + lineMovementLag, + sharpBookRating, + marketEfficiency: toDecimal(marketEfficiency), + sportsCovered, + marketsCovered, + uptimePercentage: toDecimal(uptimePercentage), + oddsUpdateFrequency, + averageOddsAge, + limitProfile, + estimatedMaxBet: toDecimal(estimatedMaxBet), + accountLimitReports: 0, + totalGamesOffered, + totalMarketsOffered, + averageMargin: toDecimal(marginVsConsensus), + userRating: null, + userReviewCount: 0, + recommendationScore, + }, + update: { + averageCLVOffered: toDecimal(averageCLVOffered), + bestOddsFrequency: toDecimal(bestOddsFrequency), + marginVsConsensus: toDecimal(marginVsConsensus), + outlierFrequency: toDecimal(outlierFrequency), + firstMoverFrequency: toDecimal(firstMoverFrequency), + lineMovementLag, + sharpBookRating, + marketEfficiency: toDecimal(marketEfficiency), + sportsCovered, + marketsCovered, + uptimePercentage: toDecimal(uptimePercentage), + oddsUpdateFrequency, + averageOddsAge, + limitProfile, + estimatedMaxBet: toDecimal(estimatedMaxBet), + totalGamesOffered, + totalMarketsOffered, + averageMargin: toDecimal(marginVsConsensus), + recommendationScore, + calculatedAt: now, + }, + }); + + logger.info( + `Calculated bookmaker analytics for ${normalizedBookmaker}: score=${recommendationScore}` + ); + + return analytics; + } + + async rankBookmakers(criteria: string): Promise { + const normalizedCriteria = criteria.toLowerCase() as RankCriteria; + + const orderByMap: Record = { + value: [{ bestOddsFrequency: 'desc' }, { averageCLVOffered: 'desc' }], + sharpness: [{ sharpBookRating: 'desc' }, { firstMoverFrequency: 'desc' }], + reliability: [{ uptimePercentage: 'desc' }, { marketEfficiency: 'desc' }], + coverage: [{ totalMarketsOffered: 'desc' }, { totalGamesOffered: 'desc' }], + limits: [{ estimatedMaxBet: 'desc' }, { sharpBookRating: 'desc' }], + recommendation: [{ recommendationScore: 'desc' }, { sharpBookRating: 'desc' }], + }; + + const orderBy = orderByMap[normalizedCriteria] ?? orderByMap.recommendation; + + return prisma.bookmakerAnalytics.findMany({ + orderBy, + take: 50, + }); + } +} + +export const bookmakerAnalyticsService = new BookmakerAnalyticsService(); diff --git a/dashboard/backend/tests/bookmaker-analytics.service.test.ts b/dashboard/backend/tests/bookmaker-analytics.service.test.ts new file mode 100644 index 0000000..3b4dca8 --- /dev/null +++ b/dashboard/backend/tests/bookmaker-analytics.service.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { BookmakerAnalyticsService } from '../src/services/bookmaker-analytics.service'; + +jest.mock('../src/config/database', () => ({ + prisma: { + currentOdds: { + findMany: jest.fn(), + }, + marketConsensus: { + findMany: jest.fn(), + }, + bookmakerMovementEvent: { + findMany: jest.fn(), + }, + oddsSnapshot: { + findMany: jest.fn(), + }, + bookmakerAnalytics: { + upsert: jest.fn(), + findMany: 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('BookmakerAnalyticsService', () => { + let service: BookmakerAnalyticsService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new BookmakerAnalyticsService(); + }); + + it('calculates and upserts bookmaker analytics metrics', async () => { + jest.spyOn(Date, 'now').mockReturnValue(new Date('2026-05-14T11:00:00.000Z').getTime()); + + mockPrisma.currentOdds.findMany.mockResolvedValue([ + { + gameId: 'game-1', + marketType: 'h2h', + homePrice: -110, + homeSpread: null, + totalLine: null, + game: { sport: { key: 'basketball_nba' } }, + }, + { + gameId: 'game-2', + marketType: 'spreads', + homePrice: null, + homeSpread: -3.5, + totalLine: null, + game: { sport: { key: 'americanfootball_nfl' } }, + }, + ] as any); + + mockPrisma.marketConsensus.findMany.mockResolvedValue([ + { + gameId: 'game-1', + marketType: 'h2h', + bestValueBookmaker: 'draftkings', + outlierBookmakers: [], + consensusLine: -112, + }, + { + gameId: 'game-2', + marketType: 'spreads', + bestValueBookmaker: 'fanduel', + outlierBookmakers: [{ bookmaker: 'draftkings' }], + consensusLine: -3, + }, + { + gameId: 'game-3', + marketType: 'totals', + bestValueBookmaker: 'draftkings', + outlierBookmakers: [], + consensusLine: 45, + }, + ] as any); + + mockPrisma.bookmakerMovementEvent.findMany.mockResolvedValue([ + { + firstMover: 'draftkings', + followers: [{ bookmaker: 'fanduel', lagSeconds: 20 }], + }, + { + firstMover: 'pinnacle', + followers: [{ bookmaker: 'draftkings', lagSeconds: 30 }], + }, + ] as any); + + mockPrisma.oddsSnapshot.findMany.mockResolvedValue([ + { capturedAt: new Date('2026-05-14T10:00:00.000Z') }, + { capturedAt: new Date('2026-05-14T10:10:00.000Z') }, + { capturedAt: new Date('2026-05-14T10:20:00.000Z') }, + ] as any); + + mockPrisma.bookmakerAnalytics.upsert.mockImplementation(async ({ create }: any) => ({ + id: 'analytics-1', + ...create, + })); + + const result = await service.calculateBookmakerMetrics(' DraftKings '); + + expect(mockPrisma.bookmakerAnalytics.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { bookmaker: 'draftkings' }, + }) + ); + + expect(result.bookmaker).toBe('draftkings'); + expect(result.bestOddsFrequency.toString()).toBe('50'); + expect(result.outlierFrequency.toString()).toBe('50'); + expect(result.marginVsConsensus.toString()).toBe('1.25'); + expect(result.firstMoverFrequency.toString()).toBe('50'); + expect(result.lineMovementLag).toBe(30); + expect(result.sportsCovered).toEqual(['americanfootball_nfl', 'basketball_nba']); + expect(result.marketsCovered).toEqual(['h2h', 'spreads']); + expect(result.limitProfile).toBe('medium'); + expect(result.totalGamesOffered).toBe(2); + expect(result.totalMarketsOffered).toBe(2); + expect(result.recommendationScore).toBeGreaterThan(1); + }); + + it('ranks bookmakers by requested criteria and defaults to recommendation', async () => { + mockPrisma.bookmakerAnalytics.findMany.mockResolvedValue([] as any); + + await service.rankBookmakers('sharpness'); + expect(mockPrisma.bookmakerAnalytics.findMany).toHaveBeenLastCalledWith({ + orderBy: [{ sharpBookRating: 'desc' }, { firstMoverFrequency: 'desc' }], + take: 50, + }); + + await service.rankBookmakers('not-a-real-criteria'); + expect(mockPrisma.bookmakerAnalytics.findMany).toHaveBeenLastCalledWith({ + orderBy: [{ recommendationScore: 'desc' }, { sharpBookRating: 'desc' }], + take: 50, + }); + }); +}); diff --git a/docs/wiki/Database-Guide.md b/docs/wiki/Database-Guide.md index 1905008..4e18ee8 100644 --- a/docs/wiki/Database-Guide.md +++ b/docs/wiki/Database-Guide.md @@ -7,6 +7,7 @@ Complete guide to the BetTrack database schema, migrations, and data model. - [Database Overview](#database-overview) - [Schema Design](#schema-design) - [Core Models](#core-models) +- [Bookmaker Analytics Models](#bookmaker-analytics-models) - [Relationships](#relationships) - [Indexes & Performance](#indexes--performance) - [Migrations](#migrations) @@ -226,6 +227,67 @@ model SharpMoneyIndicator { - `contraindicators` array flags signals that reduce reliability - `publicBettingPct` / `publicMoneyPct` reserved for external data integration +### Bookmaker Analytics Models + +Stores per-bookmaker quality, sharpness, and reliability metrics plus first-mover tracking events. + +```prisma +model BookmakerAnalytics { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + bookmaker String @unique @db.VarChar(50) + averageCLVOffered Decimal @map("average_clv_offered") @db.Decimal(5, 2) + bestOddsFrequency Decimal @map("best_odds_frequency") @db.Decimal(5, 2) + marginVsConsensus Decimal @map("margin_vs_consensus") @db.Decimal(5, 2) + outlierFrequency Decimal @map("outlier_frequency") @db.Decimal(5, 2) + firstMoverFrequency Decimal @map("first_mover_frequency") @db.Decimal(5, 2) + lineMovementLag Int @map("line_movement_lag") + sharpBookRating Int @map("sharp_book_rating") + marketEfficiency Decimal @map("market_efficiency") @db.Decimal(5, 2) + sportsCovered String[] @map("sports_covered") + marketsCovered String[] @map("markets_covered") + uptimePercentage Decimal @map("uptime_percentage") @db.Decimal(5, 2) + oddsUpdateFrequency Int @map("odds_update_frequency") + averageOddsAge Int @map("average_odds_age") + limitProfile String @map("limit_profile") @db.VarChar(20) + estimatedMaxBet Decimal? @map("estimated_max_bet") @db.Decimal(10, 2) + accountLimitReports Int @map("account_limit_reports") + totalGamesOffered Int @map("total_games_offered") + totalMarketsOffered Int @map("total_markets_offered") + averageMargin Decimal @map("average_margin") @db.Decimal(5, 2) + userRating Decimal? @map("user_rating") @db.Decimal(3, 2) + userReviewCount Int @default(0) @map("user_review_count") + recommendationScore Int @map("recommendation_score") + calculatedAt DateTime @default(now()) @map("calculated_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + + @@index([bestOddsFrequency]) + @@index([sharpBookRating]) + @@map("bookmaker_analytics") +} + +model BookmakerMovementEvent { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + gameId String @map("game_id") @db.Uuid + marketType String @map("market_type") @db.VarChar(20) + detectedAt DateTime @default(now()) @map("detected_at") @db.Timestamptz(6) + firstMover String @map("first_mover") @db.VarChar(50) + firstMoveTime DateTime @map("first_move_time") @db.Timestamptz(6) + followers Json + movementSize Decimal @map("movement_size") @db.Decimal(5, 2) + game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) + + @@index([firstMover]) + @@index([detectedAt]) + @@map("bookmaker_movement_events") +} +``` + +**Key Points**: +- `BookmakerAnalytics` stores one row per bookmaker with continuously refreshed metrics. +- `BookmakerMovementEvent` stores movement leadership events (`firstMover`) and follower lag (`followers` JSON). +- `recommendationScore` is a 1–100 composite ranking score for bookmaker selection. +- `sportsCovered` and `marketsCovered` support coverage-driven ranking/filtering. + ### Bet Model User bets placed on games.