+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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<T>(config: AiEngineRequestConfig): Promise<AxiosResponse<T>> {
|
||||
private async request<T>(
|
||||
config: AiEngineRequestConfig,
|
||||
): Promise<AxiosResponse<T>> {
|
||||
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", {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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")}`;
|
||||
}
|
||||
|
||||
@@ -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<ApiResponse<Record<string, string>>> {
|
||||
const settings = await this.prisma.appSetting.findMany();
|
||||
const settingsMap: Record<string, string> = {};
|
||||
@@ -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<ApiResponse<{ count: number }>> {
|
||||
const result = await this.prisma.usageLimit.updateMany({
|
||||
data: {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -25,4 +25,3 @@ import { MatchesModule } from "../matches/matches.module";
|
||||
],
|
||||
})
|
||||
export class CouponsModule {}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -108,8 +108,7 @@ export class FrequencyEngineService {
|
||||
venue: "home" | "away",
|
||||
oddsBand: string,
|
||||
): Promise<TeamFrequencyRow | null> {
|
||||
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<LeagueProfileRow | null> {
|
||||
async getLeagueProfile(leagueId: string): Promise<LeagueProfileRow | null> {
|
||||
const rows = await this.prisma.$queryRawUnsafe<LeagueProfileRow[]>(
|
||||
`
|
||||
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<string, { min: number; max: number }> = {
|
||||
"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;
|
||||
}
|
||||
|
||||
@@ -154,9 +154,10 @@ export class SmartCouponService {
|
||||
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
|
||||
let prediction: SingleMatchPredictionPackage;
|
||||
try {
|
||||
const response = await this.aiEngineClient.post<SingleMatchPredictionPackage>(
|
||||
`/v20plus/analyze/${matchId}`,
|
||||
);
|
||||
const response =
|
||||
await this.aiEngineClient.post<SingleMatchPredictionPackage>(
|
||||
`/v20plus/analyze/${matchId}`,
|
||||
);
|
||||
prediction = response.data;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AiEngineRequestError) {
|
||||
@@ -264,7 +265,7 @@ export class SmartCouponService {
|
||||
markets?: string[];
|
||||
}): Promise<FrequencyCouponResult> {
|
||||
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(
|
||||
|
||||
@@ -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<Array<{ id: string }>>(
|
||||
`
|
||||
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<Map<string, string[]>> {
|
||||
async getMissingScopes(matchIds: string[]): Promise<Map<string, string[]>> {
|
||||
const result = new Map<string, string[]>();
|
||||
if (matchIds.length === 0) return result;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, any>;
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
private readonly qualifiedLeagueIds = new Set<string>();
|
||||
private readonly reasonTranslations: Record<string, string> = {
|
||||
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<string> {
|
||||
private loadQualifiedLeagueIds(): Set<string> {
|
||||
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<string>();
|
||||
}
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8"));
|
||||
const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
if (!Array.isArray(raw)) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
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<string>();
|
||||
}
|
||||
}
|
||||
@@ -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<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({
|
||||
where: { matchId },
|
||||
});
|
||||
@@ -1216,32 +1258,38 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
private async ensurePredictionDataReady(matchId: string): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
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<string, unknown> | 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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user