gg
Deploy Iddaai Backend / build-and-deploy (push) Successful in 10m48s

This commit is contained in:
2026-06-23 03:06:33 +03:00
parent 6269ede4ad
commit 4d40b55fb9
6 changed files with 261 additions and 0 deletions
+2
View File
@@ -46,6 +46,7 @@ import { SocialPosterModule } from "./modules/social-poster/social-poster.module
// Sports Domain Modules // Sports Domain Modules
import { MatchesModule } from "./modules/matches/matches.module"; import { MatchesModule } from "./modules/matches/matches.module";
import { PredictionsModule } from "./modules/predictions/predictions.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 { LeaguesModule } from "./modules/leagues/leagues.module";
import { AnalysisModule } from "./modules/analysis/analysis.module"; import { AnalysisModule } from "./modules/analysis/analysis.module";
import { CouponsModule } from "./modules/coupons/coupons.module"; import { CouponsModule } from "./modules/coupons/coupons.module";
@@ -200,6 +201,7 @@ const historicalFeederMode = process.env.FEEDER_MODE === "historical";
// Sports Domain Modules // Sports Domain Modules
MatchesModule, MatchesModule,
PredictionsModule, PredictionsModule,
ValueBoardModule,
LeaguesModule, LeaguesModule,
AnalysisModule, AnalysisModule,
CouponsModule, CouponsModule,
+89
View File
@@ -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<string, Record<string, number | string>>;
/** 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<string, number | string> | 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<string, number | string> | 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<string, number | string> | 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));
}
@@ -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");
}
}
@@ -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 {}
@@ -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<ValueBoardMatch[]> {
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;
}
}
@@ -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
}