This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user