From 4d40b55fb9321b8de479b4c28b0fe2fe18352261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahri=20Can=20Se=C3=A7er?= Date: Tue, 23 Jun 2026 03:06:33 +0300 Subject: [PATCH] gg --- src/app.module.ts | 2 + src/modules/value-board/devig.util.ts | 89 +++++++++++++++++ .../value-board/value-board.controller.ts | 27 ++++++ src/modules/value-board/value-board.module.ts | 12 +++ .../value-board/value-board.service.ts | 96 +++++++++++++++++++ src/modules/value-board/value-board.types.ts | 35 +++++++ 6 files changed, 261 insertions(+) create mode 100644 src/modules/value-board/devig.util.ts create mode 100644 src/modules/value-board/value-board.controller.ts create mode 100644 src/modules/value-board/value-board.module.ts create mode 100644 src/modules/value-board/value-board.service.ts create mode 100644 src/modules/value-board/value-board.types.ts diff --git a/src/app.module.ts b/src/app.module.ts index 53b24ed..87a7b97 100755 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -46,6 +46,7 @@ import { SocialPosterModule } from "./modules/social-poster/social-poster.module // Sports Domain Modules import { MatchesModule } from "./modules/matches/matches.module"; import { PredictionsModule } from "./modules/predictions/predictions.module"; +import { ValueBoardModule } from "./modules/value-board/value-board.module"; import { LeaguesModule } from "./modules/leagues/leagues.module"; import { AnalysisModule } from "./modules/analysis/analysis.module"; import { CouponsModule } from "./modules/coupons/coupons.module"; @@ -200,6 +201,7 @@ const historicalFeederMode = process.env.FEEDER_MODE === "historical"; // Sports Domain Modules MatchesModule, PredictionsModule, + ValueBoardModule, LeaguesModule, AnalysisModule, CouponsModule, diff --git a/src/modules/value-board/devig.util.ts b/src/modules/value-board/devig.util.ts new file mode 100644 index 0000000..854db44 --- /dev/null +++ b/src/modules/value-board/devig.util.ts @@ -0,0 +1,89 @@ +/** + * Pure de-vig helpers for the Value Board (no I/O, unit-testable). + * + * The bookmaker's decimal odds encode probability + margin. De-vigging removes + * the margin: p_i = (1/odds_i) / Σ(1/odds_j). The result is the market's + * "fair" probability — empirically calibrated to <2% ECE (see DATA_FINDINGS.md). + * We NEVER fabricate numbers: if a market leg is missing/placeholder, return null. + */ + +export type OddsBlob = Record>; + +/** Coerce a raw odds value (number or numeric string) to a finite number > 1.01. */ +function toOdd(v: number | string | undefined): number | null { + if (v === undefined || v === null) return null; + const n = typeof v === "number" ? v : parseFloat(String(v)); + return Number.isFinite(n) && n > 1.01 ? n : null; +} + +/** Vig-removed probabilities for a group of selections, in the given key order. */ +export function devig( + market: Record | undefined, + keys: string[], +): number[] | null { + if (!market) return null; + const odds = keys.map((k) => toOdd(market[k])); + if (odds.some((o) => o === null)) return null; + const inv = (odds as number[]).map((o) => 1 / o); + const sum = inv.reduce((a, b) => a + b, 0); + if (sum <= 0) return null; + return inv.map((x) => x / sum); +} + +/** Bookmaker margin (overround) for a market, as a fraction (e.g. 0.19 = 19%). */ +export function overround( + market: Record | undefined, + keys: string[], +): number | null { + if (!market) return null; + const odds = keys.map((k) => toOdd(market[k])); + if (odds.some((o) => o === null)) return null; + return (odds as number[]).reduce((a, o) => a + 1 / o, 0) - 1; +} + +export interface ScoreDistribution { + topScores: { score: string; prob: number }[]; + expectedGoals: number | null; +} + +/** + * De-vig the "Maç Skoru" (correct-score) market into a calibrated score + * distribution. Normalises over the EXPLICIT scorelines (ignoring the "Diğer" + * bucket for the displayed picks) and derives expected total goals from them. + */ +export function scoreDistribution( + market: Record | undefined, + topN = 3, +): ScoreDistribution { + const empty: ScoreDistribution = { topScores: [], expectedGoals: null }; + if (!market) return empty; + const explicit: { score: string; inv: number; goals: number }[] = []; + for (const [score, raw] of Object.entries(market)) { + const m = /^(\d+)-(\d+)$/.exec(score); + const odd = toOdd(raw); + if (!m || odd === null) continue; // skip "Diğer" and bad legs + explicit.push({ + score, + inv: 1 / odd, + goals: parseInt(m[1], 10) + parseInt(m[2], 10), + }); + } + if (explicit.length === 0) return empty; + const sum = explicit.reduce((a, e) => a + e.inv, 0); + if (sum <= 0) return empty; + const withProb = explicit + .map((e) => ({ score: e.score, prob: e.inv / sum, goals: e.goals })) + .sort((a, b) => b.prob - a.prob); + const expectedGoals = withProb.reduce((a, e) => a + e.prob * e.goals, 0); + return { + topScores: withProb + .slice(0, topN) + .map((e) => ({ score: e.score, prob: e.prob })), + expectedGoals: Math.round(expectedGoals * 100) / 100, + }; +} + +export function isCupLeague(name: string | null | undefined): boolean { + const n = (name || "").toLowerCase(); + return ["kupa", "cup", "trophy"].some((w) => n.includes(w)); +} diff --git a/src/modules/value-board/value-board.controller.ts b/src/modules/value-board/value-board.controller.ts new file mode 100644 index 0000000..c39f21e --- /dev/null +++ b/src/modules/value-board/value-board.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Query } from "@nestjs/common"; +import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { Public } from "../../common/decorators"; +import { ValueBoardService } from "./value-board.service"; + +@ApiTags("Value Board") +@Controller("value-board") +export class ValueBoardController { + constructor(private readonly valueBoardService: ValueBoardService) {} + + /** + * GET /value-board + * Upcoming matches with de-vigged (calibrated) probabilities — the honest, + * transparent product board. No fabricated value; the bookmaker margin is + * disclosed per match. + */ + @Public() + @Get() + @ApiOperation({ + summary: "Upcoming matches with calibrated (de-vigged) probabilities", + }) + @ApiQuery({ name: "sport", required: false, type: String }) + @ApiResponse({ status: 200, description: "Value board for upcoming matches" }) + async getBoard(@Query("sport") sport?: string) { + return this.valueBoardService.getBoard(sport || "football"); + } +} diff --git a/src/modules/value-board/value-board.module.ts b/src/modules/value-board/value-board.module.ts new file mode 100644 index 0000000..bfe2e8b --- /dev/null +++ b/src/modules/value-board/value-board.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { DatabaseModule } from "../../database/database.module"; +import { ValueBoardController } from "./value-board.controller"; +import { ValueBoardService } from "./value-board.service"; + +@Module({ + imports: [DatabaseModule], + controllers: [ValueBoardController], + providers: [ValueBoardService], + exports: [ValueBoardService], +}) +export class ValueBoardModule {} diff --git a/src/modules/value-board/value-board.service.ts b/src/modules/value-board/value-board.service.ts new file mode 100644 index 0000000..75d0ad6 --- /dev/null +++ b/src/modules/value-board/value-board.service.ts @@ -0,0 +1,96 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { PrismaService } from "../../database/prisma.service"; +import { + devig, + overround, + scoreDistribution, + isCupLeague, + OddsBlob, +} from "./devig.util"; +import { ValueBoardMatch } from "./value-board.types"; + +// Turkish market keys as stored in live_matches.odds +const K = { + MS: "Maç Sonucu", + OU25: "2,5 Alt/Üst", + BTTS: "Karşılıklı Gol", + HT: "1. Yarı Sonucu", + HT15: "1. Yarı 1,5 Alt/Üst", + SCORE: "Maç Skoru", +} as const; + +@Injectable() +export class ValueBoardService { + private readonly logger = new Logger(ValueBoardService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * Upcoming matches with de-vigged (calibrated) probabilities for the main + * markets, the market's correct-score distribution, half-time markets and a + * derived expected-goals figure. Honest baseline — no fabricated value. + */ + async getBoard( + sport = "football", + limit = 60, + ): Promise { + const now = BigInt(Date.now()); + const rows = await this.prisma.liveMatch.findMany({ + where: { + sport, + status: "NS", + mstUtc: { gt: now }, + }, + include: { + league: { include: { country: true } }, + homeTeam: true, + awayTeam: true, + }, + orderBy: { mstUtc: "asc" }, + take: limit, + }); + + const board: ValueBoardMatch[] = []; + for (const m of rows) { + if (!m.odds || typeof m.odds !== "object" || Array.isArray(m.odds)) { + continue; + } + const odds = m.odds as unknown as OddsBlob; + + const ms = devig(odds[K.MS], ["1", "X", "2"]); + if (!ms) continue; // no real MS price → don't show (never fabricate) + + const ou = devig(odds[K.OU25], ["Alt", "Üst"]); + const bt = devig(odds[K.BTTS], ["Var", "Yok"]); + const ht = devig(odds[K.HT], ["1", "X", "2"]); + const ht15 = devig(odds[K.HT15], ["Alt", "Üst"]); + const score = scoreDistribution(odds[K.SCORE]); + const vig = overround(odds[K.MS], ["1", "X", "2"]); + + const leanIdx = ms.indexOf(Math.max(...ms)); + const leanKey = (["1", "X", "2"] as const)[leanIdx]; + + board.push({ + id: m.id, + matchName: + m.matchName || `${m.homeTeam?.name ?? "?"} vs ${m.awayTeam?.name ?? "?"}`, + homeTeam: m.homeTeam?.name ?? "?", + awayTeam: m.awayTeam?.name ?? "?", + league: m.league?.name ?? "—", + country: m.league?.country?.name ?? undefined, + kickoff: Number(m.mstUtc ?? 0), + isCup: isCupLeague(m.league?.name), + vigPct: vig === null ? null : Math.round(vig * 1000) / 10, + ms: { home: ms[0], draw: ms[1], away: ms[2] }, + ou25: ou ? { over: ou[1], under: ou[0] } : null, + btts: bt ? { yes: bt[0], no: bt[1] } : null, + htResult: ht ? { home: ht[0], draw: ht[1], away: ht[2] } : null, + htOu15: ht15 ? { over: ht15[1], under: ht15[0] } : null, + topScores: score.topScores, + expectedGoals: score.expectedGoals, + lean: { key: leanKey, prob: ms[leanIdx] }, + }); + } + return board; + } +} diff --git a/src/modules/value-board/value-board.types.ts b/src/modules/value-board/value-board.types.ts new file mode 100644 index 0000000..16c499a --- /dev/null +++ b/src/modules/value-board/value-board.types.ts @@ -0,0 +1,35 @@ +export interface ThreeWayProbs { + home: number; + draw: number; + away: number; +} + +export interface TwoWayProbs { + over: number; + under: number; +} + +export interface ScoreProb { + score: string; + prob: number; +} + +export interface ValueBoardMatch { + id: string; + matchName: string; + homeTeam: string; + awayTeam: string; + league: string; + country?: string; + kickoff: number; // mst_utc (epoch ms) + isCup: boolean; + vigPct: number | null; // bookmaker margin on the MS market, % + ms: ThreeWayProbs | null; // calibrated Maç Sonucu + ou25: TwoWayProbs | null; // 2.5 Alt/Üst + btts: { yes: number; no: number } | null; // Karşılıklı Gol + htResult: ThreeWayProbs | null; // 1. Yarı Sonucu + htOu15: TwoWayProbs | null; // 1. Yarı 1,5 Alt/Üst + topScores: ScoreProb[]; // calibrated Maç Skoru (top 3) + expectedGoals: number | null; // derived from the score distribution + lean: { key: "1" | "X" | "2"; prob: number } | null; // most likely MS pick +}