+1
-1
@@ -47,7 +47,7 @@ COPY --from=builder /app/dist ./dist
|
|||||||
COPY --from=builder /app/src/i18n ./dist/i18n
|
COPY --from=builder /app/src/i18n ./dist/i18n
|
||||||
|
|
||||||
# Copy league filter config files (critical: without these, feeder stores ALL matches)
|
# Copy league filter config files (critical: without these, feeder stores ALL matches)
|
||||||
COPY top_leagues.json basketball_top_leagues.json ./
|
COPY qualified_leagues.json top_leagues.json basketball_top_leagues.json ./
|
||||||
|
|
||||||
# Set environment
|
# Set environment
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
@@ -964,13 +964,13 @@ class SingleMatchOrchestrator:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# ── Pre-Match Simulation Mode ────────────────────────────
|
# ── Pre-Match Simulation Mode ────────────────────────────
|
||||||
# For finished (FT/postGame) matches, strip live scores so the
|
# Force all matches (live and finished) into pre-match state so the
|
||||||
# entire pipeline treats them as if they haven't kicked off yet.
|
# engine purely predicts based on pre-match odds and context, ignoring
|
||||||
# _is_live_match already returns False for FT, but this adds
|
# current live scores and preventing live state penalties.
|
||||||
# defense-in-depth against any code path that reads scores directly.
|
|
||||||
_status_upper = str(data.status or "").upper()
|
_status_upper = str(data.status or "").upper()
|
||||||
_state_upper = str(data.state or "").upper()
|
if _status_upper not in {"NS", "POSTPONED", "CANC", "ABD"}:
|
||||||
if _status_upper in {"FT", "FINISHED"} or _state_upper in {"POSTGAME", "POST_GAME"}:
|
data.status = "NS"
|
||||||
|
data.state = "preGame"
|
||||||
data.current_score_home = None
|
data.current_score_home = None
|
||||||
data.current_score_away = None
|
data.current_score_away = None
|
||||||
|
|
||||||
|
|||||||
@@ -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.maxRetries = options.maxRetries ?? 2;
|
||||||
this.retryDelayMs = options.retryDelayMs ?? 750;
|
this.retryDelayMs = options.retryDelayMs ?? 750;
|
||||||
this.circuitBreakerThreshold = options.circuitBreakerThreshold ?? 3;
|
this.circuitBreakerThreshold = options.circuitBreakerThreshold ?? 3;
|
||||||
this.circuitBreakerCooldownMs =
|
this.circuitBreakerCooldownMs = options.circuitBreakerCooldownMs ?? 30000;
|
||||||
options.circuitBreakerCooldownMs ?? 30000;
|
|
||||||
|
|
||||||
this.axiosClient = axios.create({
|
this.axiosClient = axios.create({
|
||||||
baseURL: options.baseUrl,
|
baseURL: options.baseUrl,
|
||||||
@@ -113,7 +112,9 @@ export class AiEngineClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async request<T>(config: AiEngineRequestConfig): Promise<AxiosResponse<T>> {
|
private async request<T>(
|
||||||
|
config: AiEngineRequestConfig,
|
||||||
|
): Promise<AxiosResponse<T>> {
|
||||||
this.ensureCircuitAvailable();
|
this.ensureCircuitAvailable();
|
||||||
|
|
||||||
const retries = this.resolveRetryCount(config);
|
const retries = this.resolveRetryCount(config);
|
||||||
@@ -162,7 +163,8 @@ export class AiEngineClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const remainingCooldown =
|
const remainingCooldown =
|
||||||
this.circuitBreakerCooldownMs - (Date.now() - (this.circuitOpenedAt ?? 0));
|
this.circuitBreakerCooldownMs -
|
||||||
|
(Date.now() - (this.circuitOpenedAt ?? 0));
|
||||||
|
|
||||||
if (remainingCooldown > 0) {
|
if (remainingCooldown > 0) {
|
||||||
throw new AiEngineRequestError("AI engine circuit breaker is open", {
|
throw new AiEngineRequestError("AI engine circuit breaker is open", {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export const LIVE_STATUS_VALUES_FOR_DB = [
|
|||||||
"Playing",
|
"Playing",
|
||||||
"Half Time",
|
"Half Time",
|
||||||
"liveGame",
|
"liveGame",
|
||||||
|
"minutes",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const LIVE_STATE_VALUES_FOR_DB = [
|
export const LIVE_STATE_VALUES_FOR_DB = [
|
||||||
@@ -109,6 +110,7 @@ export const FINISHED_STATUS_VALUES_FOR_DB = [
|
|||||||
"postGame",
|
"postGame",
|
||||||
"posted",
|
"posted",
|
||||||
"Posted",
|
"Posted",
|
||||||
|
"state",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const FINISHED_STATE_VALUES_FOR_DB = [
|
export const FINISHED_STATE_VALUES_FOR_DB = [
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ function extractDateParts(date: Date, timeZone: string) {
|
|||||||
return { year, month, day };
|
return { year, month, day };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDateStringInTimeZone(
|
export function getDateStringInTimeZone(date: Date, timeZone: string): string {
|
||||||
date: Date,
|
|
||||||
timeZone: string,
|
|
||||||
): string {
|
|
||||||
const { year, month, day } = extractDateParts(date, timeZone);
|
const { year, month, day } = extractDateParts(date, timeZone);
|
||||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ import {
|
|||||||
CACHE_MANAGER,
|
CACHE_MANAGER,
|
||||||
} from "@nestjs/cache-manager";
|
} from "@nestjs/cache-manager";
|
||||||
import * as cacheManager from "cache-manager";
|
import * as cacheManager from "cache-manager";
|
||||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse as SwaggerResponse } from "@nestjs/swagger";
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse as SwaggerResponse,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
import { Roles } from "../../common/decorators";
|
import { Roles } from "../../common/decorators";
|
||||||
import { PrismaService } from "../../database/prisma.service";
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
import { PaginationDto } from "../../common/dto/pagination.dto";
|
import { PaginationDto } from "../../common/dto/pagination.dto";
|
||||||
@@ -181,7 +186,10 @@ export class AdminController {
|
|||||||
@CacheKey("app_settings")
|
@CacheKey("app_settings")
|
||||||
@CacheTTL(60 * 1000)
|
@CacheTTL(60 * 1000)
|
||||||
@ApiOperation({ summary: "Get all app settings" })
|
@ApiOperation({ summary: "Get all app settings" })
|
||||||
@SwaggerResponse({ status: 200, schema: { type: "object", additionalProperties: { type: "string" } } })
|
@SwaggerResponse({
|
||||||
|
status: 200,
|
||||||
|
schema: { type: "object", additionalProperties: { type: "string" } },
|
||||||
|
})
|
||||||
async getAllSettings(): Promise<ApiResponse<Record<string, string>>> {
|
async getAllSettings(): Promise<ApiResponse<Record<string, string>>> {
|
||||||
const settings = await this.prisma.appSetting.findMany();
|
const settings = await this.prisma.appSetting.findMany();
|
||||||
const settingsMap: Record<string, string> = {};
|
const settingsMap: Record<string, string> = {};
|
||||||
@@ -193,7 +201,13 @@ export class AdminController {
|
|||||||
|
|
||||||
@Put("settings/:key")
|
@Put("settings/:key")
|
||||||
@ApiOperation({ summary: "Update an app setting" })
|
@ApiOperation({ summary: "Update an app setting" })
|
||||||
@SwaggerResponse({ status: 200, schema: { type: "object", properties: { key: { type: "string" }, value: { type: "string" } } } })
|
@SwaggerResponse({
|
||||||
|
status: 200,
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { key: { type: "string" }, value: { type: "string" } },
|
||||||
|
},
|
||||||
|
})
|
||||||
async updateSetting(
|
async updateSetting(
|
||||||
@Param("key") key: string,
|
@Param("key") key: string,
|
||||||
@Body() data: { value: string },
|
@Body() data: { value: string },
|
||||||
@@ -214,7 +228,10 @@ export class AdminController {
|
|||||||
|
|
||||||
@Get("usage-limits")
|
@Get("usage-limits")
|
||||||
@ApiOperation({ summary: "Get all usage limits" })
|
@ApiOperation({ summary: "Get all usage limits" })
|
||||||
@SwaggerResponse({ status: 200, schema: { type: "array", items: { type: "object" } } })
|
@SwaggerResponse({
|
||||||
|
status: 200,
|
||||||
|
schema: { type: "array", items: { type: "object" } },
|
||||||
|
})
|
||||||
async getAllUsageLimits(@Query() pagination: PaginationDto) {
|
async getAllUsageLimits(@Query() pagination: PaginationDto) {
|
||||||
const { skip, take } = pagination;
|
const { skip, take } = pagination;
|
||||||
|
|
||||||
@@ -242,7 +259,10 @@ export class AdminController {
|
|||||||
|
|
||||||
@Post("usage-limits/reset-all")
|
@Post("usage-limits/reset-all")
|
||||||
@ApiOperation({ summary: "Reset all usage limits" })
|
@ApiOperation({ summary: "Reset all usage limits" })
|
||||||
@SwaggerResponse({ status: 200, schema: { type: "object", properties: { count: { type: "number" } } } })
|
@SwaggerResponse({
|
||||||
|
status: 200,
|
||||||
|
schema: { type: "object", properties: { count: { type: "number" } } },
|
||||||
|
})
|
||||||
async resetAllUsageLimits(): Promise<ApiResponse<{ count: number }>> {
|
async resetAllUsageLimits(): Promise<ApiResponse<{ count: number }>> {
|
||||||
const result = await this.prisma.usageLimit.updateMany({
|
const result = await this.prisma.usageLimit.updateMany({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -94,11 +94,8 @@ export class RolesGuard implements CanActivate {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedUserRoles = (user.roles?.length
|
const normalizedUserRoles = (
|
||||||
? user.roles
|
user.roles?.length ? user.roles : user.role ? [user.role] : []
|
||||||
: user.role
|
|
||||||
? [user.role]
|
|
||||||
: []
|
|
||||||
).map((role) => normalizeRole(role));
|
).map((role) => normalizeRole(role));
|
||||||
|
|
||||||
const normalizedRequiredRoles = requiredRoles.map((role) =>
|
const normalizedRequiredRoles = requiredRoles.map((role) =>
|
||||||
|
|||||||
@@ -25,4 +25,3 @@ import { MatchesModule } from "../matches/matches.module";
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CouponsModule {}
|
export class CouponsModule {}
|
||||||
|
|
||||||
|
|||||||
@@ -109,8 +109,7 @@ export class FrequencyCouponDto {
|
|||||||
minSignal?: number;
|
minSignal?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description: "Filter markets: OU1.5, OU2.5, OU3.5, BTTS, MS (default: all)",
|
||||||
"Filter markets: OU1.5, OU2.5, OU3.5, BTTS, MS (default: all)",
|
|
||||||
example: ["OU2.5", "BTTS"],
|
example: ["OU2.5", "BTTS"],
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -108,8 +108,7 @@ export class FrequencyEngineService {
|
|||||||
venue: "home" | "away",
|
venue: "home" | "away",
|
||||||
oddsBand: string,
|
oddsBand: string,
|
||||||
): Promise<TeamFrequencyRow | null> {
|
): Promise<TeamFrequencyRow | null> {
|
||||||
const venueColumn =
|
const venueColumn = venue === "home" ? "m.home_team_id" : "m.away_team_id";
|
||||||
venue === "home" ? "m.home_team_id" : "m.away_team_id";
|
|
||||||
const oddsSelection = venue === "home" ? "'1'" : "'2'";
|
const oddsSelection = venue === "home" ? "'1'" : "'2'";
|
||||||
const bandRange = this.parseBandRange(oddsBand);
|
const bandRange = this.parseBandRange(oddsBand);
|
||||||
|
|
||||||
@@ -191,7 +190,7 @@ export class FrequencyEngineService {
|
|||||||
|
|
||||||
// OU 1.5 OVER
|
// OU 1.5 OVER
|
||||||
const ou15Combined = (homeFreq.ou15_rate + awayFreq.ou15_rate) / 2;
|
const ou15Combined = (homeFreq.ou15_rate + awayFreq.ou15_rate) / 2;
|
||||||
if (ou15Combined >= 0.80) {
|
if (ou15Combined >= 0.8) {
|
||||||
signals.push({
|
signals.push({
|
||||||
market: "OU1.5_OVER",
|
market: "OU1.5_OVER",
|
||||||
pick: "1.5 UST",
|
pick: "1.5 UST",
|
||||||
@@ -212,7 +211,7 @@ export class FrequencyEngineService {
|
|||||||
|
|
||||||
// OU 2.5 OVER
|
// OU 2.5 OVER
|
||||||
const ou25Combined = (homeFreq.ou25_rate + awayFreq.ou25_rate) / 2;
|
const ou25Combined = (homeFreq.ou25_rate + awayFreq.ou25_rate) / 2;
|
||||||
if (ou25Combined >= 0.60) {
|
if (ou25Combined >= 0.6) {
|
||||||
signals.push({
|
signals.push({
|
||||||
market: "OU2.5_OVER",
|
market: "OU2.5_OVER",
|
||||||
pick: "2.5 UST",
|
pick: "2.5 UST",
|
||||||
@@ -233,7 +232,7 @@ export class FrequencyEngineService {
|
|||||||
|
|
||||||
// OU 3.5 OVER
|
// OU 3.5 OVER
|
||||||
const ou35Combined = (homeFreq.ou35_rate + awayFreq.ou35_rate) / 2;
|
const ou35Combined = (homeFreq.ou35_rate + awayFreq.ou35_rate) / 2;
|
||||||
if (ou35Combined >= 0.50) {
|
if (ou35Combined >= 0.5) {
|
||||||
signals.push({
|
signals.push({
|
||||||
market: "OU3.5_OVER",
|
market: "OU3.5_OVER",
|
||||||
pick: "3.5 UST",
|
pick: "3.5 UST",
|
||||||
@@ -254,7 +253,7 @@ export class FrequencyEngineService {
|
|||||||
|
|
||||||
// BTTS YES
|
// BTTS YES
|
||||||
const bttsCombined = (homeFreq.btts_rate + awayFreq.btts_rate) / 2;
|
const bttsCombined = (homeFreq.btts_rate + awayFreq.btts_rate) / 2;
|
||||||
if (bttsCombined >= 0.60) {
|
if (bttsCombined >= 0.6) {
|
||||||
signals.push({
|
signals.push({
|
||||||
market: "BTTS_YES",
|
market: "BTTS_YES",
|
||||||
pick: "KG VAR",
|
pick: "KG VAR",
|
||||||
@@ -299,7 +298,7 @@ export class FrequencyEngineService {
|
|||||||
const hwCombined = (homeFreq.win_rate + awayFreq.win_rate) / 2;
|
const hwCombined = (homeFreq.win_rate + awayFreq.win_rate) / 2;
|
||||||
// awayFreq.win_rate aslında deplasman takımının KAYBETme oranı
|
// awayFreq.win_rate aslında deplasman takımının KAYBETme oranı
|
||||||
// (away takımı o bandda maçları kazanma değil, kaybetme olarak bak)
|
// (away takımı o bandda maçları kazanma değil, kaybetme olarak bak)
|
||||||
if (hwCombined >= 0.70 && homeOdds > 1.10 && homeOdds < 3.50) {
|
if (hwCombined >= 0.7 && homeOdds > 1.1 && homeOdds < 3.5) {
|
||||||
signals.push({
|
signals.push({
|
||||||
market: "MS_HOME",
|
market: "MS_HOME",
|
||||||
pick: "MS 1",
|
pick: "MS 1",
|
||||||
@@ -411,9 +410,7 @@ export class FrequencyEngineService {
|
|||||||
/**
|
/**
|
||||||
* Lig bazlı gol profili.
|
* Lig bazlı gol profili.
|
||||||
*/
|
*/
|
||||||
async getLeagueProfile(
|
async getLeagueProfile(leagueId: string): Promise<LeagueProfileRow | null> {
|
||||||
leagueId: string,
|
|
||||||
): Promise<LeagueProfileRow | null> {
|
|
||||||
const rows = await this.prisma.$queryRawUnsafe<LeagueProfileRow[]>(
|
const rows = await this.prisma.$queryRawUnsafe<LeagueProfileRow[]>(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -521,9 +518,7 @@ export class FrequencyEngineService {
|
|||||||
return "6.00+";
|
return "6.00+";
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseBandRange(
|
private parseBandRange(band: string): { min: number; max: number } | null {
|
||||||
band: string,
|
|
||||||
): { min: number; max: number } | null {
|
|
||||||
const map: Record<string, { min: number; max: number }> = {
|
const map: Record<string, { min: number; max: number }> = {
|
||||||
"1.00-1.30": { min: 1.0, max: 1.3 },
|
"1.00-1.30": { min: 1.0, max: 1.3 },
|
||||||
"1.30-1.50": { min: 1.3, max: 1.5 },
|
"1.30-1.50": { min: 1.3, max: 1.5 },
|
||||||
@@ -537,9 +532,7 @@ export class FrequencyEngineService {
|
|||||||
return map[band] || null;
|
return map[band] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private calculateLeagueBonus(
|
private calculateLeagueBonus(profile: LeagueProfileRow | null): number {
|
||||||
profile: LeagueProfileRow | null,
|
|
||||||
): number {
|
|
||||||
if (!profile || profile.total_matches < 20) {
|
if (!profile || profile.total_matches < 20) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,9 +154,10 @@ export class SmartCouponService {
|
|||||||
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
|
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
|
||||||
let prediction: SingleMatchPredictionPackage;
|
let prediction: SingleMatchPredictionPackage;
|
||||||
try {
|
try {
|
||||||
const response = await this.aiEngineClient.post<SingleMatchPredictionPackage>(
|
const response =
|
||||||
`/v20plus/analyze/${matchId}`,
|
await this.aiEngineClient.post<SingleMatchPredictionPackage>(
|
||||||
);
|
`/v20plus/analyze/${matchId}`,
|
||||||
|
);
|
||||||
prediction = response.data;
|
prediction = response.data;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof AiEngineRequestError) {
|
if (error instanceof AiEngineRequestError) {
|
||||||
@@ -264,7 +265,7 @@ export class SmartCouponService {
|
|||||||
markets?: string[];
|
markets?: string[];
|
||||||
}): Promise<FrequencyCouponResult> {
|
}): Promise<FrequencyCouponResult> {
|
||||||
const maxMatches = options.maxMatches ?? 3;
|
const maxMatches = options.maxMatches ?? 3;
|
||||||
const minSignal = options.minSignal ?? 0.70;
|
const minSignal = options.minSignal ?? 0.7;
|
||||||
const allowedMarkets = options.markets?.map((m) => m.toUpperCase()) || null;
|
const allowedMarkets = options.markets?.map((m) => m.toUpperCase()) || null;
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
|
|||||||
@@ -858,9 +858,7 @@ export class FeederPersistenceService {
|
|||||||
// Use raw SQL for performance — Prisma's { some: {} } relation filters
|
// Use raw SQL for performance — Prisma's { some: {} } relation filters
|
||||||
// generate heavy correlated subqueries that hang on Raspberry Pi with
|
// generate heavy correlated subqueries that hang on Raspberry Pi with
|
||||||
// large tables (15M+ odd_selections, 3M+ participations).
|
// large tables (15M+ odd_selections, 3M+ participations).
|
||||||
const result = await this.prisma.$queryRawUnsafe<
|
const result = await this.prisma.$queryRawUnsafe<Array<{ id: string }>>(
|
||||||
Array<{ id: string }>
|
|
||||||
>(
|
|
||||||
`
|
`
|
||||||
SELECT m.id
|
SELECT m.id
|
||||||
FROM matches m
|
FROM matches m
|
||||||
@@ -888,9 +886,7 @@ export class FeederPersistenceService {
|
|||||||
* returns which data scopes are missing per match.
|
* returns which data scopes are missing per match.
|
||||||
* Only checks completed (Ended) football/basketball matches.
|
* Only checks completed (Ended) football/basketball matches.
|
||||||
*/
|
*/
|
||||||
async getMissingScopes(
|
async getMissingScopes(matchIds: string[]): Promise<Map<string, string[]>> {
|
||||||
matchIds: string[],
|
|
||||||
): Promise<Map<string, string[]>> {
|
|
||||||
const result = new Map<string, string[]>();
|
const result = new Map<string, string[]>();
|
||||||
if (matchIds.length === 0) return result;
|
if (matchIds.length === 0) return result;
|
||||||
|
|
||||||
|
|||||||
@@ -324,8 +324,8 @@ export class FeederService {
|
|||||||
const sample = allMatches.slice(0, 3);
|
const sample = allMatches.slice(0, 3);
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`[${sport}] [${dateString}] DEBUG: bounds=[${targetDateStartTs}, ${targetDateEndTs}] ` +
|
`[${sport}] [${dateString}] DEBUG: bounds=[${targetDateStartTs}, ${targetDateEndTs}] ` +
|
||||||
`(${new Date(targetDateStartTs * 1000).toISOString()} - ${new Date(targetDateEndTs * 1000).toISOString()}) | ` +
|
`(${new Date(targetDateStartTs * 1000).toISOString()} - ${new Date(targetDateEndTs * 1000).toISOString()}) | ` +
|
||||||
`sampleMstUtc=[${sample.map((m) => `${m.mstUtc} (asSec=${new Date(m.mstUtc * 1000).toISOString()}, asMs=${new Date(m.mstUtc).toISOString()})`).join(', ')}]`,
|
`sampleMstUtc=[${sample.map((m) => `${m.mstUtc} (asSec=${new Date(m.mstUtc * 1000).toISOString()}, asMs=${new Date(m.mstUtc).toISOString()})`).join(", ")}]`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,7 +393,8 @@ export class FeederService {
|
|||||||
|
|
||||||
// ── Patch incomplete existing matches ──────────────────────
|
// ── Patch incomplete existing matches ──────────────────────
|
||||||
// Find matches that ARE in DB but have missing data scopes
|
// Find matches that ARE in DB but have missing data scopes
|
||||||
const allExistingInDb = await this.persistenceService.getMissingScopes(allIds);
|
const allExistingInDb =
|
||||||
|
await this.persistenceService.getMissingScopes(allIds);
|
||||||
if (allExistingInDb.size > 0) {
|
if (allExistingInDb.size > 0) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[${sport}] [${dateString}] 🔧 Found ${allExistingInDb.size} existing matches with missing data. Patching...`,
|
`[${sport}] [${dateString}] 🔧 Found ${allExistingInDb.size} existing matches with missing data. Patching...`,
|
||||||
@@ -407,7 +408,11 @@ export class FeederService {
|
|||||||
await this.delay(500);
|
await this.delay(500);
|
||||||
try {
|
try {
|
||||||
const patchScope: "all" | "lineups" | "odds" =
|
const patchScope: "all" | "lineups" | "odds" =
|
||||||
scope === "odds" ? "odds" : scope === "lineups" ? "lineups" : "all";
|
scope === "odds"
|
||||||
|
? "odds"
|
||||||
|
: scope === "lineups"
|
||||||
|
? "lineups"
|
||||||
|
: "all";
|
||||||
|
|
||||||
const result = await this.processSingleMatch(
|
const result = await this.processSingleMatch(
|
||||||
matchSummary,
|
matchSummary,
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ export class HealthController {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
return {
|
return {
|
||||||
status: "down",
|
status: "down",
|
||||||
detail: error instanceof Error ? error.message : "Unknown database error",
|
detail:
|
||||||
|
error instanceof Error ? error.message : "Unknown database error",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,9 +165,24 @@ export class LeaguesController {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "id", description: "Team ID" })
|
@ApiParam({ name: "id", description: "Team ID" })
|
||||||
@ApiQuery({ name: "page", required: false, type: Number, description: "Page number (default: 1)" })
|
@ApiQuery({
|
||||||
@ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page (default: 20)" })
|
name: "page",
|
||||||
@ApiQuery({ name: "season", required: false, type: String, description: "Season (e.g. 2024-2025)" })
|
required: false,
|
||||||
|
type: Number,
|
||||||
|
description: "Page number (default: 1)",
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: "limit",
|
||||||
|
required: false,
|
||||||
|
type: Number,
|
||||||
|
description: "Items per page (default: 20)",
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: "season",
|
||||||
|
required: false,
|
||||||
|
type: String,
|
||||||
|
description: "Season (e.g. 2024-2025)",
|
||||||
|
})
|
||||||
async getTeamMatches(
|
async getTeamMatches(
|
||||||
@Param("id") id: string,
|
@Param("id") id: string,
|
||||||
@Query("page") page?: string,
|
@Query("page") page?: string,
|
||||||
@@ -178,7 +193,7 @@ export class LeaguesController {
|
|||||||
id,
|
id,
|
||||||
parseInt(page || "1", 10),
|
parseInt(page || "1", 10),
|
||||||
parseInt(limit || "20", 10),
|
parseInt(limit || "20", 10),
|
||||||
season
|
season,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export class LeaguesService {
|
|||||||
teamId: string,
|
teamId: string,
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 20,
|
limit: number = 20,
|
||||||
season?: string
|
season?: string,
|
||||||
) {
|
) {
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
const where: any = {
|
const where: any = {
|
||||||
@@ -118,13 +118,15 @@ export class LeaguesService {
|
|||||||
if (parts.length === 2) {
|
if (parts.length === 2) {
|
||||||
const startYear = parseInt(parts[0], 10);
|
const startYear = parseInt(parts[0], 10);
|
||||||
const endYear = parseInt(parts[1], 10);
|
const endYear = parseInt(parts[1], 10);
|
||||||
|
|
||||||
if (!isNaN(startYear) && !isNaN(endYear)) {
|
if (!isNaN(startYear) && !isNaN(endYear)) {
|
||||||
// Season starts August 1st of startYear
|
// Season starts August 1st of startYear
|
||||||
const startDate = new Date(Date.UTC(startYear, 7, 1)).getTime();
|
const startDate = new Date(Date.UTC(startYear, 7, 1)).getTime();
|
||||||
// Season ends July 31st of endYear
|
// Season ends July 31st of endYear
|
||||||
const endDate = new Date(Date.UTC(endYear, 6, 31, 23, 59, 59, 999)).getTime();
|
const endDate = new Date(
|
||||||
|
Date.UTC(endYear, 6, 31, 23, 59, 59, 999),
|
||||||
|
).getTime();
|
||||||
|
|
||||||
where.mstUtc = {
|
where.mstUtc = {
|
||||||
gte: startDate,
|
gte: startDate,
|
||||||
lte: endDate,
|
lte: endDate,
|
||||||
|
|||||||
@@ -587,12 +587,19 @@ export class MatchesService {
|
|||||||
// Fill missing relations with empty arrays
|
// Fill missing relations with empty arrays
|
||||||
teamStats: [],
|
teamStats: [],
|
||||||
playerParticipations: (() => {
|
playerParticipations: (() => {
|
||||||
const parsed: Array<{ teamId: string; isStarting: boolean; shirtNumber: string | number | null; position: string | null; player: { id: string; name: string } }> = [];
|
const parsed: Array<{
|
||||||
const canTrustFeedLineups = displayStatus === "LIVE" || displayStatus === "Finished";
|
teamId: string;
|
||||||
|
isStarting: boolean;
|
||||||
|
shirtNumber: string | number | null;
|
||||||
|
position: string | null;
|
||||||
|
player: { id: string; name: string };
|
||||||
|
}> = [];
|
||||||
|
const canTrustFeedLineups =
|
||||||
|
displayStatus === "LIVE" || displayStatus === "Finished";
|
||||||
if (!canTrustFeedLineups) {
|
if (!canTrustFeedLineups) {
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
if (liveMatch.lineups && typeof liveMatch.lineups === 'object') {
|
if (liveMatch.lineups && typeof liveMatch.lineups === "object") {
|
||||||
const lu = liveMatch.lineups as Record<string, any>;
|
const lu = liveMatch.lineups as Record<string, any>;
|
||||||
const addPlayers = (teamLu: any, teamId: string | null) => {
|
const addPlayers = (teamLu: any, teamId: string | null) => {
|
||||||
if (!teamLu || !teamId) return;
|
if (!teamLu || !teamId) return;
|
||||||
@@ -603,7 +610,11 @@ export class MatchesService {
|
|||||||
isStarting: true,
|
isStarting: true,
|
||||||
shirtNumber: p.shirtNumber || p.number,
|
shirtNumber: p.shirtNumber || p.number,
|
||||||
position: p.position || p.pos,
|
position: p.position || p.pos,
|
||||||
player: { id: p.personId || p.id || p.playerId || 'unknown', name: p.matchName || p.name || p.playerName || 'Bilinmiyor' }
|
player: {
|
||||||
|
id: p.personId || p.id || p.playerId || "unknown",
|
||||||
|
name:
|
||||||
|
p.matchName || p.name || p.playerName || "Bilinmiyor",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -614,7 +625,11 @@ export class MatchesService {
|
|||||||
isStarting: false,
|
isStarting: false,
|
||||||
shirtNumber: p.shirtNumber || p.number,
|
shirtNumber: p.shirtNumber || p.number,
|
||||||
position: p.position || p.pos,
|
position: p.position || p.pos,
|
||||||
player: { id: p.personId || p.id || p.playerId || 'unknown', name: p.matchName || p.name || p.playerName || 'Bilinmiyor' }
|
player: {
|
||||||
|
id: p.personId || p.id || p.playerId || "unknown",
|
||||||
|
name:
|
||||||
|
p.matchName || p.name || p.playerName || "Bilinmiyor",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -641,7 +656,8 @@ export class MatchesService {
|
|||||||
scoreHome: match.scoreHome,
|
scoreHome: match.scoreHome,
|
||||||
scoreAway: match.scoreAway,
|
scoreAway: match.scoreAway,
|
||||||
});
|
});
|
||||||
const canTrustStoredLineups = this.canTrustStoredLineups(detailDisplayStatus);
|
const canTrustStoredLineups =
|
||||||
|
this.canTrustStoredLineups(detailDisplayStatus);
|
||||||
|
|
||||||
if (Array.isArray(match.playerParticipations)) {
|
if (Array.isArray(match.playerParticipations)) {
|
||||||
if (!canTrustStoredLineups) {
|
if (!canTrustStoredLineups) {
|
||||||
@@ -865,9 +881,7 @@ export class MatchesService {
|
|||||||
|
|
||||||
if (!rows.length) return [];
|
if (!rows.length) return [];
|
||||||
|
|
||||||
const latestMst = Math.max(
|
const latestMst = Math.max(...rows.map((row) => Number(row.mstUtc || 0)));
|
||||||
...rows.map((row) => Number(row.mstUtc || 0)),
|
|
||||||
);
|
|
||||||
const ageDays =
|
const ageDays =
|
||||||
latestMst > 0
|
latestMst > 0
|
||||||
? (beforeDateMs - latestMst) / (24 * 60 * 60 * 1000)
|
? (beforeDateMs - latestMst) / (24 * 60 * 60 * 1000)
|
||||||
@@ -901,8 +915,7 @@ export class MatchesService {
|
|||||||
|
|
||||||
const rank = matchOrder.get(String(row.matchId)) ?? matchLimit;
|
const rank = matchOrder.get(String(row.matchId)) ?? matchLimit;
|
||||||
const recencyWeight = Math.max(1, matchLimit - rank);
|
const recencyWeight = Math.max(1, matchLimit - rank);
|
||||||
const score =
|
const score = recencyWeight + (rank === 0 ? 3 : rank === 1 ? 1.5 : 0);
|
||||||
recencyWeight + (rank === 0 ? 3 : rank === 1 ? 1.5 : 0);
|
|
||||||
const existing = playerMap.get(playerId);
|
const existing = playerMap.get(playerId);
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -996,9 +1009,7 @@ export class MatchesService {
|
|||||||
private canTrustStoredLineups(displayStatus?: string): boolean {
|
private canTrustStoredLineups(displayStatus?: string): boolean {
|
||||||
const normalized = String(displayStatus || "").toLowerCase();
|
const normalized = String(displayStatus || "").toLowerCase();
|
||||||
return (
|
return (
|
||||||
normalized === "live" ||
|
normalized === "live" || normalized === "finished" || normalized === "ft"
|
||||||
normalized === "finished" ||
|
|
||||||
normalized === "ft"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import {
|
|||||||
} from "./dto";
|
} from "./dto";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { FeederService } from "../feeder/feeder.service";
|
import { FeederService } from "../feeder/feeder.service";
|
||||||
|
import {
|
||||||
|
isMatchCompleted,
|
||||||
|
isMatchLive,
|
||||||
|
} from "../../common/utils/match-status.util";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import {
|
import {
|
||||||
@@ -49,7 +53,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private queueEvents: QueueEvents | null = null;
|
private queueEvents: QueueEvents | null = null;
|
||||||
private readonly aiEngineUrl: string;
|
private readonly aiEngineUrl: string;
|
||||||
private readonly aiEngineClient: AiEngineClient;
|
private readonly aiEngineClient: AiEngineClient;
|
||||||
private readonly topLeagueIds = new Set<string>();
|
private readonly qualifiedLeagueIds = new Set<string>();
|
||||||
private readonly reasonTranslations: Record<string, string> = {
|
private readonly reasonTranslations: Record<string, string> = {
|
||||||
confidence_below_threshold: "Güven eşiğin altında",
|
confidence_below_threshold: "Güven eşiğin altında",
|
||||||
confidence_interval_too_wide: "Güven aralığı çok geniş",
|
confidence_interval_too_wide: "Güven aralığı çok geniş",
|
||||||
@@ -137,7 +141,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
maxRetries: 2,
|
maxRetries: 2,
|
||||||
retryDelayMs: 750,
|
retryDelayMs: 750,
|
||||||
});
|
});
|
||||||
this.topLeagueIds = this.loadTopLeagueIds();
|
this.qualifiedLeagueIds = this.loadQualifiedLeagueIds();
|
||||||
}
|
}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
@@ -155,6 +159,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private predictionMemCache = new Map<
|
||||||
|
string,
|
||||||
|
{ timestamp: number; payload: MatchPredictionDto }
|
||||||
|
>();
|
||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
if (this.queueEvents) {
|
if (this.queueEvents) {
|
||||||
await this.queueEvents.close();
|
await this.queueEvents.close();
|
||||||
@@ -177,8 +186,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return {
|
return {
|
||||||
status: response.data?.status || "healthy",
|
status: response.data?.status || "healthy",
|
||||||
modelLoaded: response.data?.model_loaded ?? true,
|
modelLoaded: response.data?.model_loaded ?? true,
|
||||||
predictionServiceReady:
|
predictionServiceReady: response.data?.prediction_service_ready ?? true,
|
||||||
response.data?.prediction_service_ready ?? true,
|
|
||||||
aiEngineReachable: true,
|
aiEngineReachable: true,
|
||||||
circuitState: circuit.state,
|
circuitState: circuit.state,
|
||||||
consecutiveFailures: circuit.consecutiveFailures,
|
consecutiveFailures: circuit.consecutiveFailures,
|
||||||
@@ -330,33 +338,38 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
match_date_ms: Number(p.match.mstUtc) * 1000,
|
match_date_ms: Number(p.match.mstUtc) * 1000,
|
||||||
league: p.match.league?.name || "",
|
league: p.match.league?.name || "",
|
||||||
league_id: p.match.leagueId,
|
league_id: p.match.leagueId,
|
||||||
is_top_league: this.topLeagueIds.has(p.match.leagueId ?? ""),
|
is_top_league: this.qualifiedLeagueIds.has(p.match.leagueId ?? ""),
|
||||||
},
|
},
|
||||||
} as unknown as MatchPredictionDto;
|
} as unknown as MatchPredictionDto;
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadTopLeagueIds(): Set<string> {
|
private loadQualifiedLeagueIds(): Set<string> {
|
||||||
try {
|
try {
|
||||||
const topLeaguesPath = path.join(process.cwd(), "top_leagues.json");
|
const filePath = path.join(process.cwd(), "qualified_leagues.json");
|
||||||
if (!fs.existsSync(topLeaguesPath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
|
this.logger.warn(
|
||||||
|
"qualified_leagues.json not found — all leagues allowed",
|
||||||
|
);
|
||||||
return new Set<string>();
|
return new Set<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8"));
|
const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
if (!Array.isArray(raw)) {
|
if (!Array.isArray(raw)) {
|
||||||
return new Set<string>();
|
return new Set<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Set(
|
const ids = new Set(
|
||||||
raw
|
raw
|
||||||
.map((value) => String(value).trim())
|
.map((value) => String(value).trim())
|
||||||
.filter((value) => value.length > 0),
|
.filter((value) => value.length > 0),
|
||||||
);
|
);
|
||||||
|
this.logger.log(`Loaded ${ids.size} qualified league IDs`);
|
||||||
|
return ids;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
this.logger.warn(`Failed to load top_leagues.json: ${message}`);
|
this.logger.warn(`Failed to load qualified_leagues.json: ${message}`);
|
||||||
return new Set<string>();
|
return new Set<string>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,7 +383,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (match) {
|
if (match) {
|
||||||
return {
|
return {
|
||||||
leagueId: match.leagueId ?? null,
|
leagueId: match.leagueId ?? null,
|
||||||
isTopLeague: this.topLeagueIds.has(match.leagueId ?? ""),
|
isTopLeague: this.qualifiedLeagueIds.has(match.leagueId ?? ""),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,7 +394,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
leagueId: liveMatch?.leagueId ?? null,
|
leagueId: liveMatch?.leagueId ?? null,
|
||||||
isTopLeague: this.topLeagueIds.has(liveMatch?.leagueId ?? ""),
|
isTopLeague: this.qualifiedLeagueIds.has(liveMatch?.leagueId ?? ""),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,20 +744,20 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return this.reasonTranslations[normalized];
|
return this.reasonTranslations[normalized];
|
||||||
}
|
}
|
||||||
|
|
||||||
const evMatch = normalized.match(/^ev_edge_([+\-][\d.]+%)_grade_(\w)$/);
|
const evMatch = normalized.match(/^ev_edge_([-+][\d.]+%)_grade_(\w)$/);
|
||||||
if (evMatch) {
|
if (evMatch) {
|
||||||
return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`;
|
return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const negativeEdgeMatch = normalized.match(
|
const negativeEdgeMatch = normalized.match(
|
||||||
/^negative_model_edge_([+\-]?[\d.]+)$/,
|
/^negative_model_edge_([-+]?[\d.]+)$/,
|
||||||
);
|
);
|
||||||
if (negativeEdgeMatch) {
|
if (negativeEdgeMatch) {
|
||||||
return `Model avantajı negatif (${negativeEdgeMatch[1]})`;
|
return `Model avantajı negatif (${negativeEdgeMatch[1]})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const edgeThresholdMatch = normalized.match(
|
const edgeThresholdMatch = normalized.match(
|
||||||
/^below_market_edge_threshold_([+\-]?[\d.]+)$/,
|
/^below_market_edge_threshold_([-+]?[\d.]+)$/,
|
||||||
);
|
);
|
||||||
if (edgeThresholdMatch) {
|
if (edgeThresholdMatch) {
|
||||||
return `Piyasa avantaj eşiğinin altında (${edgeThresholdMatch[1]})`;
|
return `Piyasa avantaj eşiğinin altında (${edgeThresholdMatch[1]})`;
|
||||||
@@ -1071,10 +1084,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
// Direct HTTP mode
|
// Direct HTTP mode
|
||||||
try {
|
try {
|
||||||
const response = await this.aiEngineClient.post(
|
const response = await this.aiEngineClient.post("/smart-coupon", {
|
||||||
"/smart-coupon",
|
match_ids: matchIds,
|
||||||
{ match_ids: matchIds, strategy, ...options },
|
strategy,
|
||||||
);
|
...options,
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message =
|
const message =
|
||||||
@@ -1130,8 +1144,26 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async cachePrediction(matchId: string, prediction: MatchPredictionDto) {
|
async cachePrediction(matchId: string, prediction: MatchPredictionDto) {
|
||||||
|
this.predictionMemCache.set(matchId, {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
payload: prediction,
|
||||||
|
});
|
||||||
|
if (this.predictionMemCache.size > 500) {
|
||||||
|
const firstKey = this.predictionMemCache.keys().next().value;
|
||||||
|
if (firstKey) this.predictionMemCache.delete(firstKey);
|
||||||
|
}
|
||||||
|
|
||||||
const payload = prediction as unknown as Prisma.InputJsonObject;
|
const payload = prediction as unknown as Prisma.InputJsonObject;
|
||||||
try {
|
try {
|
||||||
|
const existsInMatch = await this.prisma.match.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existsInMatch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.prisma.prediction.upsert({
|
await this.prisma.prediction.upsert({
|
||||||
where: { matchId },
|
where: { matchId },
|
||||||
update: {
|
update: {
|
||||||
@@ -1151,6 +1183,16 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
async getCachedPrediction(
|
async getCachedPrediction(
|
||||||
matchId: string,
|
matchId: string,
|
||||||
): Promise<MatchPredictionDto | null> {
|
): Promise<MatchPredictionDto | null> {
|
||||||
|
const memCached = this.predictionMemCache.get(matchId);
|
||||||
|
if (memCached) {
|
||||||
|
if (Date.now() - memCached.timestamp < 10 * 60 * 1000) {
|
||||||
|
// 10 mins TTL
|
||||||
|
return memCached.payload;
|
||||||
|
} else {
|
||||||
|
this.predictionMemCache.delete(matchId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const prediction = await this.prisma.prediction.findUnique({
|
const prediction = await this.prisma.prediction.findUnique({
|
||||||
where: { matchId },
|
where: { matchId },
|
||||||
});
|
});
|
||||||
@@ -1216,32 +1258,38 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async ensurePredictionDataReady(matchId: string): Promise<void> {
|
private async ensurePredictionDataReady(matchId: string): Promise<void> {
|
||||||
const [liveMatch, persistedMatch, oddCategoryCount] = await Promise.all([
|
const [liveMatch, persistedMatch, oddCategoryCount, lineupCount] =
|
||||||
this.prisma.liveMatch.findUnique({
|
await Promise.all([
|
||||||
where: { id: matchId },
|
this.prisma.liveMatch.findUnique({
|
||||||
select: {
|
where: { id: matchId },
|
||||||
id: true,
|
select: {
|
||||||
odds: true,
|
id: true,
|
||||||
state: true,
|
odds: true,
|
||||||
status: true,
|
state: true,
|
||||||
scoreHome: true,
|
status: true,
|
||||||
scoreAway: true,
|
scoreHome: true,
|
||||||
},
|
scoreAway: true,
|
||||||
}),
|
leagueId: true,
|
||||||
this.prisma.match.findUnique({
|
},
|
||||||
where: { id: matchId },
|
}),
|
||||||
select: {
|
this.prisma.match.findUnique({
|
||||||
id: true,
|
where: { id: matchId },
|
||||||
state: true,
|
select: {
|
||||||
status: true,
|
id: true,
|
||||||
scoreHome: true,
|
state: true,
|
||||||
scoreAway: true,
|
status: true,
|
||||||
},
|
scoreHome: true,
|
||||||
}),
|
scoreAway: true,
|
||||||
this.prisma.oddCategory.count({
|
leagueId: true,
|
||||||
where: { matchId },
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
this.prisma.oddCategory.count({
|
||||||
|
where: { matchId },
|
||||||
|
}),
|
||||||
|
this.prisma.matchPlayerParticipation.count({
|
||||||
|
where: { matchId },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const hasLiveOdds =
|
const hasLiveOdds =
|
||||||
!!liveMatch?.odds &&
|
!!liveMatch?.odds &&
|
||||||
@@ -1257,27 +1305,68 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// League qualification gate: reject predictions for leagues without
|
||||||
|
// sufficient historical training data (odds + lineups + stats)
|
||||||
|
const leagueId = liveMatch?.leagueId || persistedMatch?.leagueId;
|
||||||
|
if (
|
||||||
|
this.qualifiedLeagueIds.size > 0 &&
|
||||||
|
(!leagueId || !this.qualifiedLeagueIds.has(leagueId))
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
`Bu lig için yeterli geçmiş veri bulunmuyor. Tahmin yapılamaz.`,
|
||||||
|
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const state = liveMatch?.state || persistedMatch?.state;
|
const state = liveMatch?.state || persistedMatch?.state;
|
||||||
const status = liveMatch?.status || persistedMatch?.status;
|
const status = liveMatch?.status || persistedMatch?.status;
|
||||||
const scoreHome = liveMatch?.scoreHome ?? persistedMatch?.scoreHome;
|
const scoreHome = liveMatch?.scoreHome ?? persistedMatch?.scoreHome;
|
||||||
const scoreAway = liveMatch?.scoreAway ?? persistedMatch?.scoreAway;
|
const scoreAway = liveMatch?.scoreAway ?? persistedMatch?.scoreAway;
|
||||||
const hasScores =
|
|
||||||
scoreHome !== null &&
|
|
||||||
scoreHome !== undefined &&
|
|
||||||
scoreAway !== null &&
|
|
||||||
scoreAway !== undefined;
|
|
||||||
|
|
||||||
const isFinished =
|
const isFinished = isMatchCompleted({
|
||||||
hasScores ||
|
state: state ?? null,
|
||||||
state === "MS" ||
|
status: status ?? null,
|
||||||
state === "postGame" ||
|
scoreHome,
|
||||||
["Finished", "Played", "FT", "AET", "PEN", "Ended"].includes(
|
scoreAway,
|
||||||
status as string,
|
});
|
||||||
);
|
|
||||||
|
const isLive = isMatchLive({
|
||||||
|
state: state ?? null,
|
||||||
|
status: status ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
const hasOdds = hasLiveOdds || oddCategoryCount > 0;
|
const hasOdds = hasLiveOdds || oddCategoryCount > 0;
|
||||||
|
|
||||||
if (hasOdds || isFinished) {
|
if (hasOdds || isFinished || isLive) {
|
||||||
|
// ── Lineup guard: fetch lineups if missing before analysis ──
|
||||||
|
// A proper football lineup has at least 11 starting players (22 total
|
||||||
|
// with subs). If we have fewer than 11 participation records, the
|
||||||
|
// lineup data is likely missing — attempt to fetch it from source.
|
||||||
|
if (lineupCount < 11) {
|
||||||
|
this.logger.log(
|
||||||
|
`[${matchId}] ⚠️ Lineups missing (${lineupCount} players in DB). Fetching from source before analysis...`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const refreshResult = await this.feederService.refreshMatch(
|
||||||
|
matchId,
|
||||||
|
"lineups",
|
||||||
|
);
|
||||||
|
if (refreshResult.success) {
|
||||||
|
this.logger.log(
|
||||||
|
`[${matchId}] ✅ Lineups fetched successfully before analysis`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
`[${matchId}] ⚠️ Lineup fetch returned failure — proceeding with existing data. Error: ${refreshResult.error ?? "unknown"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.warn(
|
||||||
|
`[${matchId}] ⚠️ Lineup fetch exception — proceeding with existing data. ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1315,7 +1404,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
this.logger.warn(`Prediction run audit skipped for ${matchId}: ${message}`);
|
this.logger.warn(
|
||||||
|
`Prediction run audit skipped for ${matchId}: ${message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1397,8 +1488,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
: null,
|
: null,
|
||||||
bet_advice: {
|
bet_advice: {
|
||||||
playable: payload.bet_advice?.playable ?? false,
|
playable: payload.bet_advice?.playable ?? false,
|
||||||
suggested_stake_units:
|
suggested_stake_units: payload.bet_advice?.suggested_stake_units ?? 0,
|
||||||
payload.bet_advice?.suggested_stake_units ?? 0,
|
|
||||||
reason: payload.bet_advice?.reason ?? null,
|
reason: payload.bet_advice?.reason ?? null,
|
||||||
},
|
},
|
||||||
top_summary: topSummary,
|
top_summary: topSummary,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import axios from "axios";
|
|||||||
let createCanvas: any;
|
let createCanvas: any;
|
||||||
let loadImage: any;
|
let loadImage: any;
|
||||||
try {
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const canvas = require("canvas");
|
const canvas = require("canvas");
|
||||||
createCanvas = canvas.createCanvas;
|
createCanvas = canvas.createCanvas;
|
||||||
loadImage = canvas.loadImage;
|
loadImage = canvas.loadImage;
|
||||||
@@ -397,7 +398,7 @@ export class ImageRendererService implements OnModuleInit {
|
|||||||
ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
|
ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
|
||||||
ctx.font = "700 26px sans-serif";
|
ctx.font = "700 26px sans-serif";
|
||||||
ctx.textAlign = "left";
|
ctx.textAlign = "left";
|
||||||
ctx.fillText("⚡ AI Powered by SuggestBet", paddingX, currentY);
|
ctx.fillText("⚡ AI Powered by iddaai.com", paddingX, currentY);
|
||||||
|
|
||||||
let riskBg, riskColor, riskBorder;
|
let riskBg, riskColor, riskBorder;
|
||||||
switch (data.riskLevel) {
|
switch (data.riskLevel) {
|
||||||
|
|||||||
@@ -44,9 +44,7 @@ export class AiService {
|
|||||||
private readonly pythonEngineUrl: string;
|
private readonly pythonEngineUrl: string;
|
||||||
private readonly aiEngineClient: AiEngineClient;
|
private readonly aiEngineClient: AiEngineClient;
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly configService: ConfigService) {
|
||||||
private readonly configService: ConfigService,
|
|
||||||
) {
|
|
||||||
this.pythonEngineUrl =
|
this.pythonEngineUrl =
|
||||||
this.configService.get("AI_ENGINE_URL") || "http://127.0.0.1:8000";
|
this.configService.get("AI_ENGINE_URL") || "http://127.0.0.1:8000";
|
||||||
this.aiEngineClient = new AiEngineClient({
|
this.aiEngineClient = new AiEngineClient({
|
||||||
|
|||||||
@@ -161,7 +161,8 @@ export class DataFetcherTask {
|
|||||||
`Pruned ${deleted.count} stale live matches. Starting full sync...`,
|
`Pruned ${deleted.count} stale live matches. Starting full sync...`,
|
||||||
);
|
);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
this.logger.error(`Stale live_match cleanup failed: ${message}`);
|
this.logger.error(`Stale live_match cleanup failed: ${message}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -194,12 +195,12 @@ export class DataFetcherTask {
|
|||||||
|
|
||||||
private async syncMatchList(date: string): Promise<void> {
|
private async syncMatchList(date: string): Promise<void> {
|
||||||
// Football
|
// Football
|
||||||
const footballLeagues = this.loadLeagueFilterSet("top_leagues.json");
|
const footballLeagues = this.loadLeagueFilterSet("qualified_leagues.json");
|
||||||
if (footballLeagues && footballLeagues.size > 0) {
|
if (footballLeagues && footballLeagues.size > 0) {
|
||||||
await this.fetchMatchesForSport("football", date, footballLeagues);
|
await this.fetchMatchesForSport("football", date, footballLeagues);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
"top_leagues.json is missing/empty — writing ALL football matches",
|
"qualified_leagues.json is missing/empty — writing ALL football matches",
|
||||||
);
|
);
|
||||||
await this.fetchMatchesForSport("football", date, new Set());
|
await this.fetchMatchesForSport("football", date, new Set());
|
||||||
}
|
}
|
||||||
@@ -250,7 +251,7 @@ export class DataFetcherTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`📡 Updating scores for ${liveMatches.length} live matches`,
|
`LIVE Updating scores for ${liveMatches.length} live matches`,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const match of liveMatches) {
|
for (const match of liveMatches) {
|
||||||
@@ -278,25 +279,25 @@ export class DataFetcherTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log("📡 Live score update complete");
|
this.logger.log("LIVE Live score update complete");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
this.logger.error(`Live score update failed: ${message}`);
|
this.logger.error(`Live score update failed: ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
// Phase 3: Odds + referee + lineups + sidelined
|
// Phase 3: Odds + referee + lineups + sidelined
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private async fetchOddsForMatches(): Promise<void> {
|
private async fetchOddsForMatches(): Promise<void> {
|
||||||
this.logger.log("💰 Fetching odds for live matches...");
|
this.logger.log("MONEY Fetching odds for live matches...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load both league filters
|
// Load both league filters (data-driven qualified leagues)
|
||||||
const topLeagueIds: string[] = [];
|
const topLeagueIds: string[] = [];
|
||||||
|
|
||||||
const footballLeagues = this.loadLeagueFilterSet("top_leagues.json");
|
const footballLeagues = this.loadLeagueFilterSet(
|
||||||
|
"qualified_leagues.json",
|
||||||
|
);
|
||||||
if (footballLeagues) topLeagueIds.push(...footballLeagues);
|
if (footballLeagues) topLeagueIds.push(...footballLeagues);
|
||||||
|
|
||||||
const basketballLeagues = this.loadLeagueFilterSet(
|
const basketballLeagues = this.loadLeagueFilterSet(
|
||||||
@@ -337,11 +338,13 @@ export class DataFetcherTask {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (matchesToFetch.length === 0) {
|
if (matchesToFetch.length === 0) {
|
||||||
this.logger.log("💰 No matches to fetch odds for");
|
this.logger.log("MONEY No matches to fetch odds for");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`💰 Fetching odds for ${matchesToFetch.length} matches`);
|
this.logger.log(
|
||||||
|
`MONEY Fetching odds for ${matchesToFetch.length} matches`,
|
||||||
|
);
|
||||||
|
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
@@ -370,7 +373,7 @@ export class DataFetcherTask {
|
|||||||
// Retry failed matches (502/Timeout)
|
// Retry failed matches (502/Timeout)
|
||||||
if (failedMatches.length > 0) {
|
if (failedMatches.length > 0) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`âš ï¸ Retrying ${failedMatches.length} failed matches (502/Timeout)...`,
|
`Retrying ${failedMatches.length} failed matches (502/Timeout)...`,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const match of failedMatches) {
|
for (const match of failedMatches) {
|
||||||
@@ -378,7 +381,7 @@ export class DataFetcherTask {
|
|||||||
try {
|
try {
|
||||||
await this.processMatchOdds(match);
|
await this.processMatchOdds(match);
|
||||||
successCount++;
|
successCount++;
|
||||||
this.logger.log(`✅ Retry successful for match ${match.id}`);
|
this.logger.log(`SUCCESS Retry successful for match ${match.id}`);
|
||||||
} catch (retryErr: unknown) {
|
} catch (retryErr: unknown) {
|
||||||
const message =
|
const message =
|
||||||
retryErr instanceof Error ? retryErr.message : String(retryErr);
|
retryErr instanceof Error ? retryErr.message : String(retryErr);
|
||||||
@@ -390,7 +393,7 @@ export class DataFetcherTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`💰 Odds complete: ${successCount} success, ${errorCount} errors (initially)`,
|
`MONEY Odds complete: ${successCount} success, ${errorCount} errors (initially)`,
|
||||||
);
|
);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
@@ -905,12 +908,16 @@ export class DataFetcherTask {
|
|||||||
|
|
||||||
// Guard: If match already has pre-match odds and is now live/finished,
|
// Guard: If match already has pre-match odds and is now live/finished,
|
||||||
// do NOT overwrite odds/lineups/sidelined — the model needs stable pre-match data.
|
// do NOT overwrite odds/lineups/sidelined — the model needs stable pre-match data.
|
||||||
const matchState = match.state?.toLowerCase() ?? '';
|
const matchState = match.state?.toLowerCase() ?? "";
|
||||||
const matchStatus = match.status?.toLowerCase() ?? '';
|
const matchStatus = match.status?.toLowerCase() ?? "";
|
||||||
const liveStates = LIVE_STATE_VALUES_FOR_DB.map((s) => s.toLowerCase());
|
const liveStates = LIVE_STATE_VALUES_FOR_DB.map((s) => s.toLowerCase());
|
||||||
const liveStatuses = LIVE_STATUS_VALUES_FOR_DB.map((s) => s.toLowerCase());
|
const liveStatuses = LIVE_STATUS_VALUES_FOR_DB.map((s) => s.toLowerCase());
|
||||||
const finishedStates = FINISHED_STATE_VALUES_FOR_DB.map((s) => s.toLowerCase());
|
const finishedStates = FINISHED_STATE_VALUES_FOR_DB.map((s) =>
|
||||||
const finishedStatuses = FINISHED_STATUS_VALUES_FOR_DB.map((s) => s.toLowerCase());
|
s.toLowerCase(),
|
||||||
|
);
|
||||||
|
const finishedStatuses = FINISHED_STATUS_VALUES_FOR_DB.map((s) =>
|
||||||
|
s.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
const isLiveOrFinished =
|
const isLiveOrFinished =
|
||||||
liveStates.includes(matchState) ||
|
liveStates.includes(matchState) ||
|
||||||
@@ -921,7 +928,7 @@ export class DataFetcherTask {
|
|||||||
const existingOdds = match.odds as Record<string, unknown> | null;
|
const existingOdds = match.odds as Record<string, unknown> | null;
|
||||||
const hasExistingOdds =
|
const hasExistingOdds =
|
||||||
!!existingOdds &&
|
!!existingOdds &&
|
||||||
typeof existingOdds === 'object' &&
|
typeof existingOdds === "object" &&
|
||||||
Object.keys(existingOdds).length > 0;
|
Object.keys(existingOdds).length > 0;
|
||||||
|
|
||||||
if (isLiveOrFinished && hasExistingOdds) {
|
if (isLiveOrFinished && hasExistingOdds) {
|
||||||
@@ -957,7 +964,7 @@ export class DataFetcherTask {
|
|||||||
sidelined.awayTeam.totalSidelined > 0))
|
sidelined.awayTeam.totalSidelined > 0))
|
||||||
) {
|
) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`✅ Loop update: ${match.matchName} | Odds: ${Object.keys(odds).length} | Ref: ${refereeName || "N/A"} | Lineups: ${lineups ? "Yes" : "No"} | Sidelined: ${sidelined ? "Yes" : "No"}`,
|
`SUCCESS Loop update: ${match.matchName} | Odds: ${Object.keys(odds).length} | Ref: ${refereeName || "N/A"} | Lineups: ${lineups ? "Yes" : "No"} | Sidelined: ${sidelined ? "Yes" : "No"}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
@@ -1334,4 +1341,3 @@ export class DataFetcherTask {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user