From 27e96da31debe86be9596eb78f37b38f24b79b12 Mon Sep 17 00:00:00 2001 From: Fahri Can Date: Mon, 4 May 2026 18:00:40 +0300 Subject: [PATCH] main --- Dockerfile | 2 +- .../services/single_match_orchestrator.py | 12 +- qualified_leagues.json | 267 ++++++++++++++++++ src/common/utils/ai-engine-client.ts | 10 +- src/common/utils/match-status.util.ts | 2 + src/common/utils/timezone.util.ts | 5 +- src/modules/admin/admin.controller.ts | 30 +- src/modules/auth/guards/auth.guards.ts | 7 +- src/modules/coupons/coupons.module.ts | 1 - .../coupons/dto/coupons-request.dto.ts | 3 +- .../services/frequency-engine.service.ts | 25 +- .../coupons/services/smart-coupon.service.ts | 9 +- .../feeder/feeder-persistence.service.ts | 8 +- src/modules/feeder/feeder.service.ts | 13 +- src/modules/health/health.controller.ts | 3 +- src/modules/leagues/leagues.controller.ts | 23 +- src/modules/leagues/leagues.service.ts | 10 +- src/modules/matches/matches.service.ts | 39 ++- .../predictions/predictions.service.ts | 214 ++++++++++---- .../social-poster/image-renderer.service.ts | 3 +- src/services/ai.service.ts | 4 +- src/tasks/data-fetcher.task.ts | 50 ++-- 22 files changed, 571 insertions(+), 169 deletions(-) create mode 100644 qualified_leagues.json diff --git a/Dockerfile b/Dockerfile index 3836f46..f563f50 100755 --- a/Dockerfile +++ b/Dockerfile @@ -47,7 +47,7 @@ COPY --from=builder /app/dist ./dist COPY --from=builder /app/src/i18n ./dist/i18n # 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 ENV NODE_ENV=production diff --git a/ai-engine/services/single_match_orchestrator.py b/ai-engine/services/single_match_orchestrator.py index 3db1e44..e800b93 100755 --- a/ai-engine/services/single_match_orchestrator.py +++ b/ai-engine/services/single_match_orchestrator.py @@ -964,13 +964,13 @@ class SingleMatchOrchestrator: return None # ── Pre-Match Simulation Mode ──────────────────────────── - # For finished (FT/postGame) matches, strip live scores so the - # entire pipeline treats them as if they haven't kicked off yet. - # _is_live_match already returns False for FT, but this adds - # defense-in-depth against any code path that reads scores directly. + # Force all matches (live and finished) into pre-match state so the + # engine purely predicts based on pre-match odds and context, ignoring + # current live scores and preventing live state penalties. _status_upper = str(data.status or "").upper() - _state_upper = str(data.state or "").upper() - if _status_upper in {"FT", "FINISHED"} or _state_upper in {"POSTGAME", "POST_GAME"}: + if _status_upper not in {"NS", "POSTPONED", "CANC", "ABD"}: + data.status = "NS" + data.state = "preGame" data.current_score_home = None data.current_score_away = None diff --git a/qualified_leagues.json b/qualified_leagues.json new file mode 100644 index 0000000..67ec5b2 --- /dev/null +++ b/qualified_leagues.json @@ -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" +] diff --git a/src/common/utils/ai-engine-client.ts b/src/common/utils/ai-engine-client.ts index 95f0862..75422aa 100644 --- a/src/common/utils/ai-engine-client.ts +++ b/src/common/utils/ai-engine-client.ts @@ -70,8 +70,7 @@ export class AiEngineClient { this.maxRetries = options.maxRetries ?? 2; this.retryDelayMs = options.retryDelayMs ?? 750; this.circuitBreakerThreshold = options.circuitBreakerThreshold ?? 3; - this.circuitBreakerCooldownMs = - options.circuitBreakerCooldownMs ?? 30000; + this.circuitBreakerCooldownMs = options.circuitBreakerCooldownMs ?? 30000; this.axiosClient = axios.create({ baseURL: options.baseUrl, @@ -113,7 +112,9 @@ export class AiEngineClient { }; } - private async request(config: AiEngineRequestConfig): Promise> { + private async request( + config: AiEngineRequestConfig, + ): Promise> { this.ensureCircuitAvailable(); const retries = this.resolveRetryCount(config); @@ -162,7 +163,8 @@ export class AiEngineClient { } const remainingCooldown = - this.circuitBreakerCooldownMs - (Date.now() - (this.circuitOpenedAt ?? 0)); + this.circuitBreakerCooldownMs - + (Date.now() - (this.circuitOpenedAt ?? 0)); if (remainingCooldown > 0) { throw new AiEngineRequestError("AI engine circuit breaker is open", { diff --git a/src/common/utils/match-status.util.ts b/src/common/utils/match-status.util.ts index 1db15fc..53a20a3 100644 --- a/src/common/utils/match-status.util.ts +++ b/src/common/utils/match-status.util.ts @@ -81,6 +81,7 @@ export const LIVE_STATUS_VALUES_FOR_DB = [ "Playing", "Half Time", "liveGame", + "minutes", ]; export const LIVE_STATE_VALUES_FOR_DB = [ @@ -109,6 +110,7 @@ export const FINISHED_STATUS_VALUES_FOR_DB = [ "postGame", "posted", "Posted", + "state", ]; export const FINISHED_STATE_VALUES_FOR_DB = [ diff --git a/src/common/utils/timezone.util.ts b/src/common/utils/timezone.util.ts index f2c6e5d..f5d4b91 100644 --- a/src/common/utils/timezone.util.ts +++ b/src/common/utils/timezone.util.ts @@ -14,10 +14,7 @@ function extractDateParts(date: Date, timeZone: string) { return { year, month, day }; } -export function getDateStringInTimeZone( - date: Date, - timeZone: string, -): string { +export function getDateStringInTimeZone(date: Date, timeZone: string): string { const { year, month, day } = extractDateParts(date, timeZone); return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } diff --git a/src/modules/admin/admin.controller.ts b/src/modules/admin/admin.controller.ts index cab344e..0370a87 100755 --- a/src/modules/admin/admin.controller.ts +++ b/src/modules/admin/admin.controller.ts @@ -18,7 +18,12 @@ import { CACHE_MANAGER, } from "@nestjs/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 { PrismaService } from "../../database/prisma.service"; import { PaginationDto } from "../../common/dto/pagination.dto"; @@ -181,7 +186,10 @@ export class AdminController { @CacheKey("app_settings") @CacheTTL(60 * 1000) @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>> { const settings = await this.prisma.appSetting.findMany(); const settingsMap: Record = {}; @@ -193,7 +201,13 @@ export class AdminController { @Put("settings/:key") @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( @Param("key") key: string, @Body() data: { value: string }, @@ -214,7 +228,10 @@ export class AdminController { @Get("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) { const { skip, take } = pagination; @@ -242,7 +259,10 @@ export class AdminController { @Post("usage-limits/reset-all") @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> { const result = await this.prisma.usageLimit.updateMany({ data: { diff --git a/src/modules/auth/guards/auth.guards.ts b/src/modules/auth/guards/auth.guards.ts index 1c54070..e59cb37 100755 --- a/src/modules/auth/guards/auth.guards.ts +++ b/src/modules/auth/guards/auth.guards.ts @@ -94,11 +94,8 @@ export class RolesGuard implements CanActivate { return false; } - const normalizedUserRoles = (user.roles?.length - ? user.roles - : user.role - ? [user.role] - : [] + const normalizedUserRoles = ( + user.roles?.length ? user.roles : user.role ? [user.role] : [] ).map((role) => normalizeRole(role)); const normalizedRequiredRoles = requiredRoles.map((role) => diff --git a/src/modules/coupons/coupons.module.ts b/src/modules/coupons/coupons.module.ts index 44154b2..b16de60 100755 --- a/src/modules/coupons/coupons.module.ts +++ b/src/modules/coupons/coupons.module.ts @@ -25,4 +25,3 @@ import { MatchesModule } from "../matches/matches.module"; ], }) export class CouponsModule {} - diff --git a/src/modules/coupons/dto/coupons-request.dto.ts b/src/modules/coupons/dto/coupons-request.dto.ts index 11ee6fe..d7e5a9f 100644 --- a/src/modules/coupons/dto/coupons-request.dto.ts +++ b/src/modules/coupons/dto/coupons-request.dto.ts @@ -109,8 +109,7 @@ export class FrequencyCouponDto { minSignal?: number; @ApiPropertyOptional({ - description: - "Filter markets: OU1.5, OU2.5, OU3.5, BTTS, MS (default: all)", + description: "Filter markets: OU1.5, OU2.5, OU3.5, BTTS, MS (default: all)", example: ["OU2.5", "BTTS"], }) @IsOptional() diff --git a/src/modules/coupons/services/frequency-engine.service.ts b/src/modules/coupons/services/frequency-engine.service.ts index 4003726..5c0b958 100644 --- a/src/modules/coupons/services/frequency-engine.service.ts +++ b/src/modules/coupons/services/frequency-engine.service.ts @@ -108,8 +108,7 @@ export class FrequencyEngineService { venue: "home" | "away", oddsBand: string, ): Promise { - const venueColumn = - venue === "home" ? "m.home_team_id" : "m.away_team_id"; + const venueColumn = venue === "home" ? "m.home_team_id" : "m.away_team_id"; const oddsSelection = venue === "home" ? "'1'" : "'2'"; const bandRange = this.parseBandRange(oddsBand); @@ -191,7 +190,7 @@ export class FrequencyEngineService { // OU 1.5 OVER const ou15Combined = (homeFreq.ou15_rate + awayFreq.ou15_rate) / 2; - if (ou15Combined >= 0.80) { + if (ou15Combined >= 0.8) { signals.push({ market: "OU1.5_OVER", pick: "1.5 UST", @@ -212,7 +211,7 @@ export class FrequencyEngineService { // OU 2.5 OVER const ou25Combined = (homeFreq.ou25_rate + awayFreq.ou25_rate) / 2; - if (ou25Combined >= 0.60) { + if (ou25Combined >= 0.6) { signals.push({ market: "OU2.5_OVER", pick: "2.5 UST", @@ -233,7 +232,7 @@ export class FrequencyEngineService { // OU 3.5 OVER const ou35Combined = (homeFreq.ou35_rate + awayFreq.ou35_rate) / 2; - if (ou35Combined >= 0.50) { + if (ou35Combined >= 0.5) { signals.push({ market: "OU3.5_OVER", pick: "3.5 UST", @@ -254,7 +253,7 @@ export class FrequencyEngineService { // BTTS YES const bttsCombined = (homeFreq.btts_rate + awayFreq.btts_rate) / 2; - if (bttsCombined >= 0.60) { + if (bttsCombined >= 0.6) { signals.push({ market: "BTTS_YES", pick: "KG VAR", @@ -299,7 +298,7 @@ export class FrequencyEngineService { const hwCombined = (homeFreq.win_rate + awayFreq.win_rate) / 2; // 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) - if (hwCombined >= 0.70 && homeOdds > 1.10 && homeOdds < 3.50) { + if (hwCombined >= 0.7 && homeOdds > 1.1 && homeOdds < 3.5) { signals.push({ market: "MS_HOME", pick: "MS 1", @@ -411,9 +410,7 @@ export class FrequencyEngineService { /** * Lig bazlı gol profili. */ - async getLeagueProfile( - leagueId: string, - ): Promise { + async getLeagueProfile(leagueId: string): Promise { const rows = await this.prisma.$queryRawUnsafe( ` SELECT @@ -521,9 +518,7 @@ export class FrequencyEngineService { return "6.00+"; } - private parseBandRange( - band: string, - ): { min: number; max: number } | null { + private parseBandRange(band: string): { min: number; max: number } | null { const map: Record = { "1.00-1.30": { min: 1.0, max: 1.3 }, "1.30-1.50": { min: 1.3, max: 1.5 }, @@ -537,9 +532,7 @@ export class FrequencyEngineService { return map[band] || null; } - private calculateLeagueBonus( - profile: LeagueProfileRow | null, - ): number { + private calculateLeagueBonus(profile: LeagueProfileRow | null): number { if (!profile || profile.total_matches < 20) { return 0; } diff --git a/src/modules/coupons/services/smart-coupon.service.ts b/src/modules/coupons/services/smart-coupon.service.ts index a4bb8a2..321f146 100755 --- a/src/modules/coupons/services/smart-coupon.service.ts +++ b/src/modules/coupons/services/smart-coupon.service.ts @@ -154,9 +154,10 @@ export class SmartCouponService { async analyzeMatch(matchId: string): Promise { let prediction: SingleMatchPredictionPackage; try { - const response = await this.aiEngineClient.post( - `/v20plus/analyze/${matchId}`, - ); + const response = + await this.aiEngineClient.post( + `/v20plus/analyze/${matchId}`, + ); prediction = response.data; } catch (error: unknown) { if (error instanceof AiEngineRequestError) { @@ -264,7 +265,7 @@ export class SmartCouponService { markets?: string[]; }): Promise { 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; this.logger.log( diff --git a/src/modules/feeder/feeder-persistence.service.ts b/src/modules/feeder/feeder-persistence.service.ts index 29697b6..6b3d600 100755 --- a/src/modules/feeder/feeder-persistence.service.ts +++ b/src/modules/feeder/feeder-persistence.service.ts @@ -858,9 +858,7 @@ export class FeederPersistenceService { // Use raw SQL for performance — Prisma's { some: {} } relation filters // generate heavy correlated subqueries that hang on Raspberry Pi with // large tables (15M+ odd_selections, 3M+ participations). - const result = await this.prisma.$queryRawUnsafe< - Array<{ id: string }> - >( + const result = await this.prisma.$queryRawUnsafe>( ` SELECT m.id FROM matches m @@ -888,9 +886,7 @@ export class FeederPersistenceService { * returns which data scopes are missing per match. * Only checks completed (Ended) football/basketball matches. */ - async getMissingScopes( - matchIds: string[], - ): Promise> { + async getMissingScopes(matchIds: string[]): Promise> { const result = new Map(); if (matchIds.length === 0) return result; diff --git a/src/modules/feeder/feeder.service.ts b/src/modules/feeder/feeder.service.ts index 01274a5..8af93ed 100755 --- a/src/modules/feeder/feeder.service.ts +++ b/src/modules/feeder/feeder.service.ts @@ -324,8 +324,8 @@ export class FeederService { const sample = allMatches.slice(0, 3); this.logger.warn( `[${sport}] [${dateString}] DEBUG: bounds=[${targetDateStartTs}, ${targetDateEndTs}] ` + - `(${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(', ')}]`, + `(${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(", ")}]`, ); } @@ -393,7 +393,8 @@ export class FeederService { // ── Patch incomplete existing matches ────────────────────── // 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) { this.logger.log( `[${sport}] [${dateString}] 🔧 Found ${allExistingInDb.size} existing matches with missing data. Patching...`, @@ -407,7 +408,11 @@ export class FeederService { await this.delay(500); try { const patchScope: "all" | "lineups" | "odds" = - scope === "odds" ? "odds" : scope === "lineups" ? "lineups" : "all"; + scope === "odds" + ? "odds" + : scope === "lineups" + ? "lineups" + : "all"; const result = await this.processSingleMatch( matchSummary, diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts index 230dc9b..4b43c2f 100755 --- a/src/modules/health/health.controller.ts +++ b/src/modules/health/health.controller.ts @@ -94,7 +94,8 @@ export class HealthController { } catch (error: unknown) { return { status: "down", - detail: error instanceof Error ? error.message : "Unknown database error", + detail: + error instanceof Error ? error.message : "Unknown database error", }; } } diff --git a/src/modules/leagues/leagues.controller.ts b/src/modules/leagues/leagues.controller.ts index f2732ef..a1c5fb5 100755 --- a/src/modules/leagues/leagues.controller.ts +++ b/src/modules/leagues/leagues.controller.ts @@ -165,9 +165,24 @@ export class LeaguesController { }, }) @ApiParam({ name: "id", description: "Team ID" }) - @ApiQuery({ name: "page", 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)" }) + @ApiQuery({ + name: "page", + 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( @Param("id") id: string, @Query("page") page?: string, @@ -178,7 +193,7 @@ export class LeaguesController { id, parseInt(page || "1", 10), parseInt(limit || "20", 10), - season + season, ); } diff --git a/src/modules/leagues/leagues.service.ts b/src/modules/leagues/leagues.service.ts index dcba8a2..66a1d96 100755 --- a/src/modules/leagues/leagues.service.ts +++ b/src/modules/leagues/leagues.service.ts @@ -105,7 +105,7 @@ export class LeaguesService { teamId: string, page: number = 1, limit: number = 20, - season?: string + season?: string, ) { const skip = (page - 1) * limit; const where: any = { @@ -118,13 +118,15 @@ export class LeaguesService { if (parts.length === 2) { const startYear = parseInt(parts[0], 10); const endYear = parseInt(parts[1], 10); - + if (!isNaN(startYear) && !isNaN(endYear)) { // Season starts August 1st of startYear const startDate = new Date(Date.UTC(startYear, 7, 1)).getTime(); // 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 = { gte: startDate, lte: endDate, diff --git a/src/modules/matches/matches.service.ts b/src/modules/matches/matches.service.ts index 8e4b790..3260ad8 100755 --- a/src/modules/matches/matches.service.ts +++ b/src/modules/matches/matches.service.ts @@ -587,12 +587,19 @@ export class MatchesService { // Fill missing relations with empty arrays teamStats: [], playerParticipations: (() => { - const parsed: Array<{ teamId: string; isStarting: boolean; shirtNumber: string | number | null; position: string | null; player: { id: string; name: string } }> = []; - const canTrustFeedLineups = displayStatus === "LIVE" || displayStatus === "Finished"; + const parsed: Array<{ + teamId: string; + isStarting: boolean; + shirtNumber: string | number | null; + position: string | null; + player: { id: string; name: string }; + }> = []; + const canTrustFeedLineups = + displayStatus === "LIVE" || displayStatus === "Finished"; if (!canTrustFeedLineups) { return parsed; } - if (liveMatch.lineups && typeof liveMatch.lineups === 'object') { + if (liveMatch.lineups && typeof liveMatch.lineups === "object") { const lu = liveMatch.lineups as Record; const addPlayers = (teamLu: any, teamId: string | null) => { if (!teamLu || !teamId) return; @@ -603,7 +610,11 @@ export class MatchesService { isStarting: true, shirtNumber: p.shirtNumber || p.number, 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, shirtNumber: p.shirtNumber || p.number, 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, scoreAway: match.scoreAway, }); - const canTrustStoredLineups = this.canTrustStoredLineups(detailDisplayStatus); + const canTrustStoredLineups = + this.canTrustStoredLineups(detailDisplayStatus); if (Array.isArray(match.playerParticipations)) { if (!canTrustStoredLineups) { @@ -865,9 +881,7 @@ export class MatchesService { if (!rows.length) return []; - const latestMst = Math.max( - ...rows.map((row) => Number(row.mstUtc || 0)), - ); + const latestMst = Math.max(...rows.map((row) => Number(row.mstUtc || 0))); const ageDays = latestMst > 0 ? (beforeDateMs - latestMst) / (24 * 60 * 60 * 1000) @@ -901,8 +915,7 @@ export class MatchesService { const rank = matchOrder.get(String(row.matchId)) ?? matchLimit; const recencyWeight = Math.max(1, matchLimit - rank); - const score = - recencyWeight + (rank === 0 ? 3 : rank === 1 ? 1.5 : 0); + const score = recencyWeight + (rank === 0 ? 3 : rank === 1 ? 1.5 : 0); const existing = playerMap.get(playerId); if (!existing) { @@ -996,9 +1009,7 @@ export class MatchesService { private canTrustStoredLineups(displayStatus?: string): boolean { const normalized = String(displayStatus || "").toLowerCase(); return ( - normalized === "live" || - normalized === "finished" || - normalized === "ft" + normalized === "live" || normalized === "finished" || normalized === "ft" ); } } diff --git a/src/modules/predictions/predictions.service.ts b/src/modules/predictions/predictions.service.ts index be06bbe..c0d611c 100755 --- a/src/modules/predictions/predictions.service.ts +++ b/src/modules/predictions/predictions.service.ts @@ -21,6 +21,10 @@ import { } from "./dto"; import { Prisma } from "@prisma/client"; import { FeederService } from "../feeder/feeder.service"; +import { + isMatchCompleted, + isMatchLive, +} from "../../common/utils/match-status.util"; import * as fs from "node:fs"; import * as path from "node:path"; import { @@ -49,7 +53,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { private queueEvents: QueueEvents | null = null; private readonly aiEngineUrl: string; private readonly aiEngineClient: AiEngineClient; - private readonly topLeagueIds = new Set(); + private readonly qualifiedLeagueIds = new Set(); private readonly reasonTranslations: Record = { confidence_below_threshold: "Güven eşiğin altında", confidence_interval_too_wide: "Güven aralığı çok geniş", @@ -137,7 +141,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { maxRetries: 2, retryDelayMs: 750, }); - this.topLeagueIds = this.loadTopLeagueIds(); + this.qualifiedLeagueIds = this.loadQualifiedLeagueIds(); } onModuleInit() { @@ -155,6 +159,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { } } + private predictionMemCache = new Map< + string, + { timestamp: number; payload: MatchPredictionDto } + >(); + async onModuleDestroy() { if (this.queueEvents) { await this.queueEvents.close(); @@ -177,8 +186,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { return { status: response.data?.status || "healthy", modelLoaded: response.data?.model_loaded ?? true, - predictionServiceReady: - response.data?.prediction_service_ready ?? true, + predictionServiceReady: response.data?.prediction_service_ready ?? true, aiEngineReachable: true, circuitState: circuit.state, consecutiveFailures: circuit.consecutiveFailures, @@ -330,33 +338,38 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { match_date_ms: Number(p.match.mstUtc) * 1000, league: p.match.league?.name || "", 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; }), }; } - private loadTopLeagueIds(): Set { + private loadQualifiedLeagueIds(): Set { try { - const topLeaguesPath = path.join(process.cwd(), "top_leagues.json"); - if (!fs.existsSync(topLeaguesPath)) { + const filePath = path.join(process.cwd(), "qualified_leagues.json"); + if (!fs.existsSync(filePath)) { + this.logger.warn( + "qualified_leagues.json not found — all leagues allowed", + ); return new Set(); } - const raw = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8")); + const raw = JSON.parse(fs.readFileSync(filePath, "utf8")); if (!Array.isArray(raw)) { return new Set(); } - return new Set( + const ids = new Set( raw .map((value) => String(value).trim()) .filter((value) => value.length > 0), ); + this.logger.log(`Loaded ${ids.size} qualified league IDs`); + return ids; } catch (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(); } } @@ -370,7 +383,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { if (match) { return { 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 { 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]; } - const evMatch = normalized.match(/^ev_edge_([+\-][\d.]+%)_grade_(\w)$/); + const evMatch = normalized.match(/^ev_edge_([-+][\d.]+%)_grade_(\w)$/); if (evMatch) { return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`; } const negativeEdgeMatch = normalized.match( - /^negative_model_edge_([+\-]?[\d.]+)$/, + /^negative_model_edge_([-+]?[\d.]+)$/, ); if (negativeEdgeMatch) { return `Model avantajı negatif (${negativeEdgeMatch[1]})`; } const edgeThresholdMatch = normalized.match( - /^below_market_edge_threshold_([+\-]?[\d.]+)$/, + /^below_market_edge_threshold_([-+]?[\d.]+)$/, ); if (edgeThresholdMatch) { return `Piyasa avantaj eşiğinin altında (${edgeThresholdMatch[1]})`; @@ -1071,10 +1084,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { // Direct HTTP mode try { - const response = await this.aiEngineClient.post( - "/smart-coupon", - { match_ids: matchIds, strategy, ...options }, - ); + const response = await this.aiEngineClient.post("/smart-coupon", { + match_ids: matchIds, + strategy, + ...options, + }); return response.data; } catch (error: unknown) { const message = @@ -1130,8 +1144,26 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { } 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; try { + const existsInMatch = await this.prisma.match.findUnique({ + where: { id: matchId }, + select: { id: true }, + }); + + if (!existsInMatch) { + return; + } + await this.prisma.prediction.upsert({ where: { matchId }, update: { @@ -1151,6 +1183,16 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { async getCachedPrediction( matchId: string, ): Promise { + 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({ where: { matchId }, }); @@ -1216,32 +1258,38 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { } private async ensurePredictionDataReady(matchId: string): Promise { - const [liveMatch, persistedMatch, oddCategoryCount] = await Promise.all([ - this.prisma.liveMatch.findUnique({ - where: { id: matchId }, - select: { - id: true, - odds: true, - state: true, - status: true, - scoreHome: true, - scoreAway: true, - }, - }), - this.prisma.match.findUnique({ - where: { id: matchId }, - select: { - id: true, - state: true, - status: true, - scoreHome: true, - scoreAway: true, - }, - }), - this.prisma.oddCategory.count({ - where: { matchId }, - }), - ]); + const [liveMatch, persistedMatch, oddCategoryCount, lineupCount] = + await Promise.all([ + this.prisma.liveMatch.findUnique({ + where: { id: matchId }, + select: { + id: true, + odds: true, + state: true, + status: true, + scoreHome: true, + scoreAway: true, + leagueId: true, + }, + }), + this.prisma.match.findUnique({ + where: { id: matchId }, + select: { + id: true, + state: true, + status: true, + scoreHome: true, + scoreAway: true, + leagueId: true, + }, + }), + this.prisma.oddCategory.count({ + where: { matchId }, + }), + this.prisma.matchPlayerParticipation.count({ + where: { matchId }, + }), + ]); const hasLiveOdds = !!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 status = liveMatch?.status || persistedMatch?.status; const scoreHome = liveMatch?.scoreHome ?? persistedMatch?.scoreHome; const scoreAway = liveMatch?.scoreAway ?? persistedMatch?.scoreAway; - const hasScores = - scoreHome !== null && - scoreHome !== undefined && - scoreAway !== null && - scoreAway !== undefined; - const isFinished = - hasScores || - state === "MS" || - state === "postGame" || - ["Finished", "Played", "FT", "AET", "PEN", "Ended"].includes( - status as string, - ); + const isFinished = isMatchCompleted({ + state: state ?? null, + status: status ?? null, + scoreHome, + scoreAway, + }); + + const isLive = isMatchLive({ + state: state ?? null, + status: status ?? null, + }); 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; } @@ -1315,7 +1404,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { ); } catch (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, bet_advice: { playable: payload.bet_advice?.playable ?? false, - suggested_stake_units: - payload.bet_advice?.suggested_stake_units ?? 0, + suggested_stake_units: payload.bet_advice?.suggested_stake_units ?? 0, reason: payload.bet_advice?.reason ?? null, }, top_summary: topSummary, diff --git a/src/modules/social-poster/image-renderer.service.ts b/src/modules/social-poster/image-renderer.service.ts index 6df0efb..6d9ff9b 100644 --- a/src/modules/social-poster/image-renderer.service.ts +++ b/src/modules/social-poster/image-renderer.service.ts @@ -6,6 +6,7 @@ import axios from "axios"; let createCanvas: any; let loadImage: any; try { + // eslint-disable-next-line @typescript-eslint/no-require-imports const canvas = require("canvas"); createCanvas = canvas.createCanvas; loadImage = canvas.loadImage; @@ -397,7 +398,7 @@ export class ImageRendererService implements OnModuleInit { ctx.fillStyle = "rgba(255, 255, 255, 0.4)"; ctx.font = "700 26px sans-serif"; ctx.textAlign = "left"; - ctx.fillText("⚡ AI Powered by SuggestBet", paddingX, currentY); + ctx.fillText("⚡ AI Powered by iddaai.com", paddingX, currentY); let riskBg, riskColor, riskBorder; switch (data.riskLevel) { diff --git a/src/services/ai.service.ts b/src/services/ai.service.ts index 4e5adfc..0d3f405 100755 --- a/src/services/ai.service.ts +++ b/src/services/ai.service.ts @@ -44,9 +44,7 @@ export class AiService { private readonly pythonEngineUrl: string; private readonly aiEngineClient: AiEngineClient; - constructor( - private readonly configService: ConfigService, - ) { + constructor(private readonly configService: ConfigService) { this.pythonEngineUrl = this.configService.get("AI_ENGINE_URL") || "http://127.0.0.1:8000"; this.aiEngineClient = new AiEngineClient({ diff --git a/src/tasks/data-fetcher.task.ts b/src/tasks/data-fetcher.task.ts index 01533cd..52240b1 100755 --- a/src/tasks/data-fetcher.task.ts +++ b/src/tasks/data-fetcher.task.ts @@ -161,7 +161,8 @@ export class DataFetcherTask { `Pruned ${deleted.count} stale live matches. Starting full sync...`, ); } 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}`); return; } @@ -194,12 +195,12 @@ export class DataFetcherTask { private async syncMatchList(date: string): Promise { // Football - const footballLeagues = this.loadLeagueFilterSet("top_leagues.json"); + const footballLeagues = this.loadLeagueFilterSet("qualified_leagues.json"); if (footballLeagues && footballLeagues.size > 0) { await this.fetchMatchesForSport("football", date, footballLeagues); } else { 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()); } @@ -250,7 +251,7 @@ export class DataFetcherTask { } this.logger.log( - `📡 Updating scores for ${liveMatches.length} live matches`, + `LIVE Updating scores for ${liveMatches.length} live matches`, ); 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) { const message = error instanceof Error ? error.message : String(error); this.logger.error(`Live score update failed: ${message}`); } } - // ──────────────────────────────────────────────────────────── // Phase 3: Odds + referee + lineups + sidelined - // ──────────────────────────────────────────────────────────── private async fetchOddsForMatches(): Promise { - this.logger.log("💰 Fetching odds for live matches..."); + this.logger.log("MONEY Fetching odds for live matches..."); try { - // Load both league filters + // Load both league filters (data-driven qualified leagues) const topLeagueIds: string[] = []; - const footballLeagues = this.loadLeagueFilterSet("top_leagues.json"); + const footballLeagues = this.loadLeagueFilterSet( + "qualified_leagues.json", + ); if (footballLeagues) topLeagueIds.push(...footballLeagues); const basketballLeagues = this.loadLeagueFilterSet( @@ -337,11 +338,13 @@ export class DataFetcherTask { }); if (matchesToFetch.length === 0) { - this.logger.log("💰 No matches to fetch odds for"); + this.logger.log("MONEY No matches to fetch odds for"); return; } - this.logger.log(`💰 Fetching odds for ${matchesToFetch.length} matches`); + this.logger.log( + `MONEY Fetching odds for ${matchesToFetch.length} matches`, + ); let successCount = 0; let errorCount = 0; @@ -370,7 +373,7 @@ export class DataFetcherTask { // Retry failed matches (502/Timeout) if (failedMatches.length > 0) { this.logger.warn( - `⚠️ Retrying ${failedMatches.length} failed matches (502/Timeout)...`, + `Retrying ${failedMatches.length} failed matches (502/Timeout)...`, ); for (const match of failedMatches) { @@ -378,7 +381,7 @@ export class DataFetcherTask { try { await this.processMatchOdds(match); successCount++; - this.logger.log(`✅ Retry successful for match ${match.id}`); + this.logger.log(`SUCCESS Retry successful for match ${match.id}`); } catch (retryErr: unknown) { const message = retryErr instanceof Error ? retryErr.message : String(retryErr); @@ -390,7 +393,7 @@ export class DataFetcherTask { } this.logger.log( - `💰 Odds complete: ${successCount} success, ${errorCount} errors (initially)`, + `MONEY Odds complete: ${successCount} success, ${errorCount} errors (initially)`, ); } catch (error: unknown) { 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, // do NOT overwrite odds/lineups/sidelined — the model needs stable pre-match data. - const matchState = match.state?.toLowerCase() ?? ''; - const matchStatus = match.status?.toLowerCase() ?? ''; + const matchState = match.state?.toLowerCase() ?? ""; + const matchStatus = match.status?.toLowerCase() ?? ""; const liveStates = LIVE_STATE_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 finishedStatuses = FINISHED_STATUS_VALUES_FOR_DB.map((s) => s.toLowerCase()); + const finishedStates = FINISHED_STATE_VALUES_FOR_DB.map((s) => + s.toLowerCase(), + ); + const finishedStatuses = FINISHED_STATUS_VALUES_FOR_DB.map((s) => + s.toLowerCase(), + ); const isLiveOrFinished = liveStates.includes(matchState) || @@ -921,7 +928,7 @@ export class DataFetcherTask { const existingOdds = match.odds as Record | null; const hasExistingOdds = !!existingOdds && - typeof existingOdds === 'object' && + typeof existingOdds === "object" && Object.keys(existingOdds).length > 0; if (isLiveOrFinished && hasExistingOdds) { @@ -957,7 +964,7 @@ export class DataFetcherTask { sidelined.awayTeam.totalSidelined > 0)) ) { 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 { this.logger.debug( @@ -1334,4 +1341,3 @@ export class DataFetcherTask { return new Promise((resolve) => setTimeout(resolve, ms)); } } -