From fc05e7fd844a97a76efddf417aa5fd0c96ea17f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 05:57:17 +0000 Subject: [PATCH 1/7] Initial plan From d0a9938f8e8e59d8e69395bdfb575958f7606c71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 05:59:17 +0000 Subject: [PATCH 2/7] Plan: implement Phase 2 market consensus calculations Agent-Logs-Url: https://github.com/WFord26/BetTrack/sessions/9c93eecb-77e8-4602-89c3-e8698f7bd609 Co-authored-by: WFord26 <117926366+WFord26@users.noreply.github.com> --- dashboard/package-lock.json | 70 +++++++++++++++---------------------- 1 file changed, 29 insertions(+), 41 deletions(-) diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index ef55001..a673c6c 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -21,7 +21,7 @@ }, "backend": { "name": "@wford26/bettrack-backend", - "version": "0.3.7", + "version": "0.3.10", "license": "MIT", "dependencies": { "@prisma/client": "^5.22.0", @@ -187,7 +187,7 @@ }, "frontend": { "name": "@wford26/bettrack-frontend", - "version": "0.4.2", + "version": "0.4.4", "license": "MIT", "dependencies": { "@reduxjs/toolkit": "^2.5.0", @@ -983,6 +983,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -1000,6 +1001,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1017,6 +1019,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1034,6 +1037,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1051,6 +1055,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1068,6 +1073,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1085,6 +1091,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1102,6 +1109,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1119,6 +1127,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1136,6 +1145,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1153,6 +1163,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1170,6 +1181,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1187,6 +1199,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1204,6 +1217,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1221,6 +1235,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1238,6 +1253,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1255,6 +1271,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1272,6 +1289,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1289,6 +1307,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1306,6 +1325,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1323,6 +1343,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1340,6 +1361,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -1357,6 +1379,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -1374,6 +1397,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -1391,6 +1415,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -1408,6 +1433,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -2625,9 +2651,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2642,9 +2665,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2659,9 +2679,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2676,9 +2693,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2693,9 +2707,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2710,9 +2721,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2727,9 +2735,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2744,9 +2749,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2761,9 +2763,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2778,9 +2777,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2795,9 +2791,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2812,9 +2805,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2829,9 +2819,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6622,6 +6609,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, From 30932650158b1eb1b894f95a82067cd4db3f8804 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 05:59:31 +0000 Subject: [PATCH 3/7] Revert unintended lockfile change from setup step Agent-Logs-Url: https://github.com/WFord26/BetTrack/sessions/9c93eecb-77e8-4602-89c3-e8698f7bd609 Co-authored-by: WFord26 <117926366+WFord26@users.noreply.github.com> --- dashboard/package-lock.json | 70 ++++++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index a673c6c..ef55001 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -21,7 +21,7 @@ }, "backend": { "name": "@wford26/bettrack-backend", - "version": "0.3.10", + "version": "0.3.7", "license": "MIT", "dependencies": { "@prisma/client": "^5.22.0", @@ -187,7 +187,7 @@ }, "frontend": { "name": "@wford26/bettrack-frontend", - "version": "0.4.4", + "version": "0.4.2", "license": "MIT", "dependencies": { "@reduxjs/toolkit": "^2.5.0", @@ -983,7 +983,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -1001,7 +1000,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1019,7 +1017,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1037,7 +1034,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1055,7 +1051,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -1073,7 +1068,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -1091,7 +1085,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1109,7 +1102,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1127,7 +1119,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1145,7 +1136,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1163,7 +1153,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1181,7 +1170,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1199,7 +1187,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1217,7 +1204,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1235,7 +1221,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1253,7 +1238,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1271,7 +1255,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1289,7 +1272,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1307,7 +1289,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1325,7 +1306,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1343,7 +1323,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1361,7 +1340,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -1379,7 +1357,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -1397,7 +1374,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -1415,7 +1391,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -1433,7 +1408,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2651,6 +2625,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2665,6 +2642,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2679,6 +2659,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2693,6 +2676,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2707,6 +2693,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2721,6 +2710,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2735,6 +2727,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2749,6 +2744,9 @@ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2763,6 +2761,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2777,6 +2778,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2791,6 +2795,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2805,6 +2812,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2819,6 +2829,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6609,7 +6622,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, From 232db05d40cea72b848d7b4b9498ec95fac1c1b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 06:03:25 +0000 Subject: [PATCH 4/7] Implement Phase 2 market consensus metrics and outlier service Agent-Logs-Url: https://github.com/WFord26/BetTrack/sessions/9c93eecb-77e8-4602-89c3-e8698f7bd609 Co-authored-by: WFord26 <117926366+WFord26@users.noreply.github.com> --- dashboard/backend/CHANGELOG.md | 8 + .../migration.sql | 28 +++ dashboard/backend/prisma/schema.prisma | 15 +- .../src/services/market-consensus.service.ts | 220 +++++++++++++++++- .../tests/market-consensus.service.test.ts | 189 +++++++++++++++ 5 files changed, 450 insertions(+), 10 deletions(-) create mode 100644 dashboard/backend/prisma/migrations/20260514000001_enhance_market_consensus_phase2/migration.sql create mode 100644 dashboard/backend/tests/market-consensus.service.test.ts 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..feda369 --- /dev/null +++ b/dashboard/backend/prisma/migrations/20260514000001_enhance_market_consensus_phase2/migration.sql @@ -0,0 +1,28 @@ +-- 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" = ROUND("consensus_line")::INTEGER, + "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..c5cc5e6 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 }, @@ -187,13 +256,33 @@ export class MarketConsensusService { impliedProb: americanToImpliedProb(bestHomeOdds!.homePrice!), }; + const consensusPrice = consensusAmerican; + const outlierBookmakers = outliers; + const sharpBookWeight = this.calculateSharpBookWeight(odds.map((o) => o.bookmaker)); + return { gameId, marketType, consensusLine: consensusAmerican, + consensusPrice, + medianLine: consensusAmerican, + meanLine: impliedProbToAmerican(mean(homeProbs)), + modeLine: null, standardDeviation: parseFloat((reportedDev * 100).toFixed(2)), // as percentage points - outlierBookmakers: outliers, + range: parseFloat( + ( + Math.max(...homeProbs, ...awayProbs) - Math.min(...homeProbs, ...awayProbs) + ).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 +336,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 +415,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 +476,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 +494,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 +532,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 +649,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 +719,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..016401e --- /dev/null +++ b/dashboard/backend/tests/market-consensus.service.test.ts @@ -0,0 +1,189 @@ +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); + }); + }); + + 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' }), + ]) + ); + }); + }); +}); From b2306ec4fb47483a38cec226e6136be8f8045da4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 06:05:46 +0000 Subject: [PATCH 5/7] Refine h2h consensus metrics and migration backfill defaults Agent-Logs-Url: https://github.com/WFord26/BetTrack/sessions/9c93eecb-77e8-4602-89c3-e8698f7bd609 Co-authored-by: WFord26 <117926366+WFord26@users.noreply.github.com> --- .../migration.sql | 5 ++++- .../src/services/market-consensus.service.ts | 19 +++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) 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 index feda369..c434fb4 100644 --- a/dashboard/backend/prisma/migrations/20260514000001_enhance_market_consensus_phase2/migration.sql +++ b/dashboard/backend/prisma/migrations/20260514000001_enhance_market_consensus_phase2/migration.sql @@ -14,7 +14,10 @@ ALTER TABLE "market_consensus" -- Backfill new columns from existing values where possible UPDATE "market_consensus" SET - "consensus_price" = ROUND("consensus_line")::INTEGER, + "consensus_price" = CASE + WHEN ABS("consensus_line") >= 100 THEN ROUND("consensus_line")::INTEGER + ELSE -110 + END, "median_line" = "consensus_line", "mean_line" = "consensus_line", "range" = 0, diff --git a/dashboard/backend/src/services/market-consensus.service.ts b/dashboard/backend/src/services/market-consensus.service.ts index c5cc5e6..f814f6d 100644 --- a/dashboard/backend/src/services/market-consensus.service.ts +++ b/dashboard/backend/src/services/market-consensus.service.ts @@ -198,6 +198,9 @@ export class MarketConsensusService { const homeProbs = homeEntries.map((e) => e.value); const awayProbs = awayEntries.map((e) => e.value); + const homeAmericanLines = odds + .filter((o) => o.homePrice !== null) + .map((o) => o.homePrice as number); const consensusHomeProb = median(homeProbs); const consensusAwayProb = median(awayProbs); @@ -259,21 +262,21 @@ export class MarketConsensusService { const consensusPrice = consensusAmerican; const outlierBookmakers = outliers; const sharpBookWeight = this.calculateSharpBookWeight(odds.map((o) => o.bookmaker)); + const medianLine = homeAmericanLines.length ? median(homeAmericanLines) : consensusAmerican; + const meanLine = homeAmericanLines.length ? mean(homeAmericanLines) : consensusAmerican; + const modeLine = mode(homeAmericanLines); + const combinedProbabilityRange = [...homeProbs, ...awayProbs]; return { gameId, marketType, consensusLine: consensusAmerican, consensusPrice, - medianLine: consensusAmerican, - meanLine: impliedProbToAmerican(mean(homeProbs)), - modeLine: null, + medianLine: parseFloat(medianLine.toFixed(2)), + meanLine: parseFloat(meanLine.toFixed(2)), + modeLine: modeLine === null ? null : parseFloat(modeLine.toFixed(2)), standardDeviation: parseFloat((reportedDev * 100).toFixed(2)), // as percentage points - range: parseFloat( - ( - Math.max(...homeProbs, ...awayProbs) - Math.min(...homeProbs, ...awayProbs) - ).toFixed(2) - ), + range: parseFloat(valueRange(combinedProbabilityRange).toFixed(2)), interquartileRange: parseFloat( Math.max(interquartileRange(homeProbs), interquartileRange(awayProbs)).toFixed(2) ), From 225afc652cd41d0c0ccc76a26705c33c4111e3db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 06:08:02 +0000 Subject: [PATCH 6/7] Add h2h totals consensus coverage and tighten migration backfill Agent-Logs-Url: https://github.com/WFord26/BetTrack/sessions/9c93eecb-77e8-4602-89c3-e8698f7bd609 Co-authored-by: WFord26 <117926366+WFord26@users.noreply.github.com> --- .../migration.sql | 2 +- .../src/services/market-consensus.service.ts | 14 +++---- .../tests/market-consensus.service.test.ts | 38 +++++++++++++++++++ 3 files changed, 46 insertions(+), 8 deletions(-) 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 index c434fb4..9bc804a 100644 --- a/dashboard/backend/prisma/migrations/20260514000001_enhance_market_consensus_phase2/migration.sql +++ b/dashboard/backend/prisma/migrations/20260514000001_enhance_market_consensus_phase2/migration.sql @@ -15,7 +15,7 @@ ALTER TABLE "market_consensus" UPDATE "market_consensus" SET "consensus_price" = CASE - WHEN ABS("consensus_line") >= 100 THEN ROUND("consensus_line")::INTEGER + WHEN "market_type" = 'h2h' THEN ROUND("consensus_line")::INTEGER ELSE -110 END, "median_line" = "consensus_line", diff --git a/dashboard/backend/src/services/market-consensus.service.ts b/dashboard/backend/src/services/market-consensus.service.ts index f814f6d..f96dd7f 100644 --- a/dashboard/backend/src/services/market-consensus.service.ts +++ b/dashboard/backend/src/services/market-consensus.service.ts @@ -198,7 +198,7 @@ export class MarketConsensusService { const homeProbs = homeEntries.map((e) => e.value); const awayProbs = awayEntries.map((e) => e.value); - const homeAmericanLines = odds + const homeAmericanPrices = odds .filter((o) => o.homePrice !== null) .map((o) => o.homePrice as number); @@ -262,9 +262,9 @@ export class MarketConsensusService { const consensusPrice = consensusAmerican; const outlierBookmakers = outliers; const sharpBookWeight = this.calculateSharpBookWeight(odds.map((o) => o.bookmaker)); - const medianLine = homeAmericanLines.length ? median(homeAmericanLines) : consensusAmerican; - const meanLine = homeAmericanLines.length ? mean(homeAmericanLines) : consensusAmerican; - const modeLine = mode(homeAmericanLines); + const medianPrice = homeAmericanPrices.length ? median(homeAmericanPrices) : consensusAmerican; + const meanPrice = homeAmericanPrices.length ? mean(homeAmericanPrices) : consensusAmerican; + const modePrice = mode(homeAmericanPrices); const combinedProbabilityRange = [...homeProbs, ...awayProbs]; return { @@ -272,9 +272,9 @@ export class MarketConsensusService { marketType, consensusLine: consensusAmerican, consensusPrice, - medianLine: parseFloat(medianLine.toFixed(2)), - meanLine: parseFloat(meanLine.toFixed(2)), - modeLine: modeLine === null ? null : parseFloat(modeLine.toFixed(2)), + 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 range: parseFloat(valueRange(combinedProbabilityRange).toFixed(2)), interquartileRange: parseFloat( diff --git a/dashboard/backend/tests/market-consensus.service.test.ts b/dashboard/backend/tests/market-consensus.service.test.ts index 016401e..62786ab 100644 --- a/dashboard/backend/tests/market-consensus.service.test.ts +++ b/dashboard/backend/tests/market-consensus.service.test.ts @@ -99,6 +99,44 @@ describe('MarketConsensusService', () => { ); 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!.consensusPrice).toEqual(result!.consensusLine); + 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', () => { From 17d0d0d0bceafc60cba3bcadf82b7cc4342b2da7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 06:09:56 +0000 Subject: [PATCH 7/7] Finalize consensus backfill defaults and strengthen h2h test assertions Agent-Logs-Url: https://github.com/WFord26/BetTrack/sessions/9c93eecb-77e8-4602-89c3-e8698f7bd609 Co-authored-by: WFord26 <117926366+WFord26@users.noreply.github.com> --- .../migration.sql | 2 +- dashboard/backend/tests/market-consensus.service.test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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 index 9bc804a..ffd6579 100644 --- a/dashboard/backend/prisma/migrations/20260514000001_enhance_market_consensus_phase2/migration.sql +++ b/dashboard/backend/prisma/migrations/20260514000001_enhance_market_consensus_phase2/migration.sql @@ -16,7 +16,7 @@ UPDATE "market_consensus" SET "consensus_price" = CASE WHEN "market_type" = 'h2h' THEN ROUND("consensus_line")::INTEGER - ELSE -110 + ELSE 0 END, "median_line" = "consensus_line", "mean_line" = "consensus_line", diff --git a/dashboard/backend/tests/market-consensus.service.test.ts b/dashboard/backend/tests/market-consensus.service.test.ts index 62786ab..9993950 100644 --- a/dashboard/backend/tests/market-consensus.service.test.ts +++ b/dashboard/backend/tests/market-consensus.service.test.ts @@ -111,7 +111,8 @@ describe('MarketConsensusService', () => { expect(result).not.toBeNull(); expect(result!.marketType).toBe('h2h'); - expect(result!.consensusPrice).toEqual(result!.consensusLine); + expect(result!.consensusLine).toBe(-115); + expect(result!.consensusPrice).toBe(-115); expect(result!.medianLine).toBe(-115); expect(result!.meanLine).toBe(-115); expect(result!.modeLine).toBeNull();