main
Deploy Iddaai Backend / build-and-deploy (push) Successful in 29s

This commit is contained in:
2026-05-04 18:00:40 +03:00
parent 145a8b336b
commit 27e96da31d
22 changed files with 571 additions and 169 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/src/i18n ./dist/i18n COPY --from=builder /app/src/i18n ./dist/i18n
# Copy league filter config files (critical: without these, feeder stores ALL matches) # Copy league filter config files (critical: without these, feeder stores ALL matches)
COPY top_leagues.json basketball_top_leagues.json ./ COPY qualified_leagues.json top_leagues.json basketball_top_leagues.json ./
# Set environment # Set environment
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -964,13 +964,13 @@ class SingleMatchOrchestrator:
return None return None
# ── Pre-Match Simulation Mode ──────────────────────────── # ── Pre-Match Simulation Mode ────────────────────────────
# For finished (FT/postGame) matches, strip live scores so the # Force all matches (live and finished) into pre-match state so the
# entire pipeline treats them as if they haven't kicked off yet. # engine purely predicts based on pre-match odds and context, ignoring
# _is_live_match already returns False for FT, but this adds # current live scores and preventing live state penalties.
# defense-in-depth against any code path that reads scores directly.
_status_upper = str(data.status or "").upper() _status_upper = str(data.status or "").upper()
_state_upper = str(data.state or "").upper() if _status_upper not in {"NS", "POSTPONED", "CANC", "ABD"}:
if _status_upper in {"FT", "FINISHED"} or _state_upper in {"POSTGAME", "POST_GAME"}: data.status = "NS"
data.state = "preGame"
data.current_score_home = None data.current_score_home = None
data.current_score_away = None data.current_score_away = None
+267
View File
@@ -0,0 +1,267 @@
[
"3iwftmprsznl6yribr11a8l9m",
"cegl2ivkc25blcatxp4jmk1ec",
"1zp1du9n4rj36p1ss9zbxtqfb",
"bockl24qpr7ryjl8b6obukga",
"byu00jvt1j6csyv4y1lkt2fm2",
"degxm4y6gmvp011ccyrev6z5p",
"c7b8o53flg36wbuevfzy3lb10",
"7ntvbsyq31jnzoqoa8850b9b8",
"581t4mywybx21wcpmpykhyzr3",
"3frp1zxrqulrlrnk503n6l4l",
"287tckirbfj9nb8ar2k9r60vn",
"bgen5kjer2ytfp7lo9949t72g",
"ac112osli9fvox1epcg4ld3t6",
"3is4bkgf3loxv9qfg3hm8zfqb",
"c1d9p6b2e9zr5tqlzx3ktjplg",
"5zr0b05eyx25km7z1k03ca9jx",
"5z8v4mj6cjs9ex6hdrpourjzh",
"scf9p4y91yjvqvg5jndxzhxj",
"3p81ltz6845appgkbgkzxueii",
"b5udgm9vakjqz8dcmy5b2g0xt",
"b1rveez5u792gess9w3e7v5le",
"2ty8ihceabty8yddmu31iuuej",
"8ey0ww2zsosdmwr8ehsorh6t7",
"2nttcoriwf5co73vmz1vr8frm",
"1r097lpxe0xn03ihb7wi98kao",
"2kwbbcootiqqgmrzs6o5inle5",
"907l7wtxdvugdo9i2249wcmr0",
"8o5tv5viv4hy1qg9jp94k7ayb",
"4nidzmunvpvxk1ir9b6m8mpay",
"dkarmrybx9vx10rg7cywumth0",
"a9vrdkelbgif0gtu3wxsr75xo",
"4w7x0s5gfs5abasphlha5de8k",
"8dn0w8zh7nbn2i904603eigwf",
"1gwajyt0pk2jm5fx5mu36v114",
"2o9svokc5s7diish3ycrzk7jm",
"7hl0svs2hg225i2zud0g3xzp2",
"89ovpy1rarewwzqvi30bfdr8b",
"2hsidwomhjsaaytdy9u5niyi4",
"34pl8szyvrbwcmfkuocjm3r6t",
"8r98daokeuzsamu5fmjtblqx5",
"akmkihra9ruad09ljapsm84b3",
"722fdbecxzcq9788l6jqclzlw",
"663a54fmymndjeev47qm7d3nf",
"4zwgbb66rif2spcoeeol2motx",
"9chuiarcjofld1dkj9kysehmb",
"5y0z0l2epprzbscvzsgldw8vu",
"2wolc27r8z03itcvwp43e38c5",
"alpfd99yd3lfv7bhjo0biuq7b",
"ea0h6cf3bhl698hkxhpulh2zz",
"8sdpk4aerruf515yh76ezo7vi",
"6by3h89i2eykc341oz7lv1ddd",
"7r1f93t6ddrsa5n8v1nq6qlzm",
"8yi6ejjd1zudcqtbn07haahg6",
"ein4fkggto3pdh5msp8huafiq",
"b60nisd3qn427jm0hrg9kvmab",
"1qd0wvt30rlswa4g6nu4na660",
"b73zounsynk9d3u1p9nvpu7i2",
"civf31q1inxohs4a03y8reetf",
"bu1l7ckihyr0errxw61p0m05",
"a7247po5qs29o3zsfmt222ydu",
"6lwpjhktjhl9g7x2w7njmzva6",
"4c1nfi2j1m731hcay25fcgndq",
"3ww12jab49q8q8mk9avdwjqgk",
"8y29fg2s85ppcb8uugm5ee8s4",
"82jkgccg7phfjpd0mltdl3pat",
"46b141eaqq9q7o4gz5gtdpikk",
"482ofyysbdbeoxauk19yg7tdt",
"4oogyu6o156iphvdvphwpck10",
"2y8bntiif3a9y6gtmauv30gt",
"e21cf135btr8t3upw0vl6n6x0",
"c0yqkbilbbg70ij2473xymmqv",
"5dycj9wdhxh3n33qubw18ohlk",
"1eruend45vd20g9hbrpiggs5u",
"e1kxdivp5g4cpldgpwvnzl1vv",
"ddyrh5latwfhesgfh4w401n92",
"af79lqrc0ntom74zq13ccjslo",
"3ab1uwtoyjopdj1y1fynyy9jg",
"c0r21rtokgnbtc0o2rldjmkxu",
"e0lck99w8meo9qoalfrxgo33o",
"yv73ms6v1995b5wny16jcfi3",
"5aw6uyw4pz2bpj24t5z8aacim",
"75i269i1ak43magshljadydrh",
"8k1xcsyvxapl4jlsluh3eomre",
"jznihqxle06xych9ygwiwnsa",
"6wubmo7di3kdpflluf6s8c7vs",
"7cwemnr3vi40znjq451zxkus6",
"6ifaeunfdelecgticvxanikzu",
"913mb508il6jzwtlj28fl892h",
"29actv1ohj8r10kd9hu0jnb0n",
"3btdfgw79qiz3jmyfudovtbu2",
"5cwsxtx37les6m10xj71htkgf",
"9nbpdi9q3ywcm4q0j5u0ekwcq",
"dm5ka0os1e3dxcp3vh05kmp33",
"beqqnubkv05mamuwvimeum015",
"57nu0wygurzkp6fuy5hhrtaa2",
"du6jsenbjql5e8f3yk880ox4g",
"cesdwwnxbc5fmajgroc0hqzy2",
"3w1hkk9k9gr8fwssyn4icvdfo",
"65ggsqdi6drpa4m8y3gkll25k",
"4yzidekywejmxxp77gqmdgopg",
"avs3xposm3t9x1x2vzsoxzcbu",
"75434tz9rc14xkkvudex742ui",
"aho73e5udydy96iun3tkzdzsi",
"4qehj8hfxmy6o2ohp4fxinnzo",
"ae1wva3zrzcp2zd15gpvsntg6",
"4d5d3sf6805n5u6jdoa0hdlog",
"3l29w00m506ex93t5bbh9cg2a",
"zs18qaehvhg3w1208874zvfa",
"4mbfidy8zum5u0aqjqo0vuqs2",
"8v97rcbthsxmzqk4ufxws9mug",
"c76z5d6j7dpi1e79tm8fpm39z",
"47s2kt0e8m444ftqvsrqa3bvq",
"9ikchyu9fb8bvx0s673jofj6s",
"6ihotpaocgiovlxw18e9r9prx",
"32n2r9bl6x90psj0wa7bfs6vq",
"zilopfej2h0n3vpan5tcynpo",
"7nmz249q89qg5ezcvzlheljji",
"ajxs0e0g6ryg5ol8qvw3evrcz",
"477yyajzheg2z8u7uick0e13e",
"8t2o4huu2e48ij23dxnl9w5qx",
"1wwro3z1eb3fl601dju6inlc6",
"4yngyfinzd6bb1k7anqtqs0wt",
"1b70m6qtxrp75b4vtk8hxh8c3",
"7af85xa75vozt2l4hzi6ryts7",
"117yqo02rs8dykkxpm274w3bd",
"725gd73msyt08xm76v7gkxj7u",
"f4jc2cc5nq7flaoptpi5ua4k4",
"xwnjb1az11zffwty3m6vn8y6",
"dr2xk7muj8aqcjdz2b3li1c0k",
"1mpjd0vbxbtu9zw89yj09xk3z",
"3428tckxcirwwh3o3jgc1m8ji",
"6sxm2iln2w45ux498pty9miw8",
"6321dlqv4ziuwqte4xpohijtw",
"5c96g1zm7vo5ons9c42uy2w3r",
"ili150pwfuf39f7yfdch9lhw",
"7swf4kpu3v38i2it4h94c5s9k",
"iu1vi94p4p28oozl1h9bvplr",
"5k620c7y6dlbmcm88dt3eb7t",
"f39uq10c8xhg5e6rwwcf6lhgc",
"6lkj3o21cr4g7bql6tb3fk222",
"9ynnnx1qmkizq1o3qr3v0nsuk",
"8usjlmziv3p2re0r2wwzezki9",
"4zwjlzdszduqmxzusysvzymms",
"7mxwwunvot2pi69pj1yr1kh8i",
"5taraea6mqjjldg9zxswo825y",
"9fuwphq8kvugrlc3ckm7k8wes",
"dvstmwnvw0mt5p38twn9yttyb",
"2xg0qvif1rh7du6wmk2eleku3",
"8x3sbh85gc8qir50utw39jl04",
"59tpnfrwnvhnhzmnvfyug68hj",
"1fedahp0rws09tj451onten8r",
"esrunz7rjb0td98mx9e5cedoy",
"2hj3286pqov1g1g59k2t2qcgm",
"55hcphd1ccc6eai1ms77460on",
"40yjcbx2sq6oq736iqqqczwt1",
"eog6knrkfei68si736fpquyzc",
"f47f3717z2vtpxfxrpdd4jl1x",
"3oa9e03e7w9nr8kqwqc3tlqz9",
"apdwh753fupxheygs8seahh7x",
"486rhdgz7yc0sygziht7hje65",
"erpufio3qaujd9gkszcqvb0bf",
"cu0rmpyff5692eo06ltddjo8a",
"eg6s9f1jj7jr6stmbosn0g6c8",
"9p3nnxhdjahfn8qswpzy8oyc3",
"cse5oqqt2pzfcy8uz6yz3tkbj",
"cfesxhzb83yl8b779uv3revz1",
"4rls982p5uzil6x30mhyhv9f3",
"eitf7hulqfv1clb7toewkil24",
"byhmntnl1b4lxw0zz21im3zkd",
"gfskxsdituog2kqp9yiu7bzi",
"ejunkmfhjz9weugd2bqrkgobb",
"bdtat25m14jy85y484z3e6lf",
"ax1yf4nlzqpcji4j8epdgx3zl",
"1j4ehtrbry9depwt6oghaq3lu",
"xaouuwuk8qyhv1libkeexwjh",
"1q4ab2bpg5e8jl1g2udnakrju",
"81txfenlgw75nq3u2nfdkj92o",
"19q13y6ruzo0o84ipblcuouzs",
"3n9mk5b2mxmq831wfmv6pu86i",
"3n5046abeu3x482ds3jwda238",
"2aso72utuctat2ecs6nahjss6",
"2bmwykmdlcc2u1c40ytoc39vy",
"bx57cmq1edfq53ckfk791supi",
"bly7ema5au6j40i0grhl0pnub",
"er5745q30wnr8jv9nr863omzg",
"by5nibd18nkt40t0j8a0j5yzx",
"1ncmha8yglhyyhg6gtaujymqf",
"agpweohvn9tugnyl6ry4rhivp",
"8ztsv3pzrsyq5w1r3a0nfk1y5",
"4davonpqws4a4ejl1awu98zdg",
"6vq8j5p3av14nr3iuyi4okhjt",
"bbajzna018c79opa1kl5kmkqo",
"eu2g5j36zzxiazpd729osx0wm",
"595nsvo7ykvoe690b1e4u5n56",
"1gxlzw2ezkyeykhcaa5x8ozkk",
"2z7257m7hj58zuxcjrsg4erzc",
"392slbmf1kdqlr6sd1ckt71rs",
"6g8hw3acenrw828la7gwx4mvs",
"d9eaigzyfnfiraqc3ius757tl",
"3aa4mumjl6zyetg6o9hwd5hhx",
"6hlw7rhrpe9garwmfoxu4lebc",
"e6vzdkz6l236s9p288mharefy",
"dvtl8sf1262pd2aqgu641qa7u",
"5pq4dbinkmt8ujoepyqzih7iw",
"6qitd9h242qkvjenaytfdnsf2",
"cbdbziaqczfuyuwqsylqi26zd",
"3ymqchdzk8tt6lfphf26xfvh0",
"2rdrisk4vlglfjxwu0precyqd",
"1cnx2c8g3hhp8ssxnwwli0mjb",
"65q4uwm6ol1rkf5dp89m8omny",
"8kt53kt3mfo29gldhkl05u25b",
"5jd0k2txwnq69frs79eulba8j",
"8x62utr2uti3i7kk14isbnip6",
"b3ufcd24wfnnd5j98ped6irfu",
"61fzfjogstjuukzcehighq7mu",
"50ap4sua1xyut3mpu7ehesp63",
"6694fff47wqxl10lrd9tb91f8",
"macko16888165594668885588",
"3e40pestup9xzagsu2o6c0i8u",
"9oqeqyj7swpnl86ytafjwavvo",
"1qt9bfl6dhydf4tpano6n1p7s",
"29lni33vxqrl1tqhadrnfid6t",
"2db0aw1duj2my9l5iey5gm6nq",
"1vyghvhuy6abu4htoemdi79bd",
"4vksk0d2q4c5w0itdl52lzek6",
"193wqkyb0v5jnsblhvd2ocmyo",
"a3egqgf45jqft6y0uoyvw3mbj",
"5liafywveaf56s2nod8hg9nca",
"3a0j0giz3c3ajw9h59evv7lqt",
"2mdmx668tyhy4u4z9zszwjv5v",
"19mr0xdp7li6nkz87oxh53xed",
"8u5w0g8jimye1cu5albkcb3qs",
"2kuyfkulm5lsgjxynrgh3vz70",
"8cit3whr514nnd4zkaovsnqn",
"9mr92dlx7ryaxhi07sgt90ish",
"1dajh9qrda3enawmlt7ogt05w",
"10x5pvhifwo4y7hs3fz9hf245",
"dc4k1xh2984zbypbnunk7ncic",
"e6rl4hongahbihxd3tpudespd",
"2r1hqz453bn9ljzt53kdr2lwb",
"86wrztni4x8tnvq9cr1cetvfu",
"5em08hhvd7komnfdsb1yagpas",
"326jpj7749ojwqhu3ap27zl77",
"bqvy41un7sf86rbse9tv810x7",
"93i7thp7zi0ympyt6l8aa1r2i",
"ahl3vljaignq9ebaos4uqkrvo",
"68zplepppndhl8bfdvgy9vgu1",
"df1o8phtfy4dwhv6n7mmeedvw",
"cj30195079sdep2imeyt7y47p",
"3z6xfyd3ovi5x09orlo4rmskx",
"1n990e5dpi9xwruwf6uslknkq",
"etta63x1t7tnkn4jheisjwk4p",
"2xv6qkye2rsnwram454x8i8f1",
"8c93rclta164ypkno054nkfyt",
"89v3ukjpui1gashsz3i1vphfa",
"8tddm56zbasf57jkkay4kbf11",
"dcgbs1vkp9y3y31li7s95i51f",
"dlf90uty1axvtr1vn2aaw9vqh",
"9gvvndi7vk9fzvpe65pv5x2ir",
"7siumtnmgqfap6nalpu8xcwb6",
"7zsbjmlmhzn0y7923lw4zquud",
"8dxsd8xnjm9n1ogo37yomgl3p",
"arrfx02rdlstdfwdyikwqtwgl",
"afp674ll89oqsbbrqt17xfxlh",
"22euhl6zy56cp651ipq99rooq"
]
+6 -4
View File
@@ -70,8 +70,7 @@ export class AiEngineClient {
this.maxRetries = options.maxRetries ?? 2; this.maxRetries = options.maxRetries ?? 2;
this.retryDelayMs = options.retryDelayMs ?? 750; this.retryDelayMs = options.retryDelayMs ?? 750;
this.circuitBreakerThreshold = options.circuitBreakerThreshold ?? 3; this.circuitBreakerThreshold = options.circuitBreakerThreshold ?? 3;
this.circuitBreakerCooldownMs = this.circuitBreakerCooldownMs = options.circuitBreakerCooldownMs ?? 30000;
options.circuitBreakerCooldownMs ?? 30000;
this.axiosClient = axios.create({ this.axiosClient = axios.create({
baseURL: options.baseUrl, baseURL: options.baseUrl,
@@ -113,7 +112,9 @@ export class AiEngineClient {
}; };
} }
private async request<T>(config: AiEngineRequestConfig): Promise<AxiosResponse<T>> { private async request<T>(
config: AiEngineRequestConfig,
): Promise<AxiosResponse<T>> {
this.ensureCircuitAvailable(); this.ensureCircuitAvailable();
const retries = this.resolveRetryCount(config); const retries = this.resolveRetryCount(config);
@@ -162,7 +163,8 @@ export class AiEngineClient {
} }
const remainingCooldown = const remainingCooldown =
this.circuitBreakerCooldownMs - (Date.now() - (this.circuitOpenedAt ?? 0)); this.circuitBreakerCooldownMs -
(Date.now() - (this.circuitOpenedAt ?? 0));
if (remainingCooldown > 0) { if (remainingCooldown > 0) {
throw new AiEngineRequestError("AI engine circuit breaker is open", { throw new AiEngineRequestError("AI engine circuit breaker is open", {
+2
View File
@@ -81,6 +81,7 @@ export const LIVE_STATUS_VALUES_FOR_DB = [
"Playing", "Playing",
"Half Time", "Half Time",
"liveGame", "liveGame",
"minutes",
]; ];
export const LIVE_STATE_VALUES_FOR_DB = [ export const LIVE_STATE_VALUES_FOR_DB = [
@@ -109,6 +110,7 @@ export const FINISHED_STATUS_VALUES_FOR_DB = [
"postGame", "postGame",
"posted", "posted",
"Posted", "Posted",
"state",
]; ];
export const FINISHED_STATE_VALUES_FOR_DB = [ export const FINISHED_STATE_VALUES_FOR_DB = [
+1 -4
View File
@@ -14,10 +14,7 @@ function extractDateParts(date: Date, timeZone: string) {
return { year, month, day }; return { year, month, day };
} }
export function getDateStringInTimeZone( export function getDateStringInTimeZone(date: Date, timeZone: string): string {
date: Date,
timeZone: string,
): string {
const { year, month, day } = extractDateParts(date, timeZone); const { year, month, day } = extractDateParts(date, timeZone);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
} }
+25 -5
View File
@@ -18,7 +18,12 @@ import {
CACHE_MANAGER, CACHE_MANAGER,
} from "@nestjs/cache-manager"; } from "@nestjs/cache-manager";
import * as cacheManager from "cache-manager"; import * as cacheManager from "cache-manager";
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse as SwaggerResponse } from "@nestjs/swagger"; import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiResponse as SwaggerResponse,
} from "@nestjs/swagger";
import { Roles } from "../../common/decorators"; import { Roles } from "../../common/decorators";
import { PrismaService } from "../../database/prisma.service"; import { PrismaService } from "../../database/prisma.service";
import { PaginationDto } from "../../common/dto/pagination.dto"; import { PaginationDto } from "../../common/dto/pagination.dto";
@@ -181,7 +186,10 @@ export class AdminController {
@CacheKey("app_settings") @CacheKey("app_settings")
@CacheTTL(60 * 1000) @CacheTTL(60 * 1000)
@ApiOperation({ summary: "Get all app settings" }) @ApiOperation({ summary: "Get all app settings" })
@SwaggerResponse({ status: 200, schema: { type: "object", additionalProperties: { type: "string" } } }) @SwaggerResponse({
status: 200,
schema: { type: "object", additionalProperties: { type: "string" } },
})
async getAllSettings(): Promise<ApiResponse<Record<string, string>>> { async getAllSettings(): Promise<ApiResponse<Record<string, string>>> {
const settings = await this.prisma.appSetting.findMany(); const settings = await this.prisma.appSetting.findMany();
const settingsMap: Record<string, string> = {}; const settingsMap: Record<string, string> = {};
@@ -193,7 +201,13 @@ export class AdminController {
@Put("settings/:key") @Put("settings/:key")
@ApiOperation({ summary: "Update an app setting" }) @ApiOperation({ summary: "Update an app setting" })
@SwaggerResponse({ status: 200, schema: { type: "object", properties: { key: { type: "string" }, value: { type: "string" } } } }) @SwaggerResponse({
status: 200,
schema: {
type: "object",
properties: { key: { type: "string" }, value: { type: "string" } },
},
})
async updateSetting( async updateSetting(
@Param("key") key: string, @Param("key") key: string,
@Body() data: { value: string }, @Body() data: { value: string },
@@ -214,7 +228,10 @@ export class AdminController {
@Get("usage-limits") @Get("usage-limits")
@ApiOperation({ summary: "Get all usage limits" }) @ApiOperation({ summary: "Get all usage limits" })
@SwaggerResponse({ status: 200, schema: { type: "array", items: { type: "object" } } }) @SwaggerResponse({
status: 200,
schema: { type: "array", items: { type: "object" } },
})
async getAllUsageLimits(@Query() pagination: PaginationDto) { async getAllUsageLimits(@Query() pagination: PaginationDto) {
const { skip, take } = pagination; const { skip, take } = pagination;
@@ -242,7 +259,10 @@ export class AdminController {
@Post("usage-limits/reset-all") @Post("usage-limits/reset-all")
@ApiOperation({ summary: "Reset all usage limits" }) @ApiOperation({ summary: "Reset all usage limits" })
@SwaggerResponse({ status: 200, schema: { type: "object", properties: { count: { type: "number" } } } }) @SwaggerResponse({
status: 200,
schema: { type: "object", properties: { count: { type: "number" } } },
})
async resetAllUsageLimits(): Promise<ApiResponse<{ count: number }>> { async resetAllUsageLimits(): Promise<ApiResponse<{ count: number }>> {
const result = await this.prisma.usageLimit.updateMany({ const result = await this.prisma.usageLimit.updateMany({
data: { data: {
+2 -5
View File
@@ -94,11 +94,8 @@ export class RolesGuard implements CanActivate {
return false; return false;
} }
const normalizedUserRoles = (user.roles?.length const normalizedUserRoles = (
? user.roles user.roles?.length ? user.roles : user.role ? [user.role] : []
: user.role
? [user.role]
: []
).map((role) => normalizeRole(role)); ).map((role) => normalizeRole(role));
const normalizedRequiredRoles = requiredRoles.map((role) => const normalizedRequiredRoles = requiredRoles.map((role) =>
-1
View File
@@ -25,4 +25,3 @@ import { MatchesModule } from "../matches/matches.module";
], ],
}) })
export class CouponsModule {} export class CouponsModule {}
@@ -109,8 +109,7 @@ export class FrequencyCouponDto {
minSignal?: number; minSignal?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description: "Filter markets: OU1.5, OU2.5, OU3.5, BTTS, MS (default: all)",
"Filter markets: OU1.5, OU2.5, OU3.5, BTTS, MS (default: all)",
example: ["OU2.5", "BTTS"], example: ["OU2.5", "BTTS"],
}) })
@IsOptional() @IsOptional()
@@ -108,8 +108,7 @@ export class FrequencyEngineService {
venue: "home" | "away", venue: "home" | "away",
oddsBand: string, oddsBand: string,
): Promise<TeamFrequencyRow | null> { ): Promise<TeamFrequencyRow | null> {
const venueColumn = const venueColumn = venue === "home" ? "m.home_team_id" : "m.away_team_id";
venue === "home" ? "m.home_team_id" : "m.away_team_id";
const oddsSelection = venue === "home" ? "'1'" : "'2'"; const oddsSelection = venue === "home" ? "'1'" : "'2'";
const bandRange = this.parseBandRange(oddsBand); const bandRange = this.parseBandRange(oddsBand);
@@ -191,7 +190,7 @@ export class FrequencyEngineService {
// OU 1.5 OVER // OU 1.5 OVER
const ou15Combined = (homeFreq.ou15_rate + awayFreq.ou15_rate) / 2; const ou15Combined = (homeFreq.ou15_rate + awayFreq.ou15_rate) / 2;
if (ou15Combined >= 0.80) { if (ou15Combined >= 0.8) {
signals.push({ signals.push({
market: "OU1.5_OVER", market: "OU1.5_OVER",
pick: "1.5 UST", pick: "1.5 UST",
@@ -212,7 +211,7 @@ export class FrequencyEngineService {
// OU 2.5 OVER // OU 2.5 OVER
const ou25Combined = (homeFreq.ou25_rate + awayFreq.ou25_rate) / 2; const ou25Combined = (homeFreq.ou25_rate + awayFreq.ou25_rate) / 2;
if (ou25Combined >= 0.60) { if (ou25Combined >= 0.6) {
signals.push({ signals.push({
market: "OU2.5_OVER", market: "OU2.5_OVER",
pick: "2.5 UST", pick: "2.5 UST",
@@ -233,7 +232,7 @@ export class FrequencyEngineService {
// OU 3.5 OVER // OU 3.5 OVER
const ou35Combined = (homeFreq.ou35_rate + awayFreq.ou35_rate) / 2; const ou35Combined = (homeFreq.ou35_rate + awayFreq.ou35_rate) / 2;
if (ou35Combined >= 0.50) { if (ou35Combined >= 0.5) {
signals.push({ signals.push({
market: "OU3.5_OVER", market: "OU3.5_OVER",
pick: "3.5 UST", pick: "3.5 UST",
@@ -254,7 +253,7 @@ export class FrequencyEngineService {
// BTTS YES // BTTS YES
const bttsCombined = (homeFreq.btts_rate + awayFreq.btts_rate) / 2; const bttsCombined = (homeFreq.btts_rate + awayFreq.btts_rate) / 2;
if (bttsCombined >= 0.60) { if (bttsCombined >= 0.6) {
signals.push({ signals.push({
market: "BTTS_YES", market: "BTTS_YES",
pick: "KG VAR", pick: "KG VAR",
@@ -299,7 +298,7 @@ export class FrequencyEngineService {
const hwCombined = (homeFreq.win_rate + awayFreq.win_rate) / 2; const hwCombined = (homeFreq.win_rate + awayFreq.win_rate) / 2;
// awayFreq.win_rate aslında deplasman takımının KAYBETme oranı // awayFreq.win_rate aslında deplasman takımının KAYBETme oranı
// (away takımı o bandda maçları kazanma değil, kaybetme olarak bak) // (away takımı o bandda maçları kazanma değil, kaybetme olarak bak)
if (hwCombined >= 0.70 && homeOdds > 1.10 && homeOdds < 3.50) { if (hwCombined >= 0.7 && homeOdds > 1.1 && homeOdds < 3.5) {
signals.push({ signals.push({
market: "MS_HOME", market: "MS_HOME",
pick: "MS 1", pick: "MS 1",
@@ -411,9 +410,7 @@ export class FrequencyEngineService {
/** /**
* Lig bazlı gol profili. * Lig bazlı gol profili.
*/ */
async getLeagueProfile( async getLeagueProfile(leagueId: string): Promise<LeagueProfileRow | null> {
leagueId: string,
): Promise<LeagueProfileRow | null> {
const rows = await this.prisma.$queryRawUnsafe<LeagueProfileRow[]>( const rows = await this.prisma.$queryRawUnsafe<LeagueProfileRow[]>(
` `
SELECT SELECT
@@ -521,9 +518,7 @@ export class FrequencyEngineService {
return "6.00+"; return "6.00+";
} }
private parseBandRange( private parseBandRange(band: string): { min: number; max: number } | null {
band: string,
): { min: number; max: number } | null {
const map: Record<string, { min: number; max: number }> = { const map: Record<string, { min: number; max: number }> = {
"1.00-1.30": { min: 1.0, max: 1.3 }, "1.00-1.30": { min: 1.0, max: 1.3 },
"1.30-1.50": { min: 1.3, max: 1.5 }, "1.30-1.50": { min: 1.3, max: 1.5 },
@@ -537,9 +532,7 @@ export class FrequencyEngineService {
return map[band] || null; return map[band] || null;
} }
private calculateLeagueBonus( private calculateLeagueBonus(profile: LeagueProfileRow | null): number {
profile: LeagueProfileRow | null,
): number {
if (!profile || profile.total_matches < 20) { if (!profile || profile.total_matches < 20) {
return 0; return 0;
} }
@@ -154,9 +154,10 @@ export class SmartCouponService {
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> { async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
let prediction: SingleMatchPredictionPackage; let prediction: SingleMatchPredictionPackage;
try { try {
const response = await this.aiEngineClient.post<SingleMatchPredictionPackage>( const response =
`/v20plus/analyze/${matchId}`, await this.aiEngineClient.post<SingleMatchPredictionPackage>(
); `/v20plus/analyze/${matchId}`,
);
prediction = response.data; prediction = response.data;
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof AiEngineRequestError) { if (error instanceof AiEngineRequestError) {
@@ -264,7 +265,7 @@ export class SmartCouponService {
markets?: string[]; markets?: string[];
}): Promise<FrequencyCouponResult> { }): Promise<FrequencyCouponResult> {
const maxMatches = options.maxMatches ?? 3; const maxMatches = options.maxMatches ?? 3;
const minSignal = options.minSignal ?? 0.70; const minSignal = options.minSignal ?? 0.7;
const allowedMarkets = options.markets?.map((m) => m.toUpperCase()) || null; const allowedMarkets = options.markets?.map((m) => m.toUpperCase()) || null;
this.logger.log( this.logger.log(
@@ -858,9 +858,7 @@ export class FeederPersistenceService {
// Use raw SQL for performance — Prisma's { some: {} } relation filters // Use raw SQL for performance — Prisma's { some: {} } relation filters
// generate heavy correlated subqueries that hang on Raspberry Pi with // generate heavy correlated subqueries that hang on Raspberry Pi with
// large tables (15M+ odd_selections, 3M+ participations). // large tables (15M+ odd_selections, 3M+ participations).
const result = await this.prisma.$queryRawUnsafe< const result = await this.prisma.$queryRawUnsafe<Array<{ id: string }>>(
Array<{ id: string }>
>(
` `
SELECT m.id SELECT m.id
FROM matches m FROM matches m
@@ -888,9 +886,7 @@ export class FeederPersistenceService {
* returns which data scopes are missing per match. * returns which data scopes are missing per match.
* Only checks completed (Ended) football/basketball matches. * Only checks completed (Ended) football/basketball matches.
*/ */
async getMissingScopes( async getMissingScopes(matchIds: string[]): Promise<Map<string, string[]>> {
matchIds: string[],
): Promise<Map<string, string[]>> {
const result = new Map<string, string[]>(); const result = new Map<string, string[]>();
if (matchIds.length === 0) return result; if (matchIds.length === 0) return result;
+9 -4
View File
@@ -324,8 +324,8 @@ export class FeederService {
const sample = allMatches.slice(0, 3); const sample = allMatches.slice(0, 3);
this.logger.warn( this.logger.warn(
`[${sport}] [${dateString}] DEBUG: bounds=[${targetDateStartTs}, ${targetDateEndTs}] ` + `[${sport}] [${dateString}] DEBUG: bounds=[${targetDateStartTs}, ${targetDateEndTs}] ` +
`(${new Date(targetDateStartTs * 1000).toISOString()} - ${new Date(targetDateEndTs * 1000).toISOString()}) | ` + `(${new Date(targetDateStartTs * 1000).toISOString()} - ${new Date(targetDateEndTs * 1000).toISOString()}) | ` +
`sampleMstUtc=[${sample.map((m) => `${m.mstUtc} (asSec=${new Date(m.mstUtc * 1000).toISOString()}, asMs=${new Date(m.mstUtc).toISOString()})`).join(', ')}]`, `sampleMstUtc=[${sample.map((m) => `${m.mstUtc} (asSec=${new Date(m.mstUtc * 1000).toISOString()}, asMs=${new Date(m.mstUtc).toISOString()})`).join(", ")}]`,
); );
} }
@@ -393,7 +393,8 @@ export class FeederService {
// ── Patch incomplete existing matches ────────────────────── // ── Patch incomplete existing matches ──────────────────────
// Find matches that ARE in DB but have missing data scopes // Find matches that ARE in DB but have missing data scopes
const allExistingInDb = await this.persistenceService.getMissingScopes(allIds); const allExistingInDb =
await this.persistenceService.getMissingScopes(allIds);
if (allExistingInDb.size > 0) { if (allExistingInDb.size > 0) {
this.logger.log( this.logger.log(
`[${sport}] [${dateString}] 🔧 Found ${allExistingInDb.size} existing matches with missing data. Patching...`, `[${sport}] [${dateString}] 🔧 Found ${allExistingInDb.size} existing matches with missing data. Patching...`,
@@ -407,7 +408,11 @@ export class FeederService {
await this.delay(500); await this.delay(500);
try { try {
const patchScope: "all" | "lineups" | "odds" = const patchScope: "all" | "lineups" | "odds" =
scope === "odds" ? "odds" : scope === "lineups" ? "lineups" : "all"; scope === "odds"
? "odds"
: scope === "lineups"
? "lineups"
: "all";
const result = await this.processSingleMatch( const result = await this.processSingleMatch(
matchSummary, matchSummary,
+2 -1
View File
@@ -94,7 +94,8 @@ export class HealthController {
} catch (error: unknown) { } catch (error: unknown) {
return { return {
status: "down", status: "down",
detail: error instanceof Error ? error.message : "Unknown database error", detail:
error instanceof Error ? error.message : "Unknown database error",
}; };
} }
} }
+19 -4
View File
@@ -165,9 +165,24 @@ export class LeaguesController {
}, },
}) })
@ApiParam({ name: "id", description: "Team ID" }) @ApiParam({ name: "id", description: "Team ID" })
@ApiQuery({ name: "page", required: false, type: Number, description: "Page number (default: 1)" }) @ApiQuery({
@ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page (default: 20)" }) name: "page",
@ApiQuery({ name: "season", required: false, type: String, description: "Season (e.g. 2024-2025)" }) required: false,
type: Number,
description: "Page number (default: 1)",
})
@ApiQuery({
name: "limit",
required: false,
type: Number,
description: "Items per page (default: 20)",
})
@ApiQuery({
name: "season",
required: false,
type: String,
description: "Season (e.g. 2024-2025)",
})
async getTeamMatches( async getTeamMatches(
@Param("id") id: string, @Param("id") id: string,
@Query("page") page?: string, @Query("page") page?: string,
@@ -178,7 +193,7 @@ export class LeaguesController {
id, id,
parseInt(page || "1", 10), parseInt(page || "1", 10),
parseInt(limit || "20", 10), parseInt(limit || "20", 10),
season season,
); );
} }
+4 -2
View File
@@ -105,7 +105,7 @@ export class LeaguesService {
teamId: string, teamId: string,
page: number = 1, page: number = 1,
limit: number = 20, limit: number = 20,
season?: string season?: string,
) { ) {
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const where: any = { const where: any = {
@@ -123,7 +123,9 @@ export class LeaguesService {
// Season starts August 1st of startYear // Season starts August 1st of startYear
const startDate = new Date(Date.UTC(startYear, 7, 1)).getTime(); const startDate = new Date(Date.UTC(startYear, 7, 1)).getTime();
// Season ends July 31st of endYear // Season ends July 31st of endYear
const endDate = new Date(Date.UTC(endYear, 6, 31, 23, 59, 59, 999)).getTime(); const endDate = new Date(
Date.UTC(endYear, 6, 31, 23, 59, 59, 999),
).getTime();
where.mstUtc = { where.mstUtc = {
gte: startDate, gte: startDate,
+25 -14
View File
@@ -587,12 +587,19 @@ export class MatchesService {
// Fill missing relations with empty arrays // Fill missing relations with empty arrays
teamStats: [], teamStats: [],
playerParticipations: (() => { playerParticipations: (() => {
const parsed: Array<{ teamId: string; isStarting: boolean; shirtNumber: string | number | null; position: string | null; player: { id: string; name: string } }> = []; const parsed: Array<{
const canTrustFeedLineups = displayStatus === "LIVE" || displayStatus === "Finished"; teamId: string;
isStarting: boolean;
shirtNumber: string | number | null;
position: string | null;
player: { id: string; name: string };
}> = [];
const canTrustFeedLineups =
displayStatus === "LIVE" || displayStatus === "Finished";
if (!canTrustFeedLineups) { if (!canTrustFeedLineups) {
return parsed; return parsed;
} }
if (liveMatch.lineups && typeof liveMatch.lineups === 'object') { if (liveMatch.lineups && typeof liveMatch.lineups === "object") {
const lu = liveMatch.lineups as Record<string, any>; const lu = liveMatch.lineups as Record<string, any>;
const addPlayers = (teamLu: any, teamId: string | null) => { const addPlayers = (teamLu: any, teamId: string | null) => {
if (!teamLu || !teamId) return; if (!teamLu || !teamId) return;
@@ -603,7 +610,11 @@ export class MatchesService {
isStarting: true, isStarting: true,
shirtNumber: p.shirtNumber || p.number, shirtNumber: p.shirtNumber || p.number,
position: p.position || p.pos, position: p.position || p.pos,
player: { id: p.personId || p.id || p.playerId || 'unknown', name: p.matchName || p.name || p.playerName || 'Bilinmiyor' } player: {
id: p.personId || p.id || p.playerId || "unknown",
name:
p.matchName || p.name || p.playerName || "Bilinmiyor",
},
}); });
}); });
} }
@@ -614,7 +625,11 @@ export class MatchesService {
isStarting: false, isStarting: false,
shirtNumber: p.shirtNumber || p.number, shirtNumber: p.shirtNumber || p.number,
position: p.position || p.pos, position: p.position || p.pos,
player: { id: p.personId || p.id || p.playerId || 'unknown', name: p.matchName || p.name || p.playerName || 'Bilinmiyor' } player: {
id: p.personId || p.id || p.playerId || "unknown",
name:
p.matchName || p.name || p.playerName || "Bilinmiyor",
},
}); });
}); });
} }
@@ -641,7 +656,8 @@ export class MatchesService {
scoreHome: match.scoreHome, scoreHome: match.scoreHome,
scoreAway: match.scoreAway, scoreAway: match.scoreAway,
}); });
const canTrustStoredLineups = this.canTrustStoredLineups(detailDisplayStatus); const canTrustStoredLineups =
this.canTrustStoredLineups(detailDisplayStatus);
if (Array.isArray(match.playerParticipations)) { if (Array.isArray(match.playerParticipations)) {
if (!canTrustStoredLineups) { if (!canTrustStoredLineups) {
@@ -865,9 +881,7 @@ export class MatchesService {
if (!rows.length) return []; if (!rows.length) return [];
const latestMst = Math.max( const latestMst = Math.max(...rows.map((row) => Number(row.mstUtc || 0)));
...rows.map((row) => Number(row.mstUtc || 0)),
);
const ageDays = const ageDays =
latestMst > 0 latestMst > 0
? (beforeDateMs - latestMst) / (24 * 60 * 60 * 1000) ? (beforeDateMs - latestMst) / (24 * 60 * 60 * 1000)
@@ -901,8 +915,7 @@ export class MatchesService {
const rank = matchOrder.get(String(row.matchId)) ?? matchLimit; const rank = matchOrder.get(String(row.matchId)) ?? matchLimit;
const recencyWeight = Math.max(1, matchLimit - rank); const recencyWeight = Math.max(1, matchLimit - rank);
const score = const score = recencyWeight + (rank === 0 ? 3 : rank === 1 ? 1.5 : 0);
recencyWeight + (rank === 0 ? 3 : rank === 1 ? 1.5 : 0);
const existing = playerMap.get(playerId); const existing = playerMap.get(playerId);
if (!existing) { if (!existing) {
@@ -996,9 +1009,7 @@ export class MatchesService {
private canTrustStoredLineups(displayStatus?: string): boolean { private canTrustStoredLineups(displayStatus?: string): boolean {
const normalized = String(displayStatus || "").toLowerCase(); const normalized = String(displayStatus || "").toLowerCase();
return ( return (
normalized === "live" || normalized === "live" || normalized === "finished" || normalized === "ft"
normalized === "finished" ||
normalized === "ft"
); );
} }
} }
+152 -62
View File
@@ -21,6 +21,10 @@ import {
} from "./dto"; } from "./dto";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { FeederService } from "../feeder/feeder.service"; import { FeederService } from "../feeder/feeder.service";
import {
isMatchCompleted,
isMatchLive,
} from "../../common/utils/match-status.util";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { import {
@@ -49,7 +53,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private queueEvents: QueueEvents | null = null; private queueEvents: QueueEvents | null = null;
private readonly aiEngineUrl: string; private readonly aiEngineUrl: string;
private readonly aiEngineClient: AiEngineClient; private readonly aiEngineClient: AiEngineClient;
private readonly topLeagueIds = new Set<string>(); private readonly qualifiedLeagueIds = new Set<string>();
private readonly reasonTranslations: Record<string, string> = { private readonly reasonTranslations: Record<string, string> = {
confidence_below_threshold: "Güven eşiğin altında", confidence_below_threshold: "Güven eşiğin altında",
confidence_interval_too_wide: "Güven aralığı çok geniş", confidence_interval_too_wide: "Güven aralığı çok geniş",
@@ -137,7 +141,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
maxRetries: 2, maxRetries: 2,
retryDelayMs: 750, retryDelayMs: 750,
}); });
this.topLeagueIds = this.loadTopLeagueIds(); this.qualifiedLeagueIds = this.loadQualifiedLeagueIds();
} }
onModuleInit() { onModuleInit() {
@@ -155,6 +159,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
} }
} }
private predictionMemCache = new Map<
string,
{ timestamp: number; payload: MatchPredictionDto }
>();
async onModuleDestroy() { async onModuleDestroy() {
if (this.queueEvents) { if (this.queueEvents) {
await this.queueEvents.close(); await this.queueEvents.close();
@@ -177,8 +186,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return { return {
status: response.data?.status || "healthy", status: response.data?.status || "healthy",
modelLoaded: response.data?.model_loaded ?? true, modelLoaded: response.data?.model_loaded ?? true,
predictionServiceReady: predictionServiceReady: response.data?.prediction_service_ready ?? true,
response.data?.prediction_service_ready ?? true,
aiEngineReachable: true, aiEngineReachable: true,
circuitState: circuit.state, circuitState: circuit.state,
consecutiveFailures: circuit.consecutiveFailures, consecutiveFailures: circuit.consecutiveFailures,
@@ -330,33 +338,38 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
match_date_ms: Number(p.match.mstUtc) * 1000, match_date_ms: Number(p.match.mstUtc) * 1000,
league: p.match.league?.name || "", league: p.match.league?.name || "",
league_id: p.match.leagueId, league_id: p.match.leagueId,
is_top_league: this.topLeagueIds.has(p.match.leagueId ?? ""), is_top_league: this.qualifiedLeagueIds.has(p.match.leagueId ?? ""),
}, },
} as unknown as MatchPredictionDto; } as unknown as MatchPredictionDto;
}), }),
}; };
} }
private loadTopLeagueIds(): Set<string> { private loadQualifiedLeagueIds(): Set<string> {
try { try {
const topLeaguesPath = path.join(process.cwd(), "top_leagues.json"); const filePath = path.join(process.cwd(), "qualified_leagues.json");
if (!fs.existsSync(topLeaguesPath)) { if (!fs.existsSync(filePath)) {
this.logger.warn(
"qualified_leagues.json not found — all leagues allowed",
);
return new Set<string>(); return new Set<string>();
} }
const raw = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8")); const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
if (!Array.isArray(raw)) { if (!Array.isArray(raw)) {
return new Set<string>(); return new Set<string>();
} }
return new Set( const ids = new Set(
raw raw
.map((value) => String(value).trim()) .map((value) => String(value).trim())
.filter((value) => value.length > 0), .filter((value) => value.length > 0),
); );
this.logger.log(`Loaded ${ids.size} qualified league IDs`);
return ids;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to load top_leagues.json: ${message}`); this.logger.warn(`Failed to load qualified_leagues.json: ${message}`);
return new Set<string>(); return new Set<string>();
} }
} }
@@ -370,7 +383,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
if (match) { if (match) {
return { return {
leagueId: match.leagueId ?? null, leagueId: match.leagueId ?? null,
isTopLeague: this.topLeagueIds.has(match.leagueId ?? ""), isTopLeague: this.qualifiedLeagueIds.has(match.leagueId ?? ""),
}; };
} }
@@ -381,7 +394,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return { return {
leagueId: liveMatch?.leagueId ?? null, leagueId: liveMatch?.leagueId ?? null,
isTopLeague: this.topLeagueIds.has(liveMatch?.leagueId ?? ""), isTopLeague: this.qualifiedLeagueIds.has(liveMatch?.leagueId ?? ""),
}; };
} }
@@ -731,20 +744,20 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return this.reasonTranslations[normalized]; return this.reasonTranslations[normalized];
} }
const evMatch = normalized.match(/^ev_edge_([+\-][\d.]+%)_grade_(\w)$/); const evMatch = normalized.match(/^ev_edge_([-+][\d.]+%)_grade_(\w)$/);
if (evMatch) { if (evMatch) {
return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`; return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`;
} }
const negativeEdgeMatch = normalized.match( const negativeEdgeMatch = normalized.match(
/^negative_model_edge_([+\-]?[\d.]+)$/, /^negative_model_edge_([-+]?[\d.]+)$/,
); );
if (negativeEdgeMatch) { if (negativeEdgeMatch) {
return `Model avantajı negatif (${negativeEdgeMatch[1]})`; return `Model avantajı negatif (${negativeEdgeMatch[1]})`;
} }
const edgeThresholdMatch = normalized.match( const edgeThresholdMatch = normalized.match(
/^below_market_edge_threshold_([+\-]?[\d.]+)$/, /^below_market_edge_threshold_([-+]?[\d.]+)$/,
); );
if (edgeThresholdMatch) { if (edgeThresholdMatch) {
return `Piyasa avantaj eşiğinin altında (${edgeThresholdMatch[1]})`; return `Piyasa avantaj eşiğinin altında (${edgeThresholdMatch[1]})`;
@@ -1071,10 +1084,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
// Direct HTTP mode // Direct HTTP mode
try { try {
const response = await this.aiEngineClient.post( const response = await this.aiEngineClient.post("/smart-coupon", {
"/smart-coupon", match_ids: matchIds,
{ match_ids: matchIds, strategy, ...options }, strategy,
); ...options,
});
return response.data; return response.data;
} catch (error: unknown) { } catch (error: unknown) {
const message = const message =
@@ -1130,8 +1144,26 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
} }
async cachePrediction(matchId: string, prediction: MatchPredictionDto) { async cachePrediction(matchId: string, prediction: MatchPredictionDto) {
this.predictionMemCache.set(matchId, {
timestamp: Date.now(),
payload: prediction,
});
if (this.predictionMemCache.size > 500) {
const firstKey = this.predictionMemCache.keys().next().value;
if (firstKey) this.predictionMemCache.delete(firstKey);
}
const payload = prediction as unknown as Prisma.InputJsonObject; const payload = prediction as unknown as Prisma.InputJsonObject;
try { try {
const existsInMatch = await this.prisma.match.findUnique({
where: { id: matchId },
select: { id: true },
});
if (!existsInMatch) {
return;
}
await this.prisma.prediction.upsert({ await this.prisma.prediction.upsert({
where: { matchId }, where: { matchId },
update: { update: {
@@ -1151,6 +1183,16 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
async getCachedPrediction( async getCachedPrediction(
matchId: string, matchId: string,
): Promise<MatchPredictionDto | null> { ): Promise<MatchPredictionDto | null> {
const memCached = this.predictionMemCache.get(matchId);
if (memCached) {
if (Date.now() - memCached.timestamp < 10 * 60 * 1000) {
// 10 mins TTL
return memCached.payload;
} else {
this.predictionMemCache.delete(matchId);
}
}
const prediction = await this.prisma.prediction.findUnique({ const prediction = await this.prisma.prediction.findUnique({
where: { matchId }, where: { matchId },
}); });
@@ -1216,32 +1258,38 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
} }
private async ensurePredictionDataReady(matchId: string): Promise<void> { private async ensurePredictionDataReady(matchId: string): Promise<void> {
const [liveMatch, persistedMatch, oddCategoryCount] = await Promise.all([ const [liveMatch, persistedMatch, oddCategoryCount, lineupCount] =
this.prisma.liveMatch.findUnique({ await Promise.all([
where: { id: matchId }, this.prisma.liveMatch.findUnique({
select: { where: { id: matchId },
id: true, select: {
odds: true, id: true,
state: true, odds: true,
status: true, state: true,
scoreHome: true, status: true,
scoreAway: true, scoreHome: true,
}, scoreAway: true,
}), leagueId: true,
this.prisma.match.findUnique({ },
where: { id: matchId }, }),
select: { this.prisma.match.findUnique({
id: true, where: { id: matchId },
state: true, select: {
status: true, id: true,
scoreHome: true, state: true,
scoreAway: true, status: true,
}, scoreHome: true,
}), scoreAway: true,
this.prisma.oddCategory.count({ leagueId: true,
where: { matchId }, },
}), }),
]); this.prisma.oddCategory.count({
where: { matchId },
}),
this.prisma.matchPlayerParticipation.count({
where: { matchId },
}),
]);
const hasLiveOdds = const hasLiveOdds =
!!liveMatch?.odds && !!liveMatch?.odds &&
@@ -1257,27 +1305,68 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
); );
} }
// League qualification gate: reject predictions for leagues without
// sufficient historical training data (odds + lineups + stats)
const leagueId = liveMatch?.leagueId || persistedMatch?.leagueId;
if (
this.qualifiedLeagueIds.size > 0 &&
(!leagueId || !this.qualifiedLeagueIds.has(leagueId))
) {
throw new HttpException(
`Bu lig için yeterli geçmiş veri bulunmuyor. Tahmin yapılamaz.`,
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
const state = liveMatch?.state || persistedMatch?.state; const state = liveMatch?.state || persistedMatch?.state;
const status = liveMatch?.status || persistedMatch?.status; const status = liveMatch?.status || persistedMatch?.status;
const scoreHome = liveMatch?.scoreHome ?? persistedMatch?.scoreHome; const scoreHome = liveMatch?.scoreHome ?? persistedMatch?.scoreHome;
const scoreAway = liveMatch?.scoreAway ?? persistedMatch?.scoreAway; const scoreAway = liveMatch?.scoreAway ?? persistedMatch?.scoreAway;
const hasScores =
scoreHome !== null &&
scoreHome !== undefined &&
scoreAway !== null &&
scoreAway !== undefined;
const isFinished = const isFinished = isMatchCompleted({
hasScores || state: state ?? null,
state === "MS" || status: status ?? null,
state === "postGame" || scoreHome,
["Finished", "Played", "FT", "AET", "PEN", "Ended"].includes( scoreAway,
status as string, });
);
const isLive = isMatchLive({
state: state ?? null,
status: status ?? null,
});
const hasOdds = hasLiveOdds || oddCategoryCount > 0; const hasOdds = hasLiveOdds || oddCategoryCount > 0;
if (hasOdds || isFinished) { if (hasOdds || isFinished || isLive) {
// ── Lineup guard: fetch lineups if missing before analysis ──
// A proper football lineup has at least 11 starting players (22 total
// with subs). If we have fewer than 11 participation records, the
// lineup data is likely missing — attempt to fetch it from source.
if (lineupCount < 11) {
this.logger.log(
`[${matchId}] ⚠️ Lineups missing (${lineupCount} players in DB). Fetching from source before analysis...`,
);
try {
const refreshResult = await this.feederService.refreshMatch(
matchId,
"lineups",
);
if (refreshResult.success) {
this.logger.log(
`[${matchId}] ✅ Lineups fetched successfully before analysis`,
);
} else {
this.logger.warn(
`[${matchId}] ⚠️ Lineup fetch returned failure — proceeding with existing data. Error: ${refreshResult.error ?? "unknown"}`,
);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`[${matchId}] ⚠️ Lineup fetch exception — proceeding with existing data. ${message}`,
);
}
}
return; return;
} }
@@ -1315,7 +1404,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
); );
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Prediction run audit skipped for ${matchId}: ${message}`); this.logger.warn(
`Prediction run audit skipped for ${matchId}: ${message}`,
);
} }
} }
@@ -1397,8 +1488,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
: null, : null,
bet_advice: { bet_advice: {
playable: payload.bet_advice?.playable ?? false, playable: payload.bet_advice?.playable ?? false,
suggested_stake_units: suggested_stake_units: payload.bet_advice?.suggested_stake_units ?? 0,
payload.bet_advice?.suggested_stake_units ?? 0,
reason: payload.bet_advice?.reason ?? null, reason: payload.bet_advice?.reason ?? null,
}, },
top_summary: topSummary, top_summary: topSummary,
@@ -6,6 +6,7 @@ import axios from "axios";
let createCanvas: any; let createCanvas: any;
let loadImage: any; let loadImage: any;
try { try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const canvas = require("canvas"); const canvas = require("canvas");
createCanvas = canvas.createCanvas; createCanvas = canvas.createCanvas;
loadImage = canvas.loadImage; loadImage = canvas.loadImage;
@@ -397,7 +398,7 @@ export class ImageRendererService implements OnModuleInit {
ctx.fillStyle = "rgba(255, 255, 255, 0.4)"; ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
ctx.font = "700 26px sans-serif"; ctx.font = "700 26px sans-serif";
ctx.textAlign = "left"; ctx.textAlign = "left";
ctx.fillText("⚡ AI Powered by SuggestBet", paddingX, currentY); ctx.fillText("⚡ AI Powered by iddaai.com", paddingX, currentY);
let riskBg, riskColor, riskBorder; let riskBg, riskColor, riskBorder;
switch (data.riskLevel) { switch (data.riskLevel) {
+1 -3
View File
@@ -44,9 +44,7 @@ export class AiService {
private readonly pythonEngineUrl: string; private readonly pythonEngineUrl: string;
private readonly aiEngineClient: AiEngineClient; private readonly aiEngineClient: AiEngineClient;
constructor( constructor(private readonly configService: ConfigService) {
private readonly configService: ConfigService,
) {
this.pythonEngineUrl = this.pythonEngineUrl =
this.configService.get("AI_ENGINE_URL") || "http://127.0.0.1:8000"; this.configService.get("AI_ENGINE_URL") || "http://127.0.0.1:8000";
this.aiEngineClient = new AiEngineClient({ this.aiEngineClient = new AiEngineClient({
+28 -22
View File
@@ -161,7 +161,8 @@ export class DataFetcherTask {
`Pruned ${deleted.count} stale live matches. Starting full sync...`, `Pruned ${deleted.count} stale live matches. Starting full sync...`,
); );
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error); const message =
error instanceof Error ? error.message : String(error);
this.logger.error(`Stale live_match cleanup failed: ${message}`); this.logger.error(`Stale live_match cleanup failed: ${message}`);
return; return;
} }
@@ -194,12 +195,12 @@ export class DataFetcherTask {
private async syncMatchList(date: string): Promise<void> { private async syncMatchList(date: string): Promise<void> {
// Football // Football
const footballLeagues = this.loadLeagueFilterSet("top_leagues.json"); const footballLeagues = this.loadLeagueFilterSet("qualified_leagues.json");
if (footballLeagues && footballLeagues.size > 0) { if (footballLeagues && footballLeagues.size > 0) {
await this.fetchMatchesForSport("football", date, footballLeagues); await this.fetchMatchesForSport("football", date, footballLeagues);
} else { } else {
this.logger.warn( this.logger.warn(
"top_leagues.json is missing/empty — writing ALL football matches", "qualified_leagues.json is missing/empty — writing ALL football matches",
); );
await this.fetchMatchesForSport("football", date, new Set()); await this.fetchMatchesForSport("football", date, new Set());
} }
@@ -250,7 +251,7 @@ export class DataFetcherTask {
} }
this.logger.log( this.logger.log(
`📡 Updating scores for ${liveMatches.length} live matches`, `LIVE Updating scores for ${liveMatches.length} live matches`,
); );
for (const match of liveMatches) { for (const match of liveMatches) {
@@ -278,25 +279,25 @@ export class DataFetcherTask {
} }
} }
this.logger.log("📡 Live score update complete"); this.logger.log("LIVE Live score update complete");
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Live score update failed: ${message}`); this.logger.error(`Live score update failed: ${message}`);
} }
} }
// ────────────────────────────────────────────────────────────
// Phase 3: Odds + referee + lineups + sidelined // Phase 3: Odds + referee + lineups + sidelined
// ────────────────────────────────────────────────────────────
private async fetchOddsForMatches(): Promise<void> { private async fetchOddsForMatches(): Promise<void> {
this.logger.log("💰 Fetching odds for live matches..."); this.logger.log("MONEY Fetching odds for live matches...");
try { try {
// Load both league filters // Load both league filters (data-driven qualified leagues)
const topLeagueIds: string[] = []; const topLeagueIds: string[] = [];
const footballLeagues = this.loadLeagueFilterSet("top_leagues.json"); const footballLeagues = this.loadLeagueFilterSet(
"qualified_leagues.json",
);
if (footballLeagues) topLeagueIds.push(...footballLeagues); if (footballLeagues) topLeagueIds.push(...footballLeagues);
const basketballLeagues = this.loadLeagueFilterSet( const basketballLeagues = this.loadLeagueFilterSet(
@@ -337,11 +338,13 @@ export class DataFetcherTask {
}); });
if (matchesToFetch.length === 0) { if (matchesToFetch.length === 0) {
this.logger.log("💰 No matches to fetch odds for"); this.logger.log("MONEY No matches to fetch odds for");
return; return;
} }
this.logger.log(`💰 Fetching odds for ${matchesToFetch.length} matches`); this.logger.log(
`MONEY Fetching odds for ${matchesToFetch.length} matches`,
);
let successCount = 0; let successCount = 0;
let errorCount = 0; let errorCount = 0;
@@ -370,7 +373,7 @@ export class DataFetcherTask {
// Retry failed matches (502/Timeout) // Retry failed matches (502/Timeout)
if (failedMatches.length > 0) { if (failedMatches.length > 0) {
this.logger.warn( this.logger.warn(
`⚠️ Retrying ${failedMatches.length} failed matches (502/Timeout)...`, `Retrying ${failedMatches.length} failed matches (502/Timeout)...`,
); );
for (const match of failedMatches) { for (const match of failedMatches) {
@@ -378,7 +381,7 @@ export class DataFetcherTask {
try { try {
await this.processMatchOdds(match); await this.processMatchOdds(match);
successCount++; successCount++;
this.logger.log(`✅ Retry successful for match ${match.id}`); this.logger.log(`SUCCESS Retry successful for match ${match.id}`);
} catch (retryErr: unknown) { } catch (retryErr: unknown) {
const message = const message =
retryErr instanceof Error ? retryErr.message : String(retryErr); retryErr instanceof Error ? retryErr.message : String(retryErr);
@@ -390,7 +393,7 @@ export class DataFetcherTask {
} }
this.logger.log( this.logger.log(
`💰 Odds complete: ${successCount} success, ${errorCount} errors (initially)`, `MONEY Odds complete: ${successCount} success, ${errorCount} errors (initially)`,
); );
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
@@ -905,12 +908,16 @@ export class DataFetcherTask {
// Guard: If match already has pre-match odds and is now live/finished, // Guard: If match already has pre-match odds and is now live/finished,
// do NOT overwrite odds/lineups/sidelined — the model needs stable pre-match data. // do NOT overwrite odds/lineups/sidelined — the model needs stable pre-match data.
const matchState = match.state?.toLowerCase() ?? ''; const matchState = match.state?.toLowerCase() ?? "";
const matchStatus = match.status?.toLowerCase() ?? ''; const matchStatus = match.status?.toLowerCase() ?? "";
const liveStates = LIVE_STATE_VALUES_FOR_DB.map((s) => s.toLowerCase()); const liveStates = LIVE_STATE_VALUES_FOR_DB.map((s) => s.toLowerCase());
const liveStatuses = LIVE_STATUS_VALUES_FOR_DB.map((s) => s.toLowerCase()); const liveStatuses = LIVE_STATUS_VALUES_FOR_DB.map((s) => s.toLowerCase());
const finishedStates = FINISHED_STATE_VALUES_FOR_DB.map((s) => s.toLowerCase()); const finishedStates = FINISHED_STATE_VALUES_FOR_DB.map((s) =>
const finishedStatuses = FINISHED_STATUS_VALUES_FOR_DB.map((s) => s.toLowerCase()); s.toLowerCase(),
);
const finishedStatuses = FINISHED_STATUS_VALUES_FOR_DB.map((s) =>
s.toLowerCase(),
);
const isLiveOrFinished = const isLiveOrFinished =
liveStates.includes(matchState) || liveStates.includes(matchState) ||
@@ -921,7 +928,7 @@ export class DataFetcherTask {
const existingOdds = match.odds as Record<string, unknown> | null; const existingOdds = match.odds as Record<string, unknown> | null;
const hasExistingOdds = const hasExistingOdds =
!!existingOdds && !!existingOdds &&
typeof existingOdds === 'object' && typeof existingOdds === "object" &&
Object.keys(existingOdds).length > 0; Object.keys(existingOdds).length > 0;
if (isLiveOrFinished && hasExistingOdds) { if (isLiveOrFinished && hasExistingOdds) {
@@ -957,7 +964,7 @@ export class DataFetcherTask {
sidelined.awayTeam.totalSidelined > 0)) sidelined.awayTeam.totalSidelined > 0))
) { ) {
this.logger.log( this.logger.log(
`✅ Loop update: ${match.matchName} | Odds: ${Object.keys(odds).length} | Ref: ${refereeName || "N/A"} | Lineups: ${lineups ? "Yes" : "No"} | Sidelined: ${sidelined ? "Yes" : "No"}`, `SUCCESS Loop update: ${match.matchName} | Odds: ${Object.keys(odds).length} | Ref: ${refereeName || "N/A"} | Lineups: ${lineups ? "Yes" : "No"} | Sidelined: ${sidelined ? "Yes" : "No"}`,
); );
} else { } else {
this.logger.debug( this.logger.debug(
@@ -1334,4 +1341,3 @@ export class DataFetcherTask {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
} }