Compare commits
33 Commits
bf7473c1e7
..
v28
| Author | SHA1 | Date | |
|---|---|---|---|
| f3362f266c | |||
| c525b12dfd | |||
| 4f7090e2d9 | |||
| 5b5f83c8cf | |||
| bfddcaca7d | |||
| 56d560af08 | |||
| 4bc51cfa99 | |||
| fdb8a5d0f0 | |||
| 22596e69f2 | |||
| f32badbd8f | |||
| 5645b38f20 | |||
| 244d8f5366 | |||
| 9bb8f39bca | |||
| 7a1cf14e2f | |||
| 62c797d299 | |||
| 34cc4a6cbb | |||
| 27e96da31d | |||
| 145a8b336b | |||
| 7a8960edb8 | |||
| 691c52f610 | |||
| bc461429f6 | |||
| a338d02244 | |||
| 1623432039 | |||
| 4c7930e9d2 | |||
| ec463cb927 | |||
| eab95c4e5c | |||
| 9027cc9900 | |||
| 3875f2a512 | |||
| 300dceeb4b | |||
| ad01976fb9 | |||
| 6880eb92f5 | |||
| 9e2edd590c | |||
| b5c2edf346 |
@@ -25,11 +25,11 @@ jobs:
|
||||
--network iddaai_iddaai-network \
|
||||
-p 127.0.0.1:1810:3005 \
|
||||
-e NODE_ENV=production \
|
||||
-e DATABASE_URL='postgresql://iddaai_user:IddaA1_S4crET!@iddaai-postgres:5432/iddaai_db?schema=public' \
|
||||
-e REDIS_HOST='iddaai-redis' \
|
||||
-e REDIS_PORT='6379' \
|
||||
-e REDIS_PASSWORD='IddaA1_Redis_Pass!' \
|
||||
-e AI_ENGINE_URL='http://iddaai-ai-engine:8000' \
|
||||
-e JWT_SECRET='b7V8jM2wP1L5mQxs2RdfFkAsLpI2oG!w' \
|
||||
-e DATABASE_URL='${{ secrets.DATABASE_URL }}' \
|
||||
-e REDIS_HOST='${{ secrets.REDIS_HOST }}' \
|
||||
-e REDIS_PORT='${{ secrets.REDIS_PORT }}' \
|
||||
-e REDIS_PASSWORD='${{ secrets.REDIS_PASSWORD }}' \
|
||||
-e AI_ENGINE_URL='${{ secrets.AI_ENGINE_URL }}' \
|
||||
-e JWT_SECRET='${{ secrets.JWT_SECRET }}' \
|
||||
-e JWT_ACCESS_EXPIRATION='1d' \
|
||||
iddaai-be:latest /bin/sh -c "npx prisma migrate deploy && node dist/src/main.js"
|
||||
|
||||
+4
-2
@@ -42,7 +42,9 @@ uploads/
|
||||
public/uploads/
|
||||
|
||||
# Large Datasets and ML Models
|
||||
ai-engine/models/
|
||||
models/
|
||||
ai-engine/models/*
|
||||
!ai-engine/models/*.py
|
||||
models/*
|
||||
!models/*.py
|
||||
colab_export/
|
||||
|
||||
|
||||
+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
|
||||
|
||||
Binary file not shown.
@@ -1,871 +0,0 @@
|
||||
iter Logloss
|
||||
0 0.6924099937
|
||||
1 0.6916660956
|
||||
2 0.691108145
|
||||
3 0.6904585078
|
||||
4 0.689812816
|
||||
5 0.689192261
|
||||
6 0.6886032715
|
||||
7 0.6880706742
|
||||
8 0.6876192378
|
||||
9 0.6870868859
|
||||
10 0.6865493528
|
||||
11 0.686105086
|
||||
12 0.6856345086
|
||||
13 0.6852027185
|
||||
14 0.6848238481
|
||||
15 0.6844045699
|
||||
16 0.6840077621
|
||||
17 0.6836197496
|
||||
18 0.6832475033
|
||||
19 0.6829012069
|
||||
20 0.6825880966
|
||||
21 0.6822424968
|
||||
22 0.6819180513
|
||||
23 0.6816384467
|
||||
24 0.6813262593
|
||||
25 0.6810353411
|
||||
26 0.6808138172
|
||||
27 0.6805550049
|
||||
28 0.680347991
|
||||
29 0.680089679
|
||||
30 0.6798451919
|
||||
31 0.6796090443
|
||||
32 0.6793890865
|
||||
33 0.6791683772
|
||||
34 0.6789766369
|
||||
35 0.6787930242
|
||||
36 0.6786087714
|
||||
37 0.6784161299
|
||||
38 0.6782227897
|
||||
39 0.6780242369
|
||||
40 0.6778499631
|
||||
41 0.6776975784
|
||||
42 0.6775231674
|
||||
43 0.6773582124
|
||||
44 0.6772234666
|
||||
45 0.6770659843
|
||||
46 0.6769049529
|
||||
47 0.6767664194
|
||||
48 0.6766584917
|
||||
49 0.6765507257
|
||||
50 0.6764489911
|
||||
51 0.6763947956
|
||||
52 0.6762778712
|
||||
53 0.6761865366
|
||||
54 0.6760679685
|
||||
55 0.6759774874
|
||||
56 0.6758500622
|
||||
57 0.6757625065
|
||||
58 0.6756876412
|
||||
59 0.6756151069
|
||||
60 0.6755303655
|
||||
61 0.6754565036
|
||||
62 0.6753738983
|
||||
63 0.6752897299
|
||||
64 0.6752115539
|
||||
65 0.6751595431
|
||||
66 0.6750764658
|
||||
67 0.6750179194
|
||||
68 0.6749408803
|
||||
69 0.6748795802
|
||||
70 0.674790372
|
||||
71 0.6747239773
|
||||
72 0.6746701254
|
||||
73 0.6746120937
|
||||
74 0.6745550085
|
||||
75 0.6744855074
|
||||
76 0.6744264172
|
||||
77 0.674381715
|
||||
78 0.6743331681
|
||||
79 0.67428564
|
||||
80 0.6742202413
|
||||
81 0.6741620971
|
||||
82 0.6741109453
|
||||
83 0.6740556003
|
||||
84 0.6740146772
|
||||
85 0.673983295
|
||||
86 0.6739595301
|
||||
87 0.6739336659
|
||||
88 0.673890361
|
||||
89 0.673863586
|
||||
90 0.6738190616
|
||||
91 0.6737799295
|
||||
92 0.6737364374
|
||||
93 0.6737093719
|
||||
94 0.6736630475
|
||||
95 0.67364367
|
||||
96 0.6735998081
|
||||
97 0.6735526984
|
||||
98 0.6735012924
|
||||
99 0.6734818024
|
||||
100 0.6734379341
|
||||
101 0.6734059869
|
||||
102 0.6733740852
|
||||
103 0.6733330971
|
||||
104 0.6733060254
|
||||
105 0.6732755898
|
||||
106 0.6732294722
|
||||
107 0.6732035176
|
||||
108 0.673196437
|
||||
109 0.6731652709
|
||||
110 0.673138808
|
||||
111 0.6731062725
|
||||
112 0.6730726625
|
||||
113 0.6730285927
|
||||
114 0.6729872702
|
||||
115 0.6729721425
|
||||
116 0.6729564624
|
||||
117 0.6729312424
|
||||
118 0.6729354345
|
||||
119 0.6729085401
|
||||
120 0.6728898322
|
||||
121 0.6728773638
|
||||
122 0.6728618874
|
||||
123 0.6728540413
|
||||
124 0.6728441291
|
||||
125 0.672815631
|
||||
126 0.6728082021
|
||||
127 0.6727900064
|
||||
128 0.6727649552
|
||||
129 0.6727467657
|
||||
130 0.6727396032
|
||||
131 0.6727245271
|
||||
132 0.6726955143
|
||||
133 0.67269209
|
||||
134 0.672677932
|
||||
135 0.6726540285
|
||||
136 0.6726288583
|
||||
137 0.6725863431
|
||||
138 0.6725837967
|
||||
139 0.6725772977
|
||||
140 0.6725685594
|
||||
141 0.6725553829
|
||||
142 0.6725484347
|
||||
143 0.6725306172
|
||||
144 0.672543149
|
||||
145 0.6725196247
|
||||
146 0.6725226452
|
||||
147 0.6725056913
|
||||
148 0.6724771476
|
||||
149 0.6724439435
|
||||
150 0.672442532
|
||||
151 0.6724303064
|
||||
152 0.6724235788
|
||||
153 0.6724294499
|
||||
154 0.6724285935
|
||||
155 0.6724172017
|
||||
156 0.6724130745
|
||||
157 0.6723860878
|
||||
158 0.6723707604
|
||||
159 0.6723566111
|
||||
160 0.6723469906
|
||||
161 0.6723287161
|
||||
162 0.6723155898
|
||||
163 0.6722970834
|
||||
164 0.6722872244
|
||||
165 0.6722800481
|
||||
166 0.6722550973
|
||||
167 0.6722394313
|
||||
168 0.6722204135
|
||||
169 0.6721982148
|
||||
170 0.6721971176
|
||||
171 0.6721880705
|
||||
172 0.672179176
|
||||
173 0.6721769709
|
||||
174 0.6721693215
|
||||
175 0.6721581386
|
||||
176 0.6721638661
|
||||
177 0.6721598475
|
||||
178 0.6721433342
|
||||
179 0.6721335599
|
||||
180 0.6721300594
|
||||
181 0.6721153533
|
||||
182 0.6721076397
|
||||
183 0.6721009911
|
||||
184 0.6720999252
|
||||
185 0.6720953028
|
||||
186 0.6720942505
|
||||
187 0.6720856237
|
||||
188 0.6720876136
|
||||
189 0.6720880182
|
||||
190 0.6720743856
|
||||
191 0.6720598415
|
||||
192 0.6720563492
|
||||
193 0.6720389527
|
||||
194 0.6720317324
|
||||
195 0.672000736
|
||||
196 0.6719895017
|
||||
197 0.6719725302
|
||||
198 0.6719770493
|
||||
199 0.6719667172
|
||||
200 0.6719511616
|
||||
201 0.6719427289
|
||||
202 0.6719299116
|
||||
203 0.6719106583
|
||||
204 0.6718967065
|
||||
205 0.671890967
|
||||
206 0.6718896293
|
||||
207 0.6718883534
|
||||
208 0.6718827289
|
||||
209 0.6718763224
|
||||
210 0.67187262
|
||||
211 0.6718590402
|
||||
212 0.6718455115
|
||||
213 0.6718253747
|
||||
214 0.671794877
|
||||
215 0.6717873786
|
||||
216 0.6717765089
|
||||
217 0.6717616726
|
||||
218 0.6717499215
|
||||
219 0.6717326052
|
||||
220 0.6717161937
|
||||
221 0.6717056951
|
||||
222 0.6717021438
|
||||
223 0.6716868488
|
||||
224 0.6716751909
|
||||
225 0.671670116
|
||||
226 0.6716558757
|
||||
227 0.6716559962
|
||||
228 0.6716487875
|
||||
229 0.6716427451
|
||||
230 0.6716323255
|
||||
231 0.6716303547
|
||||
232 0.6716309509
|
||||
233 0.6716215401
|
||||
234 0.6716162103
|
||||
235 0.6716135097
|
||||
236 0.6716156696
|
||||
237 0.6716020054
|
||||
238 0.6715921704
|
||||
239 0.6715804466
|
||||
240 0.6715882966
|
||||
241 0.6715753942
|
||||
242 0.6715752261
|
||||
243 0.6715625509
|
||||
244 0.6715628214
|
||||
245 0.6715601629
|
||||
246 0.6715576255
|
||||
247 0.6715550274
|
||||
248 0.6715448645
|
||||
249 0.6715308166
|
||||
250 0.671519334
|
||||
251 0.6715184071
|
||||
252 0.6715163019
|
||||
253 0.6715096094
|
||||
254 0.6714992963
|
||||
255 0.6714917256
|
||||
256 0.671477406
|
||||
257 0.6714741542
|
||||
258 0.6714576155
|
||||
259 0.6714473645
|
||||
260 0.6714427232
|
||||
261 0.6714364275
|
||||
262 0.6714339587
|
||||
263 0.6714336287
|
||||
264 0.6714283568
|
||||
265 0.6714271895
|
||||
266 0.671413471
|
||||
267 0.6714072396
|
||||
268 0.6714002677
|
||||
269 0.6714001163
|
||||
270 0.6713933952
|
||||
271 0.6713926761
|
||||
272 0.6713836619
|
||||
273 0.6713772112
|
||||
274 0.6713603715
|
||||
275 0.6713560246
|
||||
276 0.6713837913
|
||||
277 0.6713684274
|
||||
278 0.6713619356
|
||||
279 0.6713584836
|
||||
280 0.6713673572
|
||||
281 0.6713625568
|
||||
282 0.6713542652
|
||||
283 0.6713512017
|
||||
284 0.671342038
|
||||
285 0.6713279798
|
||||
286 0.6713123285
|
||||
287 0.6713035326
|
||||
288 0.6713022203
|
||||
289 0.671296041
|
||||
290 0.6712829551
|
||||
291 0.6712769751
|
||||
292 0.6712702915
|
||||
293 0.6712379343
|
||||
294 0.6712192006
|
||||
295 0.6712074061
|
||||
296 0.6711953324
|
||||
297 0.6711891001
|
||||
298 0.6711870526
|
||||
299 0.6711812809
|
||||
300 0.6711768946
|
||||
301 0.6711845012
|
||||
302 0.6711869636
|
||||
303 0.671186884
|
||||
304 0.6711890401
|
||||
305 0.6711868603
|
||||
306 0.6711900892
|
||||
307 0.6711884242
|
||||
308 0.6711837119
|
||||
309 0.6711766645
|
||||
310 0.671172959
|
||||
311 0.6711740433
|
||||
312 0.6711715069
|
||||
313 0.6711589843
|
||||
314 0.6711446402
|
||||
315 0.6711415366
|
||||
316 0.6711359351
|
||||
317 0.671143361
|
||||
318 0.6711353638
|
||||
319 0.6711444387
|
||||
320 0.6711487352
|
||||
321 0.67114436
|
||||
322 0.6711444722
|
||||
323 0.6711325635
|
||||
324 0.6711269403
|
||||
325 0.6711154078
|
||||
326 0.6711203043
|
||||
327 0.6711241333
|
||||
328 0.6711213497
|
||||
329 0.6711231641
|
||||
330 0.6711049215
|
||||
331 0.6711031963
|
||||
332 0.6710996314
|
||||
333 0.6710867309
|
||||
334 0.6710914578
|
||||
335 0.6710929585
|
||||
336 0.6710984779
|
||||
337 0.6710923199
|
||||
338 0.6710893917
|
||||
339 0.6710923306
|
||||
340 0.6710927901
|
||||
341 0.6711092802
|
||||
342 0.6711012995
|
||||
343 0.6711015305
|
||||
344 0.6710975574
|
||||
345 0.6710899474
|
||||
346 0.671085152
|
||||
347 0.6710814533
|
||||
348 0.6710701892
|
||||
349 0.67105503
|
||||
350 0.6710527861
|
||||
351 0.6710508715
|
||||
352 0.6710560803
|
||||
353 0.6710465693
|
||||
354 0.6710440741
|
||||
355 0.6710496913
|
||||
356 0.6710404659
|
||||
357 0.6710293986
|
||||
358 0.6710353817
|
||||
359 0.6710271815
|
||||
360 0.6710288077
|
||||
361 0.6710169894
|
||||
362 0.6710119848
|
||||
363 0.6710114775
|
||||
364 0.6710013614
|
||||
365 0.6709985657
|
||||
366 0.6709948954
|
||||
367 0.6709970591
|
||||
368 0.6709739289
|
||||
369 0.6709754911
|
||||
370 0.6709717066
|
||||
371 0.67096845
|
||||
372 0.6709739445
|
||||
373 0.6709728881
|
||||
374 0.6709694284
|
||||
375 0.6709604166
|
||||
376 0.6709605025
|
||||
377 0.6709603727
|
||||
378 0.670944339
|
||||
379 0.6709447187
|
||||
380 0.6709538679
|
||||
381 0.6709640912
|
||||
382 0.6709534847
|
||||
383 0.6709471555
|
||||
384 0.6709506783
|
||||
385 0.6709546729
|
||||
386 0.670930774
|
||||
387 0.6709287322
|
||||
388 0.6709198643
|
||||
389 0.6709220389
|
||||
390 0.6709230923
|
||||
391 0.670930414
|
||||
392 0.6709354296
|
||||
393 0.6709351544
|
||||
394 0.6709414935
|
||||
395 0.6709445943
|
||||
396 0.6709475685
|
||||
397 0.6709533591
|
||||
398 0.6709592222
|
||||
399 0.6709508704
|
||||
400 0.6709479912
|
||||
401 0.6709417519
|
||||
402 0.6709476082
|
||||
403 0.6709480979
|
||||
404 0.6709448724
|
||||
405 0.6709421934
|
||||
406 0.6709386261
|
||||
407 0.6709461564
|
||||
408 0.670934384
|
||||
409 0.6709312987
|
||||
410 0.670931806
|
||||
411 0.6709286111
|
||||
412 0.6709224729
|
||||
413 0.6709236504
|
||||
414 0.6709245901
|
||||
415 0.6709463437
|
||||
416 0.6709567049
|
||||
417 0.670945606
|
||||
418 0.6709479298
|
||||
419 0.6709464351
|
||||
420 0.6709414048
|
||||
421 0.6709414427
|
||||
422 0.6709296343
|
||||
423 0.670924721
|
||||
424 0.670906284
|
||||
425 0.6708996826
|
||||
426 0.6708987677
|
||||
427 0.670909526
|
||||
428 0.6709033226
|
||||
429 0.6708750209
|
||||
430 0.6708752079
|
||||
431 0.6708776566
|
||||
432 0.6708736133
|
||||
433 0.6708754298
|
||||
434 0.6708751084
|
||||
435 0.6708642042
|
||||
436 0.6708610465
|
||||
437 0.6708574768
|
||||
438 0.6708557953
|
||||
439 0.670871378
|
||||
440 0.6708640187
|
||||
441 0.6708700565
|
||||
442 0.6708667534
|
||||
443 0.6708675383
|
||||
444 0.6708740175
|
||||
445 0.6708774523
|
||||
446 0.6708697231
|
||||
447 0.6708614971
|
||||
448 0.6708607946
|
||||
449 0.6708740865
|
||||
450 0.6708729562
|
||||
451 0.6708674017
|
||||
452 0.6708693088
|
||||
453 0.6708712037
|
||||
454 0.6708703905
|
||||
455 0.6708577595
|
||||
456 0.6708493546
|
||||
457 0.6708523777
|
||||
458 0.6708454134
|
||||
459 0.6708404483
|
||||
460 0.6708274771
|
||||
461 0.6708244992
|
||||
462 0.6708344314
|
||||
463 0.6708279081
|
||||
464 0.6708258106
|
||||
465 0.6708049714
|
||||
466 0.670810989
|
||||
467 0.6708212237
|
||||
468 0.6708221741
|
||||
469 0.6708259658
|
||||
470 0.6708159692
|
||||
471 0.6708136212
|
||||
472 0.6708224942
|
||||
473 0.6708363084
|
||||
474 0.670850875
|
||||
475 0.6708527236
|
||||
476 0.6708453401
|
||||
477 0.6708413844
|
||||
478 0.6708364569
|
||||
479 0.6708251774
|
||||
480 0.6708154393
|
||||
481 0.6708111613
|
||||
482 0.6708102339
|
||||
483 0.6707929623
|
||||
484 0.6707900226
|
||||
485 0.6707832384
|
||||
486 0.6707739118
|
||||
487 0.6707737538
|
||||
488 0.6707730234
|
||||
489 0.6707796291
|
||||
490 0.670791408
|
||||
491 0.6707944906
|
||||
492 0.6707835635
|
||||
493 0.6707908928
|
||||
494 0.670796262
|
||||
495 0.6707877825
|
||||
496 0.6707854132
|
||||
497 0.6707756206
|
||||
498 0.6707707899
|
||||
499 0.6707704386
|
||||
500 0.6707621465
|
||||
501 0.6707661931
|
||||
502 0.6707651988
|
||||
503 0.6707607827
|
||||
504 0.670760242
|
||||
505 0.6707506008
|
||||
506 0.6707452886
|
||||
507 0.6707355189
|
||||
508 0.6707312551
|
||||
509 0.6707199485
|
||||
510 0.6707131947
|
||||
511 0.6707154112
|
||||
512 0.6706982346
|
||||
513 0.6706988941
|
||||
514 0.6706989098
|
||||
515 0.670693306
|
||||
516 0.6706944515
|
||||
517 0.6706899688
|
||||
518 0.6706909374
|
||||
519 0.6706855074
|
||||
520 0.6706787779
|
||||
521 0.6706737082
|
||||
522 0.6706761225
|
||||
523 0.670685455
|
||||
524 0.6706693855
|
||||
525 0.6706647216
|
||||
526 0.6706569188
|
||||
527 0.6706549134
|
||||
528 0.6706547978
|
||||
529 0.6706564214
|
||||
530 0.6706559196
|
||||
531 0.6706515072
|
||||
532 0.6706474616
|
||||
533 0.6706424204
|
||||
534 0.6706520008
|
||||
535 0.6706448306
|
||||
536 0.6706415789
|
||||
537 0.6706305359
|
||||
538 0.6706152774
|
||||
539 0.670616585
|
||||
540 0.6705963243
|
||||
541 0.6706027368
|
||||
542 0.6706003522
|
||||
543 0.6706044301
|
||||
544 0.6706047241
|
||||
545 0.6706038235
|
||||
546 0.6706026913
|
||||
547 0.6705845786
|
||||
548 0.6705873967
|
||||
549 0.6705755426
|
||||
550 0.6705715731
|
||||
551 0.6705757153
|
||||
552 0.6705516814
|
||||
553 0.6705530864
|
||||
554 0.6705552479
|
||||
555 0.6705563336
|
||||
556 0.6705718544
|
||||
557 0.6705688384
|
||||
558 0.6705641528
|
||||
559 0.6705628467
|
||||
560 0.670558488
|
||||
561 0.6705544404
|
||||
562 0.6705617451
|
||||
563 0.6705631717
|
||||
564 0.6705636201
|
||||
565 0.6705537522
|
||||
566 0.670555083
|
||||
567 0.6705524541
|
||||
568 0.6705503132
|
||||
569 0.6705354602
|
||||
570 0.6705387012
|
||||
571 0.6705411923
|
||||
572 0.6705390018
|
||||
573 0.6705354939
|
||||
574 0.670531296
|
||||
575 0.6705377163
|
||||
576 0.6705248875
|
||||
577 0.6705252902
|
||||
578 0.6705181562
|
||||
579 0.6705123446
|
||||
580 0.6705128345
|
||||
581 0.6705173712
|
||||
582 0.670541941
|
||||
583 0.6705463243
|
||||
584 0.6705513215
|
||||
585 0.6705455889
|
||||
586 0.6705408087
|
||||
587 0.6705510193
|
||||
588 0.6705456751
|
||||
589 0.6705402427
|
||||
590 0.6705443402
|
||||
591 0.67054441
|
||||
592 0.6705441955
|
||||
593 0.6705319356
|
||||
594 0.6705358843
|
||||
595 0.6705334396
|
||||
596 0.6705320462
|
||||
597 0.6705332043
|
||||
598 0.6705328363
|
||||
599 0.6705315638
|
||||
600 0.6705274435
|
||||
601 0.670509808
|
||||
602 0.6705077789
|
||||
603 0.6705212132
|
||||
604 0.6705098442
|
||||
605 0.6705061509
|
||||
606 0.6705003071
|
||||
607 0.6705045031
|
||||
608 0.6705083194
|
||||
609 0.6705329997
|
||||
610 0.6705269987
|
||||
611 0.6705315607
|
||||
612 0.6705142835
|
||||
613 0.6705165015
|
||||
614 0.6705001061
|
||||
615 0.6705013916
|
||||
616 0.6705037253
|
||||
617 0.67049647
|
||||
618 0.6705005632
|
||||
619 0.6704957943
|
||||
620 0.6704955333
|
||||
621 0.6704961207
|
||||
622 0.6704921459
|
||||
623 0.6704751713
|
||||
624 0.6704753101
|
||||
625 0.6704620888
|
||||
626 0.6704604282
|
||||
627 0.6704663192
|
||||
628 0.6704680085
|
||||
629 0.670453228
|
||||
630 0.6704577785
|
||||
631 0.67046675
|
||||
632 0.6704731863
|
||||
633 0.6704811116
|
||||
634 0.6704839644
|
||||
635 0.6704854798
|
||||
636 0.6704835837
|
||||
637 0.6704736198
|
||||
638 0.6704640242
|
||||
639 0.670465663
|
||||
640 0.6704646829
|
||||
641 0.6704600961
|
||||
642 0.6704643207
|
||||
643 0.6704600533
|
||||
644 0.6704614691
|
||||
645 0.6704728212
|
||||
646 0.6704758731
|
||||
647 0.6704833026
|
||||
648 0.6704767664
|
||||
649 0.6704702727
|
||||
650 0.6704671372
|
||||
651 0.6704699936
|
||||
652 0.6704587989
|
||||
653 0.6704637668
|
||||
654 0.6704653717
|
||||
655 0.6704598273
|
||||
656 0.6704522865
|
||||
657 0.6704558586
|
||||
658 0.6704466331
|
||||
659 0.6704405886
|
||||
660 0.6704463767
|
||||
661 0.6704475216
|
||||
662 0.6704572386
|
||||
663 0.6704658153
|
||||
664 0.6704600945
|
||||
665 0.6704561998
|
||||
666 0.6704535154
|
||||
667 0.6704413781
|
||||
668 0.6704450013
|
||||
669 0.6704422199
|
||||
670 0.67044342
|
||||
671 0.6704415341
|
||||
672 0.6704439539
|
||||
673 0.6704498197
|
||||
674 0.6704452194
|
||||
675 0.6704366524
|
||||
676 0.6704427124
|
||||
677 0.6704395579
|
||||
678 0.6704401246
|
||||
679 0.6704415621
|
||||
680 0.6704341343
|
||||
681 0.6704369615
|
||||
682 0.6704357425
|
||||
683 0.6704294622
|
||||
684 0.6704289794
|
||||
685 0.6704272409
|
||||
686 0.6704101162
|
||||
687 0.6704069439
|
||||
688 0.6704100747
|
||||
689 0.6704122261
|
||||
690 0.6704137826
|
||||
691 0.6704207952
|
||||
692 0.6704154834
|
||||
693 0.6704253514
|
||||
694 0.6704155636
|
||||
695 0.6704141298
|
||||
696 0.6704207635
|
||||
697 0.6704268341
|
||||
698 0.6704243126
|
||||
699 0.6704235165
|
||||
700 0.6704257736
|
||||
701 0.6704247758
|
||||
702 0.6704331799
|
||||
703 0.6704252722
|
||||
704 0.6704146644
|
||||
705 0.6704164122
|
||||
706 0.6704118954
|
||||
707 0.6704043129
|
||||
708 0.6703978198
|
||||
709 0.6703935976
|
||||
710 0.6703839683
|
||||
711 0.6703843723
|
||||
712 0.6703879502
|
||||
713 0.6703895978
|
||||
714 0.6703894359
|
||||
715 0.6703928777
|
||||
716 0.6703933128
|
||||
717 0.6703844355
|
||||
718 0.6703825151
|
||||
719 0.6703983542
|
||||
720 0.670399556
|
||||
721 0.6703931808
|
||||
722 0.6703886918
|
||||
723 0.6703847574
|
||||
724 0.6703885941
|
||||
725 0.6703788615
|
||||
726 0.6703799906
|
||||
727 0.6703774518
|
||||
728 0.6703783496
|
||||
729 0.6703648854
|
||||
730 0.6703716654
|
||||
731 0.6703550938
|
||||
732 0.6703467057
|
||||
733 0.6703484503
|
||||
734 0.6703549183
|
||||
735 0.6703501504
|
||||
736 0.6703672622
|
||||
737 0.6703560249
|
||||
738 0.6703547155
|
||||
739 0.6703593236
|
||||
740 0.6703606827
|
||||
741 0.6703511404
|
||||
742 0.6703431646
|
||||
743 0.6703475116
|
||||
744 0.6703483634
|
||||
745 0.6703475713
|
||||
746 0.670360457
|
||||
747 0.6703664352
|
||||
748 0.6703617612
|
||||
749 0.6703669926
|
||||
750 0.6703670837
|
||||
751 0.6703706628
|
||||
752 0.670369618
|
||||
753 0.6703692351
|
||||
754 0.6703624433
|
||||
755 0.6703686285
|
||||
756 0.6703598432
|
||||
757 0.6703618766
|
||||
758 0.6703694148
|
||||
759 0.6703683652
|
||||
760 0.6703604855
|
||||
761 0.6703758987
|
||||
762 0.6703773302
|
||||
763 0.6703641028
|
||||
764 0.6703649602
|
||||
765 0.6703567811
|
||||
766 0.6703544688
|
||||
767 0.6703611821
|
||||
768 0.6703527821
|
||||
769 0.6703523616
|
||||
770 0.6703616298
|
||||
771 0.6703603551
|
||||
772 0.6703675655
|
||||
773 0.6703582411
|
||||
774 0.6703581437
|
||||
775 0.6703551885
|
||||
776 0.6703608491
|
||||
777 0.6703674554
|
||||
778 0.6703679619
|
||||
779 0.6703701757
|
||||
780 0.6703603462
|
||||
781 0.670359801
|
||||
782 0.6703523669
|
||||
783 0.6703365674
|
||||
784 0.6703486118
|
||||
785 0.6703450011
|
||||
786 0.6703473135
|
||||
787 0.670350998
|
||||
788 0.6703417767
|
||||
789 0.6703349821
|
||||
790 0.6703457717
|
||||
791 0.6703506266
|
||||
792 0.6703596395
|
||||
793 0.6703799895
|
||||
794 0.6703687687
|
||||
795 0.6703780675
|
||||
796 0.670374835
|
||||
797 0.6703831387
|
||||
798 0.670377656
|
||||
799 0.6703689741
|
||||
800 0.6703709756
|
||||
801 0.6703737517
|
||||
802 0.6703818964
|
||||
803 0.6703812173
|
||||
804 0.6703960068
|
||||
805 0.6703976729
|
||||
806 0.6704024604
|
||||
807 0.6704085008
|
||||
808 0.6704076633
|
||||
809 0.6704111719
|
||||
810 0.6704220803
|
||||
811 0.6704265011
|
||||
812 0.6704251162
|
||||
813 0.6704375472
|
||||
814 0.6704319336
|
||||
815 0.670437614
|
||||
816 0.6704554331
|
||||
817 0.6704603317
|
||||
818 0.6704548042
|
||||
819 0.6704502654
|
||||
820 0.6704512072
|
||||
821 0.6704433481
|
||||
822 0.6704095112
|
||||
823 0.6704086019
|
||||
824 0.6703987131
|
||||
825 0.6704019708
|
||||
826 0.6704046556
|
||||
827 0.6704091961
|
||||
828 0.6704103204
|
||||
829 0.6704074257
|
||||
830 0.6704115335
|
||||
831 0.6704041275
|
||||
832 0.6704004556
|
||||
833 0.6704037097
|
||||
834 0.6704035477
|
||||
835 0.6704025281
|
||||
836 0.6704024549
|
||||
837 0.670405721
|
||||
838 0.6703983189
|
||||
839 0.6704035256
|
||||
840 0.6704047613
|
||||
841 0.6704139617
|
||||
842 0.6704066193
|
||||
843 0.670402892
|
||||
844 0.6704081961
|
||||
845 0.6704029862
|
||||
846 0.6704014281
|
||||
847 0.6704036115
|
||||
848 0.6704016777
|
||||
849 0.6704085392
|
||||
850 0.6704042952
|
||||
851 0.670394789
|
||||
852 0.6703885644
|
||||
853 0.6703946813
|
||||
854 0.6704042137
|
||||
855 0.6704077517
|
||||
856 0.6704118698
|
||||
857 0.6704114259
|
||||
858 0.6704157567
|
||||
859 0.6703837005
|
||||
860 0.6703829482
|
||||
861 0.6703818491
|
||||
862 0.6703826129
|
||||
863 0.6703834487
|
||||
864 0.6703868275
|
||||
865 0.6703853357
|
||||
866 0.670450644
|
||||
867 0.6704556991
|
||||
868 0.6704535742
|
||||
869 0.6704495915
|
||||
|
@@ -1,17 +1,19 @@
|
||||
import os
|
||||
import json
|
||||
import yaml
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
class EnsembleConfig:
|
||||
_instance: Optional['EnsembleConfig'] = None
|
||||
_config: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(EnsembleConfig, cls).__new__(cls)
|
||||
cls._instance._load_config()
|
||||
return cls._instance
|
||||
|
||||
|
||||
def _load_config(self):
|
||||
"""Load configuration from YAML file."""
|
||||
config_path = os.path.join(os.path.dirname(__file__), 'ensemble_config.yaml')
|
||||
@@ -22,12 +24,12 @@ class EnsembleConfig:
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load ensemble config: {e}")
|
||||
self._config = {}
|
||||
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get configuration value by key (supports dot notation for nested keys)."""
|
||||
keys = key.split('.')
|
||||
value = self._config
|
||||
|
||||
|
||||
try:
|
||||
for k in keys:
|
||||
value = value[k]
|
||||
@@ -35,12 +37,79 @@ class EnsembleConfig:
|
||||
except (KeyError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
# Singleton accessor
|
||||
def get_config() -> EnsembleConfig:
|
||||
return EnsembleConfig()
|
||||
|
||||
|
||||
# ── Market Thresholds Loader ────────────────────────────────────────────
|
||||
|
||||
_market_thresholds_cache: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
def load_market_thresholds() -> Dict[str, Any]:
|
||||
"""
|
||||
Load market thresholds from JSON config file.
|
||||
Returns the full config dict with 'markets' and 'defaults' keys.
|
||||
Caches after first load for performance.
|
||||
"""
|
||||
global _market_thresholds_cache
|
||||
if _market_thresholds_cache is not None:
|
||||
return _market_thresholds_cache
|
||||
|
||||
config_path = os.path.join(os.path.dirname(__file__), 'market_thresholds.json')
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
_market_thresholds_cache = data
|
||||
print(f"✅ Market thresholds loaded: {len(data.get('markets', {}))} markets (v={data.get('_meta', {}).get('version', '?')})")
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load market thresholds: {e} — using built-in defaults")
|
||||
_market_thresholds_cache = {"markets": {}, "defaults": {
|
||||
"calibration": 0.55,
|
||||
"min_conf": 55.0,
|
||||
"min_play_score": 68.0,
|
||||
"min_edge": 0.02,
|
||||
"odds_band_min_sample": 0.0,
|
||||
"odds_band_min_edge": 0.0,
|
||||
}}
|
||||
return _market_thresholds_cache
|
||||
|
||||
|
||||
def build_threshold_dict(field: str) -> Dict[str, float]:
|
||||
"""
|
||||
Build a flat {market: value} dict for a specific threshold field.
|
||||
|
||||
Usage:
|
||||
calibration_map = build_threshold_dict("calibration")
|
||||
# → {"MS": 0.62, "DC": 0.82, ...}
|
||||
"""
|
||||
data = load_market_thresholds()
|
||||
markets = data.get("markets", {})
|
||||
result: Dict[str, float] = {}
|
||||
for market, cfg in markets.items():
|
||||
if field in cfg:
|
||||
result[market] = float(cfg[field])
|
||||
return result
|
||||
|
||||
|
||||
def get_threshold_default(field: str) -> float:
|
||||
"""Get the default fallback value for a threshold field."""
|
||||
data = load_market_thresholds()
|
||||
defaults = data.get("defaults", {})
|
||||
return float(defaults.get(field, 0.0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test
|
||||
cfg = get_config()
|
||||
print(f"Weights: {cfg.get('engine_weights')}")
|
||||
print(f"Team Weight: {cfg.get('engine_weights.team')}")
|
||||
print()
|
||||
print("--- Market Thresholds ---")
|
||||
for field in ["calibration", "min_conf", "min_play_score", "min_edge"]:
|
||||
d = build_threshold_dict(field)
|
||||
print(f"{field}: {d}")
|
||||
print(f"Default calibration: {get_threshold_default('calibration')}")
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"_meta": {
|
||||
"version": "v34",
|
||||
"description": "Market-specific thresholds for the betting engine pipeline — V34 odds-aware gate fix",
|
||||
"rule": "max_reachable (100 × calibration) MUST be > min_conf + 8",
|
||||
"updated_at": "2026-05-10",
|
||||
"changelog": "V34: Reduced min_edge to realistic levels for odds-aware V25 model. Model output ≈ market-implied, so large EV edges are mathematically impossible."
|
||||
},
|
||||
"markets": {
|
||||
"MS": {
|
||||
"calibration": 0.62,
|
||||
"min_conf": 20.0,
|
||||
"min_play_score": 28.0,
|
||||
"min_edge": 0.005,
|
||||
"odds_band_min_sample": 8.0,
|
||||
"odds_band_min_edge": 0.005
|
||||
},
|
||||
"DC": {
|
||||
"calibration": 0.82,
|
||||
"min_conf": 40.0,
|
||||
"min_play_score": 50.0,
|
||||
"min_edge": 0.003,
|
||||
"odds_band_min_sample": 8.0,
|
||||
"odds_band_min_edge": 0.005
|
||||
},
|
||||
"OU15": {
|
||||
"calibration": 0.84,
|
||||
"min_conf": 45.0,
|
||||
"min_play_score": 50.0,
|
||||
"min_edge": 0.003,
|
||||
"odds_band_min_sample": 8.0,
|
||||
"odds_band_min_edge": 0.005
|
||||
},
|
||||
"OU25": {
|
||||
"calibration": 0.68,
|
||||
"min_conf": 30.0,
|
||||
"min_play_score": 40.0,
|
||||
"min_edge": 0.005,
|
||||
"odds_band_min_sample": 8.0,
|
||||
"odds_band_min_edge": 0.005
|
||||
},
|
||||
"OU35": {
|
||||
"calibration": 0.60,
|
||||
"min_conf": 20.0,
|
||||
"min_play_score": 30.0,
|
||||
"min_edge": 0.008,
|
||||
"odds_band_min_sample": 8.0,
|
||||
"odds_band_min_edge": 0.008
|
||||
},
|
||||
"BTTS": {
|
||||
"calibration": 0.65,
|
||||
"min_conf": 30.0,
|
||||
"min_play_score": 40.0,
|
||||
"min_edge": 0.005,
|
||||
"odds_band_min_sample": 8.0,
|
||||
"odds_band_min_edge": 0.005
|
||||
},
|
||||
"HT": {
|
||||
"calibration": 0.58,
|
||||
"min_conf": 20.0,
|
||||
"min_play_score": 28.0,
|
||||
"min_edge": 0.01,
|
||||
"odds_band_min_sample": 8.0,
|
||||
"odds_band_min_edge": 0.008
|
||||
},
|
||||
"HT_OU05": {
|
||||
"calibration": 0.68,
|
||||
"min_conf": 35.0,
|
||||
"min_play_score": 42.0,
|
||||
"min_edge": 0.005,
|
||||
"odds_band_min_sample": 8.0,
|
||||
"odds_band_min_edge": 0.005
|
||||
},
|
||||
"HT_OU15": {
|
||||
"calibration": 0.60,
|
||||
"min_conf": 25.0,
|
||||
"min_play_score": 32.0,
|
||||
"min_edge": 0.008,
|
||||
"odds_band_min_sample": 8.0,
|
||||
"odds_band_min_edge": 0.008
|
||||
},
|
||||
"OE": {
|
||||
"calibration": 0.62,
|
||||
"min_conf": 35.0,
|
||||
"min_play_score": 32.0,
|
||||
"min_edge": 0.005
|
||||
},
|
||||
"CARDS": {
|
||||
"calibration": 0.58,
|
||||
"min_conf": 30.0,
|
||||
"min_play_score": 35.0,
|
||||
"min_edge": 0.008
|
||||
},
|
||||
"HCAP": {
|
||||
"calibration": 0.56,
|
||||
"min_conf": 25.0,
|
||||
"min_play_score": 30.0,
|
||||
"min_edge": 0.015
|
||||
},
|
||||
"HTFT": {
|
||||
"calibration": 0.45,
|
||||
"min_conf": 10.0,
|
||||
"min_play_score": 18.0,
|
||||
"min_edge": 0.02
|
||||
}
|
||||
},
|
||||
"defaults": {
|
||||
"calibration": 0.55,
|
||||
"min_conf": 55.0,
|
||||
"min_play_score": 60.0,
|
||||
"min_edge": 0.008,
|
||||
"odds_band_min_sample": 0.0,
|
||||
"odds_band_min_edge": 0.0
|
||||
}
|
||||
}
|
||||
@@ -18,15 +18,20 @@ from features.sidelined_analyzer import get_sidelined_analyzer
|
||||
|
||||
@dataclass
|
||||
class PlayerPrediction:
|
||||
"""Player engine prediction output."""
|
||||
home_squad_quality: float = 50.0 # 0-100
|
||||
away_squad_quality: float = 50.0
|
||||
squad_diff: float = 0.0 # -100 to +100
|
||||
"""Player engine prediction output.
|
||||
|
||||
IMPORTANT: squad_quality uses the SAME composite formula as
|
||||
extract_training_data.py so that inference values match the
|
||||
distribution the model was trained on (~3-36 range).
|
||||
"""
|
||||
home_squad_quality: float = 12.0 # training-scale composite (~3-36)
|
||||
away_squad_quality: float = 12.0
|
||||
squad_diff: float = 0.0 # home - away (training scale)
|
||||
home_key_players: int = 0
|
||||
away_key_players: int = 0
|
||||
home_missing_impact: float = 0.0 # 0-1, how much weaker due to missing players
|
||||
home_missing_impact: float = 0.0 # 0-1, how much weaker due to missing players
|
||||
away_missing_impact: float = 0.0
|
||||
home_goals_form: int = 0 # Goals in last 5 matches
|
||||
home_goals_form: int = 0 # Goals in last 5 matches
|
||||
away_goals_form: int = 0
|
||||
lineup_available: bool = False
|
||||
confidence: float = 0.0
|
||||
@@ -100,10 +105,12 @@ class PlayerPredictorEngine:
|
||||
"home_goals_last_5": home_analysis.total_goals_last_5,
|
||||
"home_assists_last_5": home_analysis.total_assists_last_5,
|
||||
"home_key_players": home_analysis.key_players_count,
|
||||
"home_forwards": home_analysis.forward_count or 2,
|
||||
"away_starting_11": away_analysis.starting_count or 11,
|
||||
"away_goals_last_5": away_analysis.total_goals_last_5,
|
||||
"away_assists_last_5": away_analysis.total_assists_last_5,
|
||||
"away_key_players": away_analysis.key_players_count,
|
||||
"away_forwards": away_analysis.forward_count or 2,
|
||||
}
|
||||
elif match_id:
|
||||
# Try to get from database
|
||||
@@ -131,13 +138,31 @@ class PlayerPredictorEngine:
|
||||
away_goals = features.get("away_goals_last_5", 0)
|
||||
home_key = features.get("home_key_players", 0)
|
||||
away_key = features.get("away_key_players", 0)
|
||||
home_assists = features.get("home_assists_last_5", 0)
|
||||
away_assists = features.get("away_assists_last_5", 0)
|
||||
home_starting = features.get("home_starting_11", 11)
|
||||
away_starting = features.get("away_starting_11", 11)
|
||||
home_fwd = features.get("home_forwards", 2)
|
||||
away_fwd = features.get("away_forwards", 2)
|
||||
|
||||
# Calculate squad quality (0-100)
|
||||
# Based on: goals scored, key players, assists
|
||||
home_quality = min(100, 50 + (home_goals * 3) + (home_key * 5) +
|
||||
features.get("home_assists_last_5", 0) * 2)
|
||||
away_quality = min(100, 50 + (away_goals * 3) + (away_key * 5) +
|
||||
features.get("away_assists_last_5", 0) * 2)
|
||||
# Calculate squad quality — MUST match extract_training_data.py formula
|
||||
# Formula: starting_count * 0.3 + goals * 2.0 + assists * 1.0
|
||||
# + key_players * 3.0 + fwd_count * 1.5
|
||||
# Typical range: ~3 – 36 (model trained on this distribution)
|
||||
home_quality = (
|
||||
home_starting * 0.3 +
|
||||
home_goals * 2.0 +
|
||||
home_assists * 1.0 +
|
||||
home_key * 3.0 +
|
||||
home_fwd * 1.5
|
||||
)
|
||||
away_quality = (
|
||||
away_starting * 0.3 +
|
||||
away_goals * 2.0 +
|
||||
away_assists * 1.0 +
|
||||
away_key * 3.0 +
|
||||
away_fwd * 1.5
|
||||
)
|
||||
|
||||
# Squad difference
|
||||
squad_diff = home_quality - away_quality
|
||||
@@ -186,8 +211,10 @@ class PlayerPredictorEngine:
|
||||
Calculate 1X2 probability modifiers based on squad analysis.
|
||||
|
||||
Returns modifiers to apply to base probabilities.
|
||||
squad_diff is in training scale (~-33 to +33), normalize to -1..+1.
|
||||
"""
|
||||
diff = prediction.squad_diff / 100 # -1 to +1
|
||||
diff = prediction.squad_diff / 33.0 # training-scale normalisation
|
||||
diff = max(-1.0, min(1.0, diff)) # clamp
|
||||
|
||||
return {
|
||||
"home_modifier": 1.0 + (diff * 0.3), # Up to +/-30%
|
||||
|
||||
@@ -323,8 +323,8 @@ class OddsBandAnalyzer:
|
||||
m.home_team_id,
|
||||
m.away_team_id,
|
||||
CASE
|
||||
WHEN m.home_team_id = %(team_id)s THEN os_sel.odd_value
|
||||
ELSE os_sel2.odd_value
|
||||
WHEN m.home_team_id = %(team_id)s THEN os_sel.odd_value::numeric
|
||||
ELSE os_sel2.odd_value::numeric
|
||||
END AS team_odds
|
||||
FROM matches m
|
||||
JOIN odd_categories oc
|
||||
@@ -344,7 +344,7 @@ class OddsBandAnalyzer:
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.score_away IS NOT NULL
|
||||
AND m.mst_utc < %(before_ts)s
|
||||
AND COALESCE(os_sel.odd_value, os_sel2.odd_value)
|
||||
AND COALESCE(os_sel.odd_value::numeric, os_sel2.odd_value::numeric)
|
||||
BETWEEN %(band_low)s AND %(band_high)s
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT %(max_lookback)s
|
||||
@@ -432,7 +432,7 @@ class OddsBandAnalyzer:
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.score_away IS NOT NULL
|
||||
AND m.mst_utc < %(before_ts)s
|
||||
AND os_h.odd_value BETWEEN %(band_low)s AND %(band_high)s
|
||||
AND os_h.odd_value::numeric BETWEEN %(band_low)s AND %(band_high)s
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT %(max_lookback)s
|
||||
)
|
||||
@@ -508,7 +508,7 @@ class OddsBandAnalyzer:
|
||||
f"İlk Yarı {line_str} Alt/Üst",
|
||||
f"Ilk Yari {line_str} Alt/Ust",
|
||||
]
|
||||
score_expr = "COALESCE(m.score_ht_home, 0) + COALESCE(m.score_ht_away, 0)"
|
||||
score_expr = "COALESCE(m.ht_score_home, 0) + COALESCE(m.ht_score_away, 0)"
|
||||
else:
|
||||
cat_names = [
|
||||
f"{line_str} Alt/Üst",
|
||||
@@ -535,7 +535,7 @@ class OddsBandAnalyzer:
|
||||
AND m.status = 'FT'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.mst_utc < %(before_ts)s
|
||||
AND os_over.odd_value BETWEEN %(band_low)s AND %(band_high)s
|
||||
AND os_over.odd_value::numeric BETWEEN %(band_low)s AND %(band_high)s
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT %(max_lookback)s
|
||||
)
|
||||
@@ -620,7 +620,7 @@ class OddsBandAnalyzer:
|
||||
AND m.status = 'FT'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.mst_utc < %(before_ts)s
|
||||
AND os_yes.odd_value BETWEEN %(band_low)s AND %(band_high)s
|
||||
AND os_yes.odd_value::numeric BETWEEN %(band_low)s AND %(band_high)s
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT %(max_lookback)s
|
||||
)
|
||||
@@ -696,7 +696,7 @@ class OddsBandAnalyzer:
|
||||
AND m.sport = 'football' AND m.status = 'FT'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.mst_utc < %(before_ts)s
|
||||
AND os_sel.odd_value BETWEEN %(bl)s AND %(bh)s
|
||||
AND os_sel.odd_value::numeric BETWEEN %(bl)s AND %(bh)s
|
||||
ORDER BY m.mst_utc DESC LIMIT %(ml)s
|
||||
)
|
||||
SELECT COUNT(*) AS ss,
|
||||
@@ -748,7 +748,7 @@ class OddsBandAnalyzer:
|
||||
try:
|
||||
cur.execute("""
|
||||
WITH ht_matches AS (
|
||||
SELECT m.score_ht_home, m.score_ht_away,
|
||||
SELECT m.ht_score_home, m.ht_score_away,
|
||||
m.home_team_id, m.away_team_id
|
||||
FROM matches m
|
||||
JOIN odd_categories oc ON oc.match_id = m.id
|
||||
@@ -761,18 +761,18 @@ class OddsBandAnalyzer:
|
||||
AND os2.name = '2' AND m.away_team_id = %(tid)s
|
||||
WHERE (m.home_team_id = %(tid)s OR m.away_team_id = %(tid)s)
|
||||
AND m.sport = 'football' AND m.status = 'FT'
|
||||
AND m.score_ht_home IS NOT NULL
|
||||
AND m.ht_score_home IS NOT NULL
|
||||
AND m.mst_utc < %(before_ts)s
|
||||
AND COALESCE(os1.odd_value, os2.odd_value)
|
||||
AND COALESCE(os1.odd_value::numeric, os2.odd_value::numeric)
|
||||
BETWEEN %(bl)s AND %(bh)s
|
||||
ORDER BY m.mst_utc DESC LIMIT %(ml)s
|
||||
)
|
||||
SELECT COUNT(*) AS ss,
|
||||
COALESCE(AVG(CASE
|
||||
WHEN (home_team_id = %(tid)s AND score_ht_home > score_ht_away)
|
||||
OR (away_team_id = %(tid)s AND score_ht_away > score_ht_home)
|
||||
WHEN (home_team_id = %(tid)s AND ht_score_home > ht_score_away)
|
||||
OR (away_team_id = %(tid)s AND ht_score_away > ht_score_home)
|
||||
THEN 1.0 ELSE 0.0 END), 0.33) AS win_rate,
|
||||
COALESCE(AVG(CASE WHEN score_ht_home = score_ht_away
|
||||
COALESCE(AVG(CASE WHEN ht_score_home = ht_score_away
|
||||
THEN 1.0 ELSE 0.0 END), 0.40) AS draw_rate
|
||||
FROM ht_matches
|
||||
""", {
|
||||
@@ -824,7 +824,7 @@ class OddsBandAnalyzer:
|
||||
AND m.sport = 'football' AND m.status = 'FT'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.mst_utc < %(before_ts)s
|
||||
AND os_odd.odd_value BETWEEN %(bl)s AND %(bh)s
|
||||
AND os_odd.odd_value::numeric BETWEEN %(bl)s AND %(bh)s
|
||||
ORDER BY m.mst_utc DESC LIMIT %(ml)s
|
||||
)
|
||||
SELECT COUNT(*) AS ss,
|
||||
@@ -1185,7 +1185,7 @@ class OddsBandAnalyzer:
|
||||
'IY/MS'
|
||||
)
|
||||
JOIN odd_selections os ON os.odd_category_db_id = oc.db_id
|
||||
AND os.odd_value BETWEEN %(bl)s AND %(bh)s
|
||||
AND os.odd_value::numeric BETWEEN %(bl)s AND %(bh)s
|
||||
WHERE m.sport = 'football'
|
||||
AND m.status = 'FT'
|
||||
AND m.score_home IS NOT NULL
|
||||
|
||||
+14
-8
@@ -14,10 +14,13 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from models.basketball_v25 import get_basketball_v25_predictor
|
||||
try:
|
||||
from models.basketball_v25 import get_basketball_v25_predictor
|
||||
HAS_BASKETBALL = True
|
||||
except ImportError:
|
||||
HAS_BASKETBALL = False
|
||||
from services.single_match_orchestrator import get_single_match_orchestrator
|
||||
from services.v26_shadow_engine import get_v26_shadow_engine
|
||||
from data.database import dispose_engine
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -49,9 +52,6 @@ async def lifespan(_: FastAPI):
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup async DB connections on shutdown
|
||||
await dispose_engine()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Suggest-Bet AI Engine",
|
||||
@@ -123,9 +123,15 @@ def health_check() -> dict[str, Any]:
|
||||
try:
|
||||
orchestrator = get_single_match_orchestrator()
|
||||
shadow_engine = get_v26_shadow_engine()
|
||||
basketball_predictor = get_basketball_v25_predictor()
|
||||
basketball_readiness = basketball_predictor.readiness_summary()
|
||||
ready = bool(basketball_readiness["fully_loaded"])
|
||||
|
||||
if HAS_BASKETBALL:
|
||||
basketball_predictor = get_basketball_v25_predictor()
|
||||
basketball_readiness = basketball_predictor.readiness_summary()
|
||||
ready = bool(basketball_readiness.get("fully_loaded", True))
|
||||
else:
|
||||
basketball_readiness = {"fully_loaded": False, "error": "Basketball module not found"}
|
||||
ready = True
|
||||
|
||||
return {
|
||||
"status": "healthy" if ready else "degraded",
|
||||
"engine": "v28.main",
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
Calibration Module for XGBoost Models
|
||||
=====================================
|
||||
Calibrates raw probabilities from XGBoost models using Isotonic Regression.
|
||||
Ensures that a predicted probability of 70% actually corresponds to a 70% win rate.
|
||||
|
||||
Usage:
|
||||
from ai_engine.models.calibration import Calibrator
|
||||
calibrator = Calibrator()
|
||||
calibrated_prob = calibrator.calibrate("ms", raw_prob)
|
||||
|
||||
# Training new calibration models:
|
||||
calibrator.train_calibration(valid_df, market="ms")
|
||||
"""
|
||||
|
||||
import os
|
||||
import pickle
|
||||
import json
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from sklearn.isotonic import IsotonicRegression
|
||||
from sklearn.calibration import calibration_curve
|
||||
from sklearn.metrics import brier_score_loss
|
||||
|
||||
AI_ENGINE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CALIBRATION_DIR = os.path.join(AI_ENGINE_DIR, "models", "calibration")
|
||||
|
||||
os.makedirs(CALIBRATION_DIR, exist_ok=True)
|
||||
|
||||
# Supported markets for calibration
|
||||
SUPPORTED_MARKETS = [
|
||||
"ms", # Match Result (1X2) - multi-class, calibrated per class
|
||||
"ms_home", # Standard Home win probability
|
||||
"ms_home_heavy_fav", # Context: home odds <= 1.40
|
||||
"ms_home_fav", # Context: 1.40 < home odds <= 1.80
|
||||
"ms_home_balanced", # Context: 1.80 < home odds <= 2.50
|
||||
"ms_home_underdog", # Context: home odds > 2.50
|
||||
"ms_draw", # Draw probability
|
||||
"ms_away", # Away win probability
|
||||
"ou15", # Over/Under 1.5
|
||||
"ou25", # Over/Under 2.5
|
||||
"ou35", # Over/Under 3.5
|
||||
"btts", # Both Teams to Score
|
||||
"ht_ft", # Half-Time/Full-Time
|
||||
"dc", # Double Chance
|
||||
"ht", # Half-Time Result
|
||||
]
|
||||
|
||||
|
||||
class CalibrationMetrics:
|
||||
"""Stores calibration quality metrics for a market."""
|
||||
|
||||
def __init__(self):
|
||||
self.brier_score: float = 0.0
|
||||
self.calibration_error: float = 0.0
|
||||
self.sample_count: int = 0
|
||||
self.last_trained: str = ""
|
||||
self.mean_predicted: float = 0.0
|
||||
self.mean_actual: float = 0.0
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
"brier_score": round(self.brier_score, 4),
|
||||
"calibration_error": round(self.calibration_error, 4),
|
||||
"sample_count": self.sample_count,
|
||||
"last_trained": self.last_trained,
|
||||
"mean_predicted": round(self.mean_predicted, 4),
|
||||
"mean_actual": round(self.mean_actual, 4),
|
||||
}
|
||||
|
||||
|
||||
class Calibrator:
|
||||
"""
|
||||
Probability calibration using Isotonic Regression.
|
||||
|
||||
Isotonic Regression is a non-parametric method that fits a piecewise
|
||||
constant function that is monotonically increasing. It's ideal for
|
||||
calibrating probabilities because:
|
||||
|
||||
1. It preserves ranking (if P(A) > P(B) before, P(A) > P(B) after)
|
||||
2. It doesn't assume a specific distribution shape
|
||||
3. It can correct systematic over/under-confidence
|
||||
|
||||
Example:
|
||||
# Before calibration: model predicts 70% but actual win rate is 60%
|
||||
# After calibration: model predicts 70% → calibrated to 60%
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.calibrators: Dict[str, IsotonicRegression] = {}
|
||||
self.metrics: Dict[str, CalibrationMetrics] = {}
|
||||
self.heuristic_fallback: Dict[str, float] = {
|
||||
"ms": 0.90,
|
||||
"ms_home": 0.90,
|
||||
"ms_home_heavy_fav": 0.95,
|
||||
"ms_home_fav": 0.90,
|
||||
"ms_home_balanced": 0.85,
|
||||
"ms_home_underdog": 0.80,
|
||||
"ms_draw": 0.90,
|
||||
"ms_away": 0.90,
|
||||
"ou15": 0.90,
|
||||
"ou25": 0.90,
|
||||
"ou35": 0.90,
|
||||
"btts": 0.90,
|
||||
"ht_ft": 0.85,
|
||||
"dc": 0.93,
|
||||
"ht": 0.85,
|
||||
}
|
||||
self._load_calibrators()
|
||||
|
||||
def _load_calibrators(self):
|
||||
"""Load trained calibrators for each market from disk."""
|
||||
for market in SUPPORTED_MARKETS:
|
||||
model_path = os.path.join(CALIBRATION_DIR, f"{market}_calibrator.pkl")
|
||||
metrics_path = os.path.join(CALIBRATION_DIR, f"{market}_metrics.json")
|
||||
|
||||
if os.path.exists(model_path):
|
||||
try:
|
||||
with open(model_path, "rb") as f:
|
||||
self.calibrators[market] = pickle.load(f)
|
||||
print(f"[Calibrator] Loaded calibration model for {market}")
|
||||
except Exception as e:
|
||||
print(f"[Calibrator] Warning: Failed to load {market}: {e}")
|
||||
|
||||
if os.path.exists(metrics_path):
|
||||
try:
|
||||
with open(metrics_path, "r") as f:
|
||||
data = json.load(f)
|
||||
metrics = CalibrationMetrics()
|
||||
metrics.brier_score = data.get("brier_score", 0.0)
|
||||
metrics.calibration_error = data.get("calibration_error", 0.0)
|
||||
metrics.sample_count = data.get("sample_count", 0)
|
||||
metrics.last_trained = data.get("last_trained", "")
|
||||
metrics.mean_predicted = data.get("mean_predicted", 0.0)
|
||||
metrics.mean_actual = data.get("mean_actual", 0.0)
|
||||
self.metrics[market] = metrics
|
||||
except Exception as e:
|
||||
print(f"[Calibrator] Warning: Failed to load metrics for {market}: {e}")
|
||||
|
||||
def calibrate(self, market_type: str, raw_prob: float, odds_val: Optional[float] = None) -> float:
|
||||
"""
|
||||
Calibrate a raw probability using Isotonic Regression.
|
||||
|
||||
Args:
|
||||
market_type (str): 'ms_home', 'ou25', 'btts', 'ht_ft', etc.
|
||||
raw_prob (float): The raw probability from XGBoost (0.0 - 1.0)
|
||||
odds_val (float, optional): The pre-match odds, used for context-aware bucket mapping
|
||||
|
||||
Returns:
|
||||
float: Calibrated probability (0.0 - 1.0)
|
||||
"""
|
||||
# Normalize market type
|
||||
market_key = market_type.lower().replace("-", "_")
|
||||
|
||||
# Route to bucket if ms_home and odds provided
|
||||
if market_key == "ms_home" and odds_val is not None and odds_val > 1.0:
|
||||
if odds_val <= 1.40:
|
||||
bucket_key = "ms_home_heavy_fav"
|
||||
elif odds_val <= 1.80:
|
||||
bucket_key = "ms_home_fav"
|
||||
elif odds_val <= 2.50:
|
||||
bucket_key = "ms_home_balanced"
|
||||
else:
|
||||
bucket_key = "ms_home_underdog"
|
||||
|
||||
if bucket_key in self.calibrators:
|
||||
market_key = bucket_key
|
||||
|
||||
# If we have a trained Isotonic Regression model, use it
|
||||
if market_key in self.calibrators:
|
||||
try:
|
||||
calibrated = self.calibrators[market_key].predict([raw_prob])[0]
|
||||
# Ensure output is valid probability
|
||||
return float(np.clip(calibrated, 0.01, 0.99))
|
||||
except Exception as e:
|
||||
print(f"[Calibrator] Warning: Isotonic failed for {market_key}: {e}")
|
||||
# Fall through to heuristic
|
||||
|
||||
# Fallback to heuristic calibration
|
||||
return self._heuristic_calibrate(market_key, raw_prob)
|
||||
|
||||
def _heuristic_calibrate(self, market_type: str, raw_prob: float) -> float:
|
||||
"""
|
||||
Heuristic calibration fallback when no trained model exists.
|
||||
|
||||
This applies a conservative shrinkage towards the mean:
|
||||
- Binary markets (OU, BTTS): shrink towards 0.5
|
||||
- Multi-class (MS): shrink towards 0.33
|
||||
- HT/FT: stronger shrinkage due to higher variance
|
||||
"""
|
||||
# Get shrinkage factor for this market
|
||||
shrinkage = self.heuristic_fallback.get(market_type, 0.90)
|
||||
|
||||
if market_type in ["ms", "ms_home", "ms_home_heavy_fav", "ms_home_fav", "ms_home_balanced", "ms_home_underdog", "ms_draw", "ms_away"]:
|
||||
# Pull towards 0.33 (uniform for 3-class)
|
||||
return (raw_prob * shrinkage) + (0.33 * (1.0 - shrinkage))
|
||||
|
||||
elif market_type in ["ou15", "ou25", "ou35", "btts"]:
|
||||
# Pull towards 0.5 (uniform for binary)
|
||||
return (raw_prob * shrinkage) + (0.5 * (1.0 - shrinkage))
|
||||
|
||||
elif market_type in ["ht_ft", "ht"]:
|
||||
# Stronger shrinkage for high-variance markets
|
||||
return raw_prob * shrinkage
|
||||
|
||||
elif market_type == "dc":
|
||||
# Double chance is more reliable
|
||||
return (raw_prob * shrinkage) + (0.66 * (1.0 - shrinkage))
|
||||
|
||||
return raw_prob
|
||||
|
||||
def train_calibration(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
market: str,
|
||||
prob_col: str,
|
||||
actual_col: str,
|
||||
min_samples: int = 100,
|
||||
save: bool = True,
|
||||
) -> CalibrationMetrics:
|
||||
"""
|
||||
Train an Isotonic Regression calibration model for a specific market.
|
||||
|
||||
Args:
|
||||
df: DataFrame with predictions and actual outcomes
|
||||
market: Market identifier (e.g., 'ms_home', 'ou25', 'btts')
|
||||
prob_col: Column name for raw probabilities
|
||||
actual_col: Column name for actual outcomes (0 or 1)
|
||||
min_samples: Minimum samples required to train
|
||||
save: Whether to save the model to disk
|
||||
|
||||
Returns:
|
||||
CalibrationMetrics with quality metrics
|
||||
"""
|
||||
# Filter valid data
|
||||
valid_df = df[[prob_col, actual_col]].dropna()
|
||||
n_samples = len(valid_df)
|
||||
|
||||
if n_samples < min_samples:
|
||||
print(f"[Calibrator] Warning: Only {n_samples} samples for {market}, "
|
||||
f"need at least {min_samples}")
|
||||
metrics = CalibrationMetrics()
|
||||
metrics.sample_count = n_samples
|
||||
return metrics
|
||||
|
||||
# Extract arrays
|
||||
raw_probs = valid_df[prob_col].values
|
||||
actuals = valid_df[actual_col].values
|
||||
|
||||
# Train Isotonic Regression
|
||||
iso = IsotonicRegression(out_of_bounds="clip", increasing=True)
|
||||
iso.fit(raw_probs, actuals)
|
||||
|
||||
# Calculate calibrated probabilities
|
||||
calibrated_probs = iso.predict(raw_probs)
|
||||
|
||||
# Calculate metrics
|
||||
metrics = CalibrationMetrics()
|
||||
metrics.sample_count = n_samples
|
||||
metrics.last_trained = datetime.utcnow().isoformat()
|
||||
metrics.brier_score = brier_score_loss(actuals, calibrated_probs)
|
||||
metrics.mean_predicted = np.mean(raw_probs)
|
||||
metrics.mean_actual = np.mean(actuals)
|
||||
|
||||
# Calculate Expected Calibration Error (ECE)
|
||||
metrics.calibration_error = self._calculate_ece(
|
||||
calibrated_probs, actuals, n_bins=10
|
||||
)
|
||||
|
||||
# Store in memory
|
||||
self.calibrators[market] = iso
|
||||
self.metrics[market] = metrics
|
||||
|
||||
# Save to disk
|
||||
if save:
|
||||
self._save_calibration(market, iso, metrics)
|
||||
|
||||
print(f"[Calibrator] Trained {market}: "
|
||||
f"Brier={metrics.brier_score:.4f}, "
|
||||
f"ECE={metrics.calibration_error:.4f}, "
|
||||
f"n={n_samples}")
|
||||
|
||||
return metrics
|
||||
|
||||
def train_all_markets(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
market_config: Dict[str, Tuple[str, str]],
|
||||
min_samples: int = 100,
|
||||
) -> Dict[str, CalibrationMetrics]:
|
||||
"""
|
||||
Train calibration models for multiple markets at once.
|
||||
|
||||
Args:
|
||||
df: DataFrame with all predictions and outcomes
|
||||
market_config: Dict mapping market -> (prob_col, actual_col)
|
||||
e.g., {'ou25': ('ou25_over_prob', 'ou25_over_actual')}
|
||||
min_samples: Minimum samples per market
|
||||
|
||||
Returns:
|
||||
Dict of market -> CalibrationMetrics
|
||||
"""
|
||||
results = {}
|
||||
|
||||
for market, (prob_col, actual_col) in market_config.items():
|
||||
print(f"\n[Calibrator] Training {market}...")
|
||||
try:
|
||||
metrics = self.train_calibration(
|
||||
df=df,
|
||||
market=market,
|
||||
prob_col=prob_col,
|
||||
actual_col=actual_col,
|
||||
min_samples=min_samples,
|
||||
save=True,
|
||||
)
|
||||
results[market] = metrics
|
||||
except Exception as e:
|
||||
print(f"[Calibrator] Failed to train {market}: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def _calculate_ece(
|
||||
self,
|
||||
probs: np.ndarray,
|
||||
actuals: np.ndarray,
|
||||
n_bins: int = 10
|
||||
) -> float:
|
||||
"""
|
||||
Calculate Expected Calibration Error (ECE).
|
||||
|
||||
ECE = sum(|bin_accuracy - bin_confidence| * bin_weight)
|
||||
|
||||
Lower is better. Perfect calibration = 0.
|
||||
"""
|
||||
bin_boundaries = np.linspace(0, 1, n_bins + 1)
|
||||
ece = 0.0
|
||||
|
||||
for i in range(n_bins):
|
||||
in_bin = (probs >= bin_boundaries[i]) & (probs < bin_boundaries[i + 1])
|
||||
prop_in_bin = np.mean(in_bin)
|
||||
|
||||
if prop_in_bin > 0:
|
||||
accuracy_in_bin = np.mean(actuals[in_bin])
|
||||
avg_confidence_in_bin = np.mean(probs[in_bin])
|
||||
ece += np.abs(accuracy_in_bin - avg_confidence_in_bin) * prop_in_bin
|
||||
|
||||
return ece
|
||||
|
||||
def _save_calibration(
|
||||
self,
|
||||
market: str,
|
||||
calibrator: IsotonicRegression,
|
||||
metrics: CalibrationMetrics
|
||||
):
|
||||
"""Save calibration model and metrics to disk."""
|
||||
# Save model
|
||||
model_path = os.path.join(CALIBRATION_DIR, f"{market}_calibrator.pkl")
|
||||
with open(model_path, "wb") as f:
|
||||
pickle.dump(calibrator, f)
|
||||
|
||||
# Save metrics
|
||||
metrics_path = os.path.join(CALIBRATION_DIR, f"{market}_metrics.json")
|
||||
with open(metrics_path, "w") as f:
|
||||
json.dump(metrics.to_dict(), f, indent=2)
|
||||
|
||||
print(f"[Calibrator] Saved {market} to {CALIBRATION_DIR}")
|
||||
|
||||
def get_calibration_report(self) -> Dict[str, Any]:
|
||||
"""Generate a summary report of all calibration models."""
|
||||
report = {
|
||||
"trained_markets": list(self.calibrators.keys()),
|
||||
"metrics": {},
|
||||
"heuristic_only": [],
|
||||
}
|
||||
|
||||
for market in SUPPORTED_MARKETS:
|
||||
if market in self.metrics:
|
||||
report["metrics"][market] = self.metrics[market].to_dict()
|
||||
elif market not in self.calibrators:
|
||||
report["heuristic_only"].append(market)
|
||||
|
||||
return report
|
||||
|
||||
def get_calibrated_probabilities(
|
||||
self,
|
||||
market: str,
|
||||
raw_probs: np.ndarray
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Batch calibration for array of probabilities.
|
||||
|
||||
Args:
|
||||
market: Market type
|
||||
raw_probs: Array of raw probabilities
|
||||
|
||||
Returns:
|
||||
Array of calibrated probabilities
|
||||
"""
|
||||
return np.array([self.calibrate(market, p) for p in raw_probs])
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_calibrator_instance: Optional[Calibrator] = None
|
||||
|
||||
|
||||
def get_calibrator() -> Calibrator:
|
||||
"""Get or create the global Calibrator instance."""
|
||||
global _calibrator_instance
|
||||
if _calibrator_instance is None:
|
||||
_calibrator_instance = Calibrator()
|
||||
return _calibrator_instance
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,676 @@
|
||||
"""
|
||||
V25 Ensemble Predictor - NO TARGET LEAKAGE
|
||||
===========================================
|
||||
Multi-model ensemble for match prediction using XGBoost and LightGBM.
|
||||
|
||||
Features:
|
||||
- 73 engineered features (NO target leakage)
|
||||
- Market-specific models (MS, OU25, BTTS)
|
||||
- Weighted ensemble predictions
|
||||
- Value bet detection
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from typing import Dict, List, Optional, Any
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import xgboost as xgb
|
||||
import lightgbm as lgb
|
||||
|
||||
# CatBoost is optional
|
||||
try:
|
||||
from catboost import CatBoostClassifier
|
||||
CATBOOST_AVAILABLE = True
|
||||
except ImportError:
|
||||
CatBoostClassifier = None
|
||||
CATBOOST_AVAILABLE = False
|
||||
|
||||
# Paths
|
||||
MODELS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'v25')
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarketPrediction:
|
||||
"""Prediction for a single betting market."""
|
||||
market_type: str
|
||||
pick: str
|
||||
probability: float
|
||||
confidence: float
|
||||
odds: float = 0.0
|
||||
is_value_bet: bool = False
|
||||
edge: float = 0.0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'market_type': self.market_type,
|
||||
'pick': self.pick,
|
||||
'probability': round(self.probability * 100, 1),
|
||||
'confidence': round(self.confidence, 1),
|
||||
'odds': self.odds,
|
||||
'is_value_bet': self.is_value_bet,
|
||||
'edge': round(self.edge * 100, 1),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValueBet:
|
||||
"""Detected value bet opportunity."""
|
||||
market_type: str
|
||||
pick: str
|
||||
probability: float
|
||||
odds: float
|
||||
edge: float
|
||||
confidence: float
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'market_type': self.market_type,
|
||||
'pick': self.pick,
|
||||
'probability': round(self.probability * 100, 1),
|
||||
'odds': self.odds,
|
||||
'edge': round(self.edge * 100, 1),
|
||||
'confidence': round(self.confidence, 1),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchPrediction:
|
||||
"""Complete match prediction with all markets."""
|
||||
match_id: str
|
||||
home_team: str
|
||||
away_team: str
|
||||
|
||||
# MS predictions
|
||||
home_prob: float = 0.0
|
||||
draw_prob: float = 0.0
|
||||
away_prob: float = 0.0
|
||||
ms_pick: str = ''
|
||||
ms_confidence: float = 0.0
|
||||
|
||||
# OU25 predictions
|
||||
over_prob: float = 0.0
|
||||
under_prob: float = 0.0
|
||||
ou25_pick: str = ''
|
||||
ou25_confidence: float = 0.0
|
||||
|
||||
# BTTS predictions
|
||||
btts_yes_prob: float = 0.0
|
||||
btts_no_prob: float = 0.0
|
||||
btts_pick: str = ''
|
||||
btts_confidence: float = 0.0
|
||||
|
||||
# Value bets
|
||||
value_bets: List[ValueBet] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'match_id': self.match_id,
|
||||
'home_team': self.home_team,
|
||||
'away_team': self.away_team,
|
||||
'ms': {
|
||||
'home_prob': round(self.home_prob * 100, 1),
|
||||
'draw_prob': round(self.draw_prob * 100, 1),
|
||||
'away_prob': round(self.away_prob * 100, 1),
|
||||
'pick': self.ms_pick,
|
||||
'confidence': round(self.ms_confidence, 1),
|
||||
},
|
||||
'ou25': {
|
||||
'over_prob': round(self.over_prob * 100, 1),
|
||||
'under_prob': round(self.under_prob * 100, 1),
|
||||
'pick': self.ou25_pick,
|
||||
'confidence': round(self.ou25_confidence, 1),
|
||||
},
|
||||
'btts': {
|
||||
'yes_prob': round(self.btts_yes_prob * 100, 1),
|
||||
'no_prob': round(self.btts_no_prob * 100, 1),
|
||||
'pick': self.btts_pick,
|
||||
'confidence': round(self.btts_confidence, 1),
|
||||
},
|
||||
'value_bets': [vb.to_dict() for vb in self.value_bets],
|
||||
}
|
||||
|
||||
|
||||
class V25Predictor:
|
||||
"""
|
||||
V25 Ensemble Predictor - NO TARGET LEAKAGE
|
||||
|
||||
Uses market-specific XGBoost and LightGBM models.
|
||||
Each market (MS, OU25, BTTS) has its own trained models.
|
||||
"""
|
||||
|
||||
# Feature columns — loaded dynamically from feature_cols.json to stay
|
||||
# in sync with the trained models. The hardcoded list below is only a
|
||||
# fallback in case the JSON file is missing.
|
||||
_FALLBACK_FEATURE_COLS = [
|
||||
# ELO Features (8)
|
||||
'home_overall_elo', 'away_overall_elo', 'elo_diff',
|
||||
'home_home_elo', 'away_away_elo',
|
||||
'home_form_elo', 'away_form_elo', 'form_elo_diff',
|
||||
|
||||
# Form Features (12)
|
||||
'home_goals_avg', 'home_conceded_avg',
|
||||
'away_goals_avg', 'away_conceded_avg',
|
||||
'home_clean_sheet_rate', 'away_clean_sheet_rate',
|
||||
'home_scoring_rate', 'away_scoring_rate',
|
||||
'home_winning_streak', 'away_winning_streak',
|
||||
'home_unbeaten_streak', 'away_unbeaten_streak',
|
||||
|
||||
# H2H Features (6)
|
||||
'h2h_total_matches', 'h2h_home_win_rate', 'h2h_draw_rate',
|
||||
'h2h_avg_goals', 'h2h_btts_rate', 'h2h_over25_rate',
|
||||
|
||||
# Team Stats Features (8)
|
||||
'home_avg_possession', 'away_avg_possession',
|
||||
'home_avg_shots_on_target', 'away_avg_shots_on_target',
|
||||
'home_shot_conversion', 'away_shot_conversion',
|
||||
'home_avg_corners', 'away_avg_corners',
|
||||
|
||||
# Odds Features (24)
|
||||
'odds_ms_h', 'odds_ms_d', 'odds_ms_a',
|
||||
'implied_home', 'implied_draw', 'implied_away',
|
||||
'odds_ht_ms_h', 'odds_ht_ms_d', 'odds_ht_ms_a',
|
||||
'odds_ou05_o', 'odds_ou05_u',
|
||||
'odds_ou15_o', 'odds_ou15_u',
|
||||
'odds_ou25_o', 'odds_ou25_u',
|
||||
'odds_ou35_o', 'odds_ou35_u',
|
||||
'odds_ht_ou05_o', 'odds_ht_ou05_u',
|
||||
'odds_ht_ou15_o', 'odds_ht_ou15_u',
|
||||
'odds_btts_y', 'odds_btts_n',
|
||||
|
||||
# Odds Presence Flags (20)
|
||||
'odds_ms_h_present', 'odds_ms_d_present', 'odds_ms_a_present',
|
||||
'odds_ht_ms_h_present', 'odds_ht_ms_d_present', 'odds_ht_ms_a_present',
|
||||
'odds_ou05_o_present', 'odds_ou05_u_present',
|
||||
'odds_ou15_o_present', 'odds_ou15_u_present',
|
||||
'odds_ou25_o_present', 'odds_ou25_u_present',
|
||||
'odds_ou35_o_present', 'odds_ou35_u_present',
|
||||
'odds_ht_ou05_o_present', 'odds_ht_ou05_u_present',
|
||||
'odds_ht_ou15_o_present', 'odds_ht_ou15_u_present',
|
||||
'odds_btts_y_present', 'odds_btts_n_present',
|
||||
|
||||
# League Features (4)
|
||||
'home_xga', 'away_xga',
|
||||
'league_avg_goals', 'league_zero_goal_rate',
|
||||
|
||||
# Upset Engine (4)
|
||||
'upset_atmosphere', 'upset_motivation', 'upset_fatigue', 'upset_potential',
|
||||
|
||||
# Referee Engine (5)
|
||||
'referee_home_bias', 'referee_avg_goals', 'referee_cards_total',
|
||||
'referee_avg_yellow', 'referee_experience',
|
||||
|
||||
# Momentum Engine (3)
|
||||
'home_momentum_score', 'away_momentum_score', 'momentum_diff',
|
||||
|
||||
# Squad Features (9)
|
||||
'home_squad_quality', 'away_squad_quality', 'squad_diff',
|
||||
'home_key_players', 'away_key_players',
|
||||
'home_missing_impact', 'away_missing_impact',
|
||||
'home_goals_form', 'away_goals_form',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _load_feature_cols() -> list:
|
||||
"""Load feature columns from feature_cols.json, falling back to hardcoded list."""
|
||||
feature_json = os.path.join(MODELS_DIR, 'feature_cols.json')
|
||||
try:
|
||||
if os.path.exists(feature_json):
|
||||
with open(feature_json, 'r', encoding='utf-8') as f:
|
||||
cols = json.load(f)
|
||||
if isinstance(cols, list) and len(cols) > 0:
|
||||
print(f"[V25] Loaded {len(cols)} feature columns from feature_cols.json")
|
||||
return cols
|
||||
except Exception as e:
|
||||
print(f"[V25] Warning: could not load feature_cols.json: {e}")
|
||||
print(f"[V25] Using fallback feature columns ({len(V25Predictor._FALLBACK_FEATURE_COLS)} features)")
|
||||
return V25Predictor._FALLBACK_FEATURE_COLS
|
||||
|
||||
FEATURE_COLS = _load_feature_cols.__func__()
|
||||
|
||||
# Model weights for ensemble
|
||||
DEFAULT_WEIGHTS = {
|
||||
'xgb': 0.50,
|
||||
'lgb': 0.50,
|
||||
}
|
||||
|
||||
def __init__(self, models_dir: str = None):
|
||||
"""
|
||||
Initialize V25 Predictor.
|
||||
|
||||
Args:
|
||||
models_dir: Directory containing model files. Defaults to v25/ directory.
|
||||
"""
|
||||
self.models_dir = models_dir or MODELS_DIR
|
||||
self.models = {} # market -> {'xgb': model, 'lgb': model}
|
||||
self._loaded = False
|
||||
|
||||
# All trained market models available in V25
|
||||
ALL_MARKETS = [
|
||||
'ms', 'ou25', 'btts', # Core markets
|
||||
'ou15', 'ou35', # Additional OU lines
|
||||
'ht_result', 'ht_ou05', 'ht_ou15', # HT markets
|
||||
'htft', # HT/FT combo
|
||||
'cards_ou45', # Cards market
|
||||
'handicap_ms', # Handicap
|
||||
'odd_even', # Odd/Even goals
|
||||
]
|
||||
|
||||
# Multi-class markets (output > 2 classes)
|
||||
MULTICLASS_MARKETS = {'ms', 'ht_result', 'htft', 'handicap_ms'}
|
||||
|
||||
def load_models(self) -> bool:
|
||||
"""Load all market-specific models from disk."""
|
||||
try:
|
||||
loaded_count = 0
|
||||
|
||||
for market in self.ALL_MARKETS:
|
||||
self.models[market] = {}
|
||||
|
||||
# Load XGBoost (read content in Python to avoid non-ASCII path issues)
|
||||
xgb_path = os.path.join(self.models_dir, f'xgb_v25_{market}.json')
|
||||
if os.path.exists(xgb_path) and os.path.getsize(xgb_path) > 0:
|
||||
with open(xgb_path, 'r', encoding='utf-8') as f:
|
||||
xgb_content = f.read()
|
||||
booster = xgb.Booster()
|
||||
booster.load_model(bytearray(xgb_content, 'utf-8'))
|
||||
self.models[market]['xgb'] = booster
|
||||
loaded_count += 1
|
||||
|
||||
# Load LightGBM (read content in Python to avoid non-ASCII path issues)
|
||||
lgb_path = os.path.join(self.models_dir, f'lgb_v25_{market}.txt')
|
||||
if os.path.exists(lgb_path) and os.path.getsize(lgb_path) > 0:
|
||||
with open(lgb_path, 'r', encoding='utf-8') as f:
|
||||
model_str = f.read()
|
||||
self.models[market]['lgb'] = lgb.Booster(model_str=model_str)
|
||||
loaded_count += 1
|
||||
|
||||
# Remove empty entries
|
||||
if not self.models[market]:
|
||||
del self.models[market]
|
||||
|
||||
print(f"[V25] Loaded {loaded_count} model files across {len(self.models)} markets: {list(self.models.keys())}")
|
||||
self._loaded = loaded_count > 0
|
||||
return self._loaded
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Error loading models: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def _ensure_loaded(self):
|
||||
"""Ensure models are loaded before prediction."""
|
||||
if not self._loaded:
|
||||
if not self.load_models():
|
||||
raise RuntimeError("Failed to load V25 models")
|
||||
|
||||
def _prepare_features(self, features: Dict[str, float]) -> pd.DataFrame:
|
||||
"""Prepare feature vector for prediction."""
|
||||
X = pd.DataFrame([{col: features.get(col, 0.0) for col in self.FEATURE_COLS}])
|
||||
return X
|
||||
|
||||
def predict_ms(self, features: Dict[str, float]) -> tuple:
|
||||
"""
|
||||
Predict match result (1X2).
|
||||
|
||||
Returns:
|
||||
(home_prob, draw_prob, away_prob)
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
X = self._prepare_features(features)
|
||||
probs = []
|
||||
|
||||
# XGBoost
|
||||
if 'xgb' in self.models.get('ms', {}):
|
||||
dmat = xgb.DMatrix(X)
|
||||
xgb_proba = self.models['ms']['xgb'].predict(dmat)
|
||||
if len(xgb_proba.shape) == 1:
|
||||
xgb_proba = np.array([xgb_proba])
|
||||
probs.append(xgb_proba[0] * self.DEFAULT_WEIGHTS['xgb'])
|
||||
|
||||
# LightGBM
|
||||
if 'lgb' in self.models.get('ms', {}):
|
||||
lgb_proba = self.models['ms']['lgb'].predict(X)
|
||||
if len(lgb_proba.shape) == 2:
|
||||
probs.append(lgb_proba[0] * self.DEFAULT_WEIGHTS['lgb'])
|
||||
|
||||
if not probs:
|
||||
return 0.33, 0.33, 0.33
|
||||
|
||||
ensemble_proba = np.sum(probs, axis=0)
|
||||
ensemble_proba = ensemble_proba / ensemble_proba.sum()
|
||||
|
||||
return float(ensemble_proba[0]), float(ensemble_proba[1]), float(ensemble_proba[2])
|
||||
|
||||
def predict_ou25(self, features: Dict[str, float]) -> tuple:
|
||||
"""
|
||||
Predict Over/Under 2.5 goals.
|
||||
|
||||
Returns:
|
||||
(over_prob, under_prob)
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
X = self._prepare_features(features)
|
||||
probs = []
|
||||
|
||||
# XGBoost
|
||||
if 'xgb' in self.models.get('ou25', {}):
|
||||
dmat = xgb.DMatrix(X)
|
||||
xgb_proba = self.models['ou25']['xgb'].predict(dmat)
|
||||
if isinstance(xgb_proba, np.ndarray) and len(xgb_proba.shape) == 1:
|
||||
probs.append(xgb_proba[0])
|
||||
|
||||
# LightGBM
|
||||
if 'lgb' in self.models.get('ou25', {}):
|
||||
lgb_proba = self.models['ou25']['lgb'].predict(X)
|
||||
if isinstance(lgb_proba, np.ndarray):
|
||||
probs.append(lgb_proba[0])
|
||||
|
||||
if not probs:
|
||||
return 0.5, 0.5
|
||||
|
||||
# Average probability
|
||||
avg_prob = np.mean(probs)
|
||||
|
||||
return float(avg_prob), float(1 - avg_prob)
|
||||
|
||||
def predict_btts(self, features: Dict[str, float]) -> tuple:
|
||||
"""
|
||||
Predict Both Teams To Score.
|
||||
|
||||
Returns:
|
||||
(yes_prob, no_prob)
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
X = self._prepare_features(features)
|
||||
probs = []
|
||||
|
||||
# XGBoost
|
||||
if 'xgb' in self.models.get('btts', {}):
|
||||
dmat = xgb.DMatrix(X)
|
||||
xgb_proba = self.models['btts']['xgb'].predict(dmat)
|
||||
if isinstance(xgb_proba, np.ndarray) and len(xgb_proba.shape) == 1:
|
||||
probs.append(xgb_proba[0])
|
||||
|
||||
# LightGBM
|
||||
if 'lgb' in self.models.get('btts', {}):
|
||||
lgb_proba = self.models['btts']['lgb'].predict(X)
|
||||
if isinstance(lgb_proba, np.ndarray):
|
||||
probs.append(lgb_proba[0])
|
||||
|
||||
if not probs:
|
||||
return 0.5, 0.5
|
||||
|
||||
# Average probability
|
||||
avg_prob = np.mean(probs)
|
||||
|
||||
return float(avg_prob), float(1 - avg_prob)
|
||||
|
||||
def predict_market(self, market: str, features: Dict[str, float]) -> np.ndarray:
|
||||
"""
|
||||
Generic prediction for any loaded market.
|
||||
|
||||
Args:
|
||||
market: Market key (e.g. 'ht_result', 'htft', 'cards_ou45')
|
||||
features: Feature dictionary.
|
||||
|
||||
Returns:
|
||||
numpy array of probabilities.
|
||||
For binary markets: [positive_prob]
|
||||
For multi-class markets: [class0_prob, class1_prob, ...]
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
if market not in self.models:
|
||||
return None
|
||||
|
||||
X = self._prepare_features(features)
|
||||
probs = []
|
||||
weights = []
|
||||
is_multiclass = market in self.MULTICLASS_MARKETS
|
||||
|
||||
# XGBoost
|
||||
if 'xgb' in self.models[market]:
|
||||
dmat = xgb.DMatrix(X)
|
||||
xgb_proba = self.models[market]['xgb'].predict(dmat)
|
||||
if isinstance(xgb_proba, np.ndarray):
|
||||
if is_multiclass and len(xgb_proba.shape) == 2:
|
||||
probs.append(xgb_proba[0])
|
||||
elif is_multiclass and len(xgb_proba.shape) == 1:
|
||||
probs.append(xgb_proba)
|
||||
else:
|
||||
probs.append(np.array([xgb_proba[0]]))
|
||||
weights.append(self.DEFAULT_WEIGHTS['xgb'])
|
||||
|
||||
# LightGBM
|
||||
if 'lgb' in self.models[market]:
|
||||
lgb_proba = self.models[market]['lgb'].predict(X)
|
||||
if isinstance(lgb_proba, np.ndarray):
|
||||
if is_multiclass and len(lgb_proba.shape) == 2:
|
||||
probs.append(lgb_proba[0])
|
||||
elif is_multiclass and len(lgb_proba.shape) == 1:
|
||||
probs.append(lgb_proba)
|
||||
else:
|
||||
probs.append(np.array([lgb_proba[0]]))
|
||||
weights.append(self.DEFAULT_WEIGHTS['lgb'])
|
||||
|
||||
if not probs:
|
||||
return None
|
||||
|
||||
# Weighted average
|
||||
if len(probs) == 1:
|
||||
return probs[0]
|
||||
|
||||
total_w = sum(weights[:len(probs)])
|
||||
result = np.zeros_like(probs[0])
|
||||
for p, w in zip(probs, weights):
|
||||
result += p * (w / total_w)
|
||||
|
||||
# Normalize multi-class
|
||||
if is_multiclass and result.sum() > 0:
|
||||
result = result / result.sum()
|
||||
|
||||
return result
|
||||
|
||||
def has_market(self, market: str) -> bool:
|
||||
"""Check if a specific market model is loaded."""
|
||||
return market in self.models
|
||||
|
||||
def predict_match(
|
||||
self,
|
||||
match_id: str,
|
||||
home_team: str,
|
||||
away_team: str,
|
||||
features: Dict[str, float],
|
||||
odds: Optional[Dict[str, float]] = None,
|
||||
) -> MatchPrediction:
|
||||
"""
|
||||
Predict all markets for a match.
|
||||
|
||||
Args:
|
||||
match_id: Match identifier.
|
||||
home_team: Home team name.
|
||||
away_team: Away team name.
|
||||
features: Feature dictionary.
|
||||
odds: Optional odds dictionary for value bet detection.
|
||||
|
||||
Returns:
|
||||
MatchPrediction object.
|
||||
"""
|
||||
# Get predictions for each market
|
||||
home_prob, draw_prob, away_prob = self.predict_ms(features)
|
||||
over_prob, under_prob = self.predict_ou25(features)
|
||||
btts_yes_prob, btts_no_prob = self.predict_btts(features)
|
||||
|
||||
# Determine picks
|
||||
ms_probs = {'1': home_prob, 'X': draw_prob, '2': away_prob}
|
||||
ms_pick = max(ms_probs, key=ms_probs.get)
|
||||
ms_confidence = ms_probs[ms_pick] * 100
|
||||
|
||||
ou25_probs = {'Over': over_prob, 'Under': under_prob}
|
||||
ou25_pick = max(ou25_probs, key=ou25_probs.get)
|
||||
ou25_confidence = ou25_probs[ou25_pick] * 100
|
||||
|
||||
btts_probs = {'Yes': btts_yes_prob, 'No': btts_no_prob}
|
||||
btts_pick = max(btts_probs, key=btts_probs.get)
|
||||
btts_confidence = btts_probs[btts_pick] * 100
|
||||
|
||||
# Create prediction
|
||||
prediction = MatchPrediction(
|
||||
match_id=match_id,
|
||||
home_team=home_team,
|
||||
away_team=away_team,
|
||||
home_prob=home_prob,
|
||||
draw_prob=draw_prob,
|
||||
away_prob=away_prob,
|
||||
ms_pick=ms_pick,
|
||||
ms_confidence=ms_confidence,
|
||||
over_prob=over_prob,
|
||||
under_prob=under_prob,
|
||||
ou25_pick=ou25_pick,
|
||||
ou25_confidence=ou25_confidence,
|
||||
btts_yes_prob=btts_yes_prob,
|
||||
btts_no_prob=btts_no_prob,
|
||||
btts_pick=btts_pick,
|
||||
btts_confidence=btts_confidence,
|
||||
)
|
||||
|
||||
# Detect value bets
|
||||
if odds:
|
||||
prediction.value_bets = self._detect_value_bets(
|
||||
prediction, odds, home_prob, draw_prob, away_prob,
|
||||
over_prob, under_prob, btts_yes_prob, btts_no_prob
|
||||
)
|
||||
|
||||
return prediction
|
||||
|
||||
def _detect_value_bets(
|
||||
self,
|
||||
prediction: MatchPrediction,
|
||||
odds: Dict[str, float],
|
||||
home_prob: float,
|
||||
draw_prob: float,
|
||||
away_prob: float,
|
||||
over_prob: float,
|
||||
under_prob: float,
|
||||
btts_yes_prob: float,
|
||||
btts_no_prob: float,
|
||||
) -> List[ValueBet]:
|
||||
"""Detect value bets based on model vs market odds."""
|
||||
value_bets = []
|
||||
min_edge = 0.05 # 5% minimum edge
|
||||
|
||||
# MS value bets
|
||||
if 'ms_h' in odds and odds['ms_h'] > 0:
|
||||
implied = 1 / odds['ms_h']
|
||||
edge = home_prob - implied
|
||||
if edge > min_edge:
|
||||
value_bets.append(ValueBet(
|
||||
market_type='MS',
|
||||
pick='1',
|
||||
probability=home_prob,
|
||||
odds=odds['ms_h'],
|
||||
edge=edge,
|
||||
confidence=home_prob * 100,
|
||||
))
|
||||
|
||||
if 'ms_d' in odds and odds['ms_d'] > 0:
|
||||
implied = 1 / odds['ms_d']
|
||||
edge = draw_prob - implied
|
||||
if edge > min_edge:
|
||||
value_bets.append(ValueBet(
|
||||
market_type='MS',
|
||||
pick='X',
|
||||
probability=draw_prob,
|
||||
odds=odds['ms_d'],
|
||||
edge=edge,
|
||||
confidence=draw_prob * 100,
|
||||
))
|
||||
|
||||
if 'ms_a' in odds and odds['ms_a'] > 0:
|
||||
implied = 1 / odds['ms_a']
|
||||
edge = away_prob - implied
|
||||
if edge > min_edge:
|
||||
value_bets.append(ValueBet(
|
||||
market_type='MS',
|
||||
pick='2',
|
||||
probability=away_prob,
|
||||
odds=odds['ms_a'],
|
||||
edge=edge,
|
||||
confidence=away_prob * 100,
|
||||
))
|
||||
|
||||
# OU25 value bets
|
||||
if 'ou25_o' in odds and odds['ou25_o'] > 0:
|
||||
implied = 1 / odds['ou25_o']
|
||||
edge = over_prob - implied
|
||||
if edge > min_edge:
|
||||
value_bets.append(ValueBet(
|
||||
market_type='OU25',
|
||||
pick='Over',
|
||||
probability=over_prob,
|
||||
odds=odds['ou25_o'],
|
||||
edge=edge,
|
||||
confidence=over_prob * 100,
|
||||
))
|
||||
|
||||
if 'ou25_u' in odds and odds['ou25_u'] > 0:
|
||||
implied = 1 / odds['ou25_u']
|
||||
edge = under_prob - implied
|
||||
if edge > min_edge:
|
||||
value_bets.append(ValueBet(
|
||||
market_type='OU25',
|
||||
pick='Under',
|
||||
probability=under_prob,
|
||||
odds=odds['ou25_u'],
|
||||
edge=edge,
|
||||
confidence=under_prob * 100,
|
||||
))
|
||||
|
||||
# BTTS value bets
|
||||
if 'btts_y' in odds and odds['btts_y'] > 0:
|
||||
implied = 1 / odds['btts_y']
|
||||
edge = btts_yes_prob - implied
|
||||
if edge > min_edge:
|
||||
value_bets.append(ValueBet(
|
||||
market_type='BTTS',
|
||||
pick='Yes',
|
||||
probability=btts_yes_prob,
|
||||
odds=odds['btts_y'],
|
||||
edge=edge,
|
||||
confidence=btts_yes_prob * 100,
|
||||
))
|
||||
|
||||
if 'btts_n' in odds and odds['btts_n'] > 0:
|
||||
implied = 1 / odds['btts_n']
|
||||
edge = btts_no_prob - implied
|
||||
if edge > min_edge:
|
||||
value_bets.append(ValueBet(
|
||||
market_type='BTTS',
|
||||
pick='No',
|
||||
probability=btts_no_prob,
|
||||
odds=odds['btts_n'],
|
||||
edge=edge,
|
||||
confidence=btts_no_prob * 100,
|
||||
))
|
||||
|
||||
return value_bets
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_v25_predictor: Optional[V25Predictor] = None
|
||||
|
||||
|
||||
def get_v25_predictor() -> V25Predictor:
|
||||
"""Get or create V25 predictor instance."""
|
||||
global _v25_predictor
|
||||
if _v25_predictor is None:
|
||||
_v25_predictor = V25Predictor()
|
||||
_v25_predictor.load_models()
|
||||
return _v25_predictor
|
||||
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
V27 Pro Predictor — Odds-Free Fundamentals + Value Edge Detection
|
||||
|
||||
This module loads V27 ensemble models (XGBoost, LightGBM, CatBoost)
|
||||
and produces market-independent probability estimates.
|
||||
|
||||
The key insight: V27 is trained WITHOUT odds features, so it produces
|
||||
"true" probabilities unbiased by market pricing. The divergence between
|
||||
V25 (odds-aware) and V27 (odds-free) predictions signals market mispricing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
V27_DIR = Path(__file__).parent / "v27"
|
||||
|
||||
|
||||
class V27Predictor:
|
||||
"""
|
||||
Loads V27 ensemble models and provides predictions using the
|
||||
82-feature odds-free vector.
|
||||
"""
|
||||
|
||||
MARKETS = ['ms', 'ou25', 'btts']
|
||||
|
||||
def __init__(self):
|
||||
self.models: Dict[str, Dict[str, object]] = {}
|
||||
self.feature_cols: List[str] = []
|
||||
self._loaded = False
|
||||
|
||||
def load_models(self) -> bool:
|
||||
"""Load all V27 ensemble models and feature column spec."""
|
||||
if self._loaded:
|
||||
return True
|
||||
|
||||
# Feature columns
|
||||
cols_path = V27_DIR / "v27_feature_cols.json"
|
||||
if not cols_path.exists():
|
||||
logger.error("[V27] Feature columns file not found: %s", cols_path)
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(cols_path, "r", encoding="utf-8") as f:
|
||||
self.feature_cols = json.load(f)
|
||||
logger.info("[V27] Loaded %d feature columns", len(self.feature_cols))
|
||||
except Exception as e:
|
||||
logger.error("[V27] Failed to load feature columns: %s", e)
|
||||
return False
|
||||
|
||||
# Load models per market
|
||||
model_types = {"xgb": "xgb", "lgb": "lgb"}
|
||||
|
||||
for market in self.MARKETS:
|
||||
self.models[market] = {}
|
||||
for short, label in model_types.items():
|
||||
# Try market-specific file first: v27_ms_xgb.pkl
|
||||
path = V27_DIR / f"v27_{market}_{short}.pkl"
|
||||
if not path.exists():
|
||||
# Fallback to generic: v27_xgboost.pkl (for MS only)
|
||||
generic_names = {"xgb": "v27_xgboost.pkl", "lgb": "v27_lightgbm.pkl", "cb": "v27_catboost.pkl"}
|
||||
path = V27_DIR / generic_names.get(short, "")
|
||||
if not path.exists():
|
||||
logger.warning("[V27] Model file not found for %s/%s", market, short)
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
model = pickle.load(f)
|
||||
self.models[market][label] = model
|
||||
logger.info("[V27] ✓ Loaded %s/%s from %s", market, label, path.name)
|
||||
except Exception as e:
|
||||
logger.error("[V27] ✗ Failed to load %s/%s: %s", market, label, e)
|
||||
|
||||
loaded_count = sum(len(v) for v in self.models.values())
|
||||
if loaded_count == 0:
|
||||
logger.error("[V27] No models loaded!")
|
||||
return False
|
||||
|
||||
self._loaded = True
|
||||
logger.info("[V27] Total models loaded: %d across %d markets", loaded_count, len(self.models))
|
||||
return True
|
||||
|
||||
def _build_feature_array(self, features: Dict[str, float]) -> np.ndarray:
|
||||
"""
|
||||
Build ordered feature array from the full feature dict.
|
||||
V27 uses only its 82 features (odds-free subset).
|
||||
"""
|
||||
row = []
|
||||
for col in self.feature_cols:
|
||||
row.append(float(features.get(col, 0.0)))
|
||||
return np.array([row])
|
||||
|
||||
def _predict_with_model(self, model, X: np.ndarray, label: str, expected_classes: int) -> Optional[np.ndarray]:
|
||||
"""
|
||||
Predict probabilities from a model, handling both sklearn wrappers
|
||||
(predict_proba) and raw Booster objects (predict).
|
||||
|
||||
For raw XGBoost Boosters, DMatrix is created WITH feature_names
|
||||
to match the training schema.
|
||||
"""
|
||||
import xgboost as xgb
|
||||
import lightgbm as lgbm
|
||||
import pandas as pd
|
||||
|
||||
# 1. Try sklearn-style predict_proba first
|
||||
if hasattr(model, 'predict_proba'):
|
||||
try:
|
||||
proba = model.predict_proba(X)[0]
|
||||
if len(proba) == expected_classes:
|
||||
return proba
|
||||
logger.warning("[V27] %s predict_proba returned %d classes, expected %d", label, len(proba), expected_classes)
|
||||
except Exception:
|
||||
pass # Fall through to raw predict
|
||||
|
||||
# 2. Raw xgboost.Booster — MUST pass feature_names
|
||||
if isinstance(model, xgb.Booster):
|
||||
try:
|
||||
feature_names = self.feature_cols if self.feature_cols else None
|
||||
dmat = xgb.DMatrix(X, feature_names=feature_names)
|
||||
raw = model.predict(dmat)
|
||||
if isinstance(raw, np.ndarray):
|
||||
if raw.ndim == 2 and raw.shape[1] == expected_classes:
|
||||
return raw[0]
|
||||
elif raw.ndim == 1 and expected_classes == 2:
|
||||
p = float(raw[0])
|
||||
return np.array([1.0 - p, p])
|
||||
elif raw.ndim == 1 and len(raw) == expected_classes:
|
||||
return raw
|
||||
except Exception as e:
|
||||
logger.warning("[V27] %s xgb.Booster predict failed: %s", label, e)
|
||||
return None
|
||||
|
||||
# 3. Raw lightgbm.Booster — pass as DataFrame with column names
|
||||
if isinstance(model, lgbm.Booster):
|
||||
try:
|
||||
if self.feature_cols:
|
||||
X_named = pd.DataFrame(X, columns=self.feature_cols)
|
||||
raw = model.predict(X_named)
|
||||
else:
|
||||
raw = model.predict(X)
|
||||
if isinstance(raw, np.ndarray):
|
||||
if raw.ndim == 2 and raw.shape[1] == expected_classes:
|
||||
return raw[0]
|
||||
elif raw.ndim == 1 and expected_classes == 2:
|
||||
p = float(raw[0])
|
||||
return np.array([1.0 - p, p])
|
||||
elif raw.ndim == 1 and len(raw) == expected_classes:
|
||||
return raw
|
||||
except Exception as e:
|
||||
logger.warning("[V27] %s lgb.Booster predict failed: %s", label, e)
|
||||
return None
|
||||
|
||||
# 4. Generic fallback (CatBoost, etc.)
|
||||
try:
|
||||
if hasattr(model, 'predict'):
|
||||
raw = model.predict(X)
|
||||
if isinstance(raw, np.ndarray):
|
||||
if raw.ndim == 2 and raw.shape[1] == expected_classes:
|
||||
return raw[0]
|
||||
elif raw.ndim == 1 and expected_classes == 2:
|
||||
p = float(raw[0])
|
||||
return np.array([1.0 - p, p])
|
||||
elif raw.ndim == 1 and len(raw) == expected_classes:
|
||||
return raw
|
||||
except Exception as e:
|
||||
logger.warning("[V27] %s generic predict failed: %s", label, e)
|
||||
|
||||
return None
|
||||
|
||||
def predict_ms(self, features: Dict[str, float]) -> Optional[Dict[str, float]]:
|
||||
"""
|
||||
Predict Match Score probabilities (Home/Draw/Away).
|
||||
Returns dict with keys: home, draw, away.
|
||||
"""
|
||||
if not self._loaded or "ms" not in self.models or not self.models["ms"]:
|
||||
return None
|
||||
|
||||
X = self._build_feature_array(features)
|
||||
probs_list = []
|
||||
|
||||
for label, model in self.models["ms"].items():
|
||||
proba = self._predict_with_model(model, X, f"MS/{label}", expected_classes=3)
|
||||
if proba is not None and len(proba) == 3:
|
||||
probs_list.append(proba)
|
||||
|
||||
if not probs_list:
|
||||
return None
|
||||
|
||||
# Ensemble average
|
||||
avg = np.mean(probs_list, axis=0)
|
||||
return {
|
||||
"home": float(avg[0]),
|
||||
"draw": float(avg[1]),
|
||||
"away": float(avg[2]),
|
||||
}
|
||||
|
||||
def predict_ou25(self, features: Dict[str, float]) -> Optional[Dict[str, float]]:
|
||||
"""
|
||||
Predict Over/Under 2.5 probabilities.
|
||||
Returns dict with keys: under, over.
|
||||
"""
|
||||
if not self._loaded or "ou25" not in self.models or not self.models["ou25"]:
|
||||
return None
|
||||
|
||||
X = self._build_feature_array(features)
|
||||
probs_list = []
|
||||
|
||||
for label, model in self.models["ou25"].items():
|
||||
proba = self._predict_with_model(model, X, f"OU25/{label}", expected_classes=2)
|
||||
if proba is not None and len(proba) == 2:
|
||||
probs_list.append(proba)
|
||||
|
||||
if not probs_list:
|
||||
return None
|
||||
|
||||
avg = np.mean(probs_list, axis=0)
|
||||
return {
|
||||
"under": float(avg[0]),
|
||||
"over": float(avg[1]),
|
||||
}
|
||||
|
||||
def predict_btts(self, features: Dict[str, float]) -> Optional[Dict[str, float]]:
|
||||
"""
|
||||
Predict Both Teams To Score probabilities.
|
||||
Returns dict with keys: no, yes.
|
||||
"""
|
||||
if not self._loaded or 'btts' not in self.models or not self.models['btts']:
|
||||
return None
|
||||
|
||||
X = self._build_feature_array(features)
|
||||
probs_list = []
|
||||
|
||||
for label, model in self.models['btts'].items():
|
||||
proba = self._predict_with_model(model, X, f'BTTS/{label}', expected_classes=2)
|
||||
if proba is not None and len(proba) == 2:
|
||||
probs_list.append(proba)
|
||||
|
||||
if not probs_list:
|
||||
return None
|
||||
|
||||
avg = np.mean(probs_list, axis=0)
|
||||
return {
|
||||
'no': float(avg[0]),
|
||||
'yes': float(avg[1]),
|
||||
}
|
||||
|
||||
def predict_dc(self, features: Dict[str, float]) -> Optional[Dict[str, float]]:
|
||||
"""
|
||||
Predict Double Chance probabilities.
|
||||
|
||||
DC is algebraically derived from MS predictions:
|
||||
1X = home + draw
|
||||
X2 = draw + away
|
||||
12 = home + away
|
||||
|
||||
This gives an odds-free DC estimate for divergence detection.
|
||||
"""
|
||||
ms_probs = self.predict_ms(features)
|
||||
if not ms_probs:
|
||||
return None
|
||||
|
||||
home = ms_probs['home']
|
||||
draw = ms_probs['draw']
|
||||
away = ms_probs['away']
|
||||
|
||||
return {
|
||||
'1x': round(home + draw, 4),
|
||||
'x2': round(draw + away, 4),
|
||||
'12': round(home + away, 4),
|
||||
}
|
||||
|
||||
def predict_all(self, features: Dict[str, float]) -> Dict[str, Optional[Dict[str, float]]]:
|
||||
"""Run predictions for all supported markets."""
|
||||
return {
|
||||
'ms': self.predict_ms(features),
|
||||
'ou25': self.predict_ou25(features),
|
||||
'btts': self.predict_btts(features),
|
||||
'dc': self.predict_dc(features),
|
||||
}
|
||||
|
||||
|
||||
def compute_divergence(
|
||||
v25_probs: Dict[str, float],
|
||||
v27_probs: Dict[str, float],
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Compute the divergence signal between V25 (odds-aware) and V27 (odds-free).
|
||||
|
||||
Positive divergence = V27 thinks it's MORE likely than the market → VALUE BET
|
||||
Negative divergence = V27 thinks it's LESS likely than the market → PASS
|
||||
|
||||
Returns per-outcome divergence values.
|
||||
"""
|
||||
divergence = {}
|
||||
for key in v27_probs:
|
||||
v25_val = v25_probs.get(key, 0.33)
|
||||
v27_val = v27_probs.get(key, 0.33)
|
||||
divergence[key] = round(v27_val - v25_val, 4)
|
||||
return divergence
|
||||
|
||||
|
||||
def compute_value_edge(
|
||||
v25_probs: Dict[str, float],
|
||||
v27_probs: Dict[str, float],
|
||||
odds: Dict[str, float],
|
||||
) -> Dict[str, Dict]:
|
||||
"""
|
||||
Detect value bets by combining V25/V27 divergence with odds.
|
||||
|
||||
A value bet exists when:
|
||||
1. V27 (odds-free) probability > implied odds probability (model says it's underpriced)
|
||||
2. V27 and V25 divergence is positive (V27 sees more signal than the market)
|
||||
|
||||
Returns per-outcome: { probability, implied_prob, edge, is_value }
|
||||
"""
|
||||
results = {}
|
||||
for key in v27_probs:
|
||||
v27_p = v27_probs[key]
|
||||
v25_p = v25_probs.get(key, 0.33)
|
||||
odds_val = odds.get(key, 0.0)
|
||||
|
||||
implied_p = (1.0 / odds_val) if odds_val > 1.01 else 0.0
|
||||
divergence = v27_p - v25_p
|
||||
edge = v27_p - implied_p if implied_p > 0 else 0.0
|
||||
|
||||
results[key] = {
|
||||
"v27_prob": round(v27_p, 4),
|
||||
"v25_prob": round(v25_p, 4),
|
||||
"implied_prob": round(implied_p, 4),
|
||||
"divergence": round(divergence, 4),
|
||||
"edge": round(edge, 4),
|
||||
"is_value": edge > 0.05 and divergence > 0.02, # 5% edge + 2% divergence
|
||||
}
|
||||
|
||||
return results
|
||||
@@ -1,902 +0,0 @@
|
||||
[
|
||||
{
|
||||
"match_id": "2b1jyd72hogojec5j50fd9gr8",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "1b2chhfsohmulm85sb95y189g",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "X"
|
||||
},
|
||||
{
|
||||
"match_id": "dg84sd1wkmtfrtdm9od7wy7f8",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "Alt",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "dydrdtrxi3dsomph1at54jaxg",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.0264,
|
||||
"avg_confidence": 69.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 2.0,
|
||||
"avg_edge": 0.1559,
|
||||
"avg_confidence": 71.4
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "2.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "b6uzz042mizu0dqpci538z4lw",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.1515,
|
||||
"avg_confidence": 66.3
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 2.0,
|
||||
"avg_edge": 0.1103,
|
||||
"avg_confidence": 64.45
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "dmp0q35bpbb7rt11opg5mwzkk",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.0756,
|
||||
"avg_confidence": 67.5
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "6opr8muwfdoosfpnkm4gbb190",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 2.0,
|
||||
"avg_edge": 0.384,
|
||||
"avg_confidence": 80.8
|
||||
},
|
||||
"v25_main": "Tek",
|
||||
"v26_main": "KG Var"
|
||||
},
|
||||
{
|
||||
"match_id": "a6dxspn0akrnf19mno8z83yms",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 1.001,
|
||||
"avg_confidence": 72.5
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "9qrr3sya0mlfusmqlushjask4",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "2",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "ytsc38rm4j22govgwo3as6j8",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 1.1251,
|
||||
"avg_confidence": 67.6
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "X2",
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "68oi5t06w15a9b8wt8rsl8gk",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "KG Yok",
|
||||
"v26_main": "2"
|
||||
},
|
||||
{
|
||||
"match_id": "bsw4axalza4idsop3qio295hw",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.0113,
|
||||
"avg_confidence": 69.6
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.9893,
|
||||
"avg_confidence": 59.1
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "btsq93obda94w3be5bogr0etw",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "ajgyxy5iqiprjkr2l98np982c",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.0166,
|
||||
"avg_confidence": 69.5
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "4ocwqbbw37kikqj38hf4txes4",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.0755,
|
||||
"avg_confidence": 67.5
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 2.0,
|
||||
"avg_edge": 0.0585,
|
||||
"avg_confidence": 74.65
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "dogkmluzn54lg0q6yuom1l53o",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 1.2566,
|
||||
"avg_confidence": 81.7
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "2"
|
||||
},
|
||||
{
|
||||
"match_id": "cfar57gsu6hy770n7e58u8duc",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "cbg4zpl58ahr6r22d6syo0wt0",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "1X",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "5psd7wioj63day6cylflbzf2s",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "3.5 Alt"
|
||||
},
|
||||
{
|
||||
"match_id": "570ybzbhwkgvef82h49c27eac",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00c7ift",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "5vaa5dl47mw2t6xf3t3qjhvro",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "9pqea93iqumda82hj1sfxd7v8",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "cjs180a7sqxkurwo18rp9aeqc",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "2"
|
||||
},
|
||||
{
|
||||
"match_id": "3j2wsty5be0d432ozvuj5w2l0",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 1.1947,
|
||||
"avg_confidence": 71.4
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.091,
|
||||
"avg_confidence": 68.6
|
||||
},
|
||||
"v25_main": "1X",
|
||||
"v26_main": "3.5 Alt"
|
||||
},
|
||||
{
|
||||
"match_id": "3toy2ctfu4kce9ojkngofzf2s",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 2.0,
|
||||
"avg_edge": 0.0557,
|
||||
"avg_confidence": 67.25
|
||||
},
|
||||
"v25_main": "\u00c7ift",
|
||||
"v26_main": "KG Yok"
|
||||
},
|
||||
{
|
||||
"match_id": "5uvgaveseimkwkkj5lf0iv9xw",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "X"
|
||||
},
|
||||
{
|
||||
"match_id": "8i2hjmknzgjkhfhn9pehrhy50",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00c7ift",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "rfhmiaml1h9taxebpvmaijo4",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00c7ift",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "25sjaorwswwaeu3p2hd3p747o",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "X"
|
||||
},
|
||||
{
|
||||
"match_id": "d8e65nskkka2b6s1cr3rks1ec",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.7042,
|
||||
"avg_confidence": 61.7
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "2"
|
||||
},
|
||||
{
|
||||
"match_id": "fbxbk21jlzgmf0dc385mu6fo",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.5759,
|
||||
"avg_confidence": 57.1
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "9i8ikoed9f94nt90w8uy6y710",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.8784,
|
||||
"avg_confidence": 56.4
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "X"
|
||||
},
|
||||
{
|
||||
"match_id": "2iw74m76pj2ibdu9igq7u6ql0",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.6571,
|
||||
"avg_confidence": 60.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "3yt7t944i6ftaok5b799fa784",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "X"
|
||||
},
|
||||
{
|
||||
"match_id": "99r0d7ggi1169dhklo4eroopg",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.0266,
|
||||
"avg_confidence": 69.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 4.0,
|
||||
"avg_edge": 0.2509,
|
||||
"avg_confidence": 76.65
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "2.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "63lonant3zu1u7xmpomocetw",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 2.0,
|
||||
"avg_edge": 0.179,
|
||||
"avg_confidence": 66.6
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "2"
|
||||
},
|
||||
{
|
||||
"match_id": "790qnaweqoyffb5ndxnb4hlas",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.1067,
|
||||
"avg_confidence": 74.5
|
||||
},
|
||||
"v25_main": "2/2",
|
||||
"v26_main": "2.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "55bckv97w88qeqymhqgwg9fdg",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "1X",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "ir507rn2anyknjvm1xua1c7o",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "Alt",
|
||||
"v26_main": "2.5 Alt"
|
||||
},
|
||||
{
|
||||
"match_id": "d9h5hdhwum2s04ma722emljx0",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 1.2061,
|
||||
"avg_confidence": 79.9
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "3riltqclv2llihnklpcw6u784",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "8808y1x2hmz52k3mr598orqc4",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.2221,
|
||||
"avg_confidence": 67.9
|
||||
},
|
||||
"v25_main": "KG Var",
|
||||
"v26_main": "KG Var"
|
||||
},
|
||||
{
|
||||
"match_id": "5qypw3tl5qkx16ihhbifv9jis",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.0979,
|
||||
"avg_confidence": 66.9
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 3.0,
|
||||
"avg_edge": 0.1071,
|
||||
"avg_confidence": 66.3
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "2"
|
||||
},
|
||||
{
|
||||
"match_id": "710vie8tgz5ccs7sq9xwrjmz8",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 1.2892,
|
||||
"avg_confidence": 82.9
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "2vkbdwkxyjj1invl5877bz0us",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "X"
|
||||
},
|
||||
{
|
||||
"match_id": "7caglruhdiegg9xrc20fzhrtg",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00c7ift",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "gl3nvw4hprtny0aby1z40duc",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00c7ift",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "f87f0jsazmdhlq3wzz7ngp3o",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "X2",
|
||||
"v26_main": "2.5 Alt"
|
||||
},
|
||||
{
|
||||
"match_id": "chb58495fc1o6ek9t9kdfbyfo",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.0572,
|
||||
"avg_confidence": 75.5
|
||||
},
|
||||
"v25_main": "1",
|
||||
"v26_main": "KG Var"
|
||||
},
|
||||
{
|
||||
"match_id": "57h5qfr5mr4qoxix7vhiwd2j8",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 1.1927,
|
||||
"avg_confidence": 79.4
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "2"
|
||||
},
|
||||
{
|
||||
"match_id": "de76ipnpgaznxvl9tk9ewquqc",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "X"
|
||||
},
|
||||
{
|
||||
"match_id": "5pjdtn5bb9v64cj4ceh4ei1hw",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00c7ift",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "6orrk7jpn3ucpybdieh6oin84",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "3shwt6aecvnu9utm2go9diq6s",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.674,
|
||||
"avg_confidence": 60.6
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "5kukkba6u6wq1xj8oi118q1hw",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.8906,
|
||||
"avg_confidence": 56.7
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "X"
|
||||
},
|
||||
{
|
||||
"match_id": "9fzu7f8aybsqy88r3nam79p1w",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "2"
|
||||
},
|
||||
{
|
||||
"match_id": "bxcnq91mnr9f6bhicdxgzbims",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "dueticp36nbckn2pnpnt6az8",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "X"
|
||||
},
|
||||
{
|
||||
"match_id": "5m9do9pcggnl0tgmeolvnph5g",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.6914,
|
||||
"avg_confidence": 61.3
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "avu5vzpdi1wy7mrneodb3gw7o",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 1.1608,
|
||||
"avg_confidence": 78.3
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1"
|
||||
}
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
match_id,date,league,match,ht_score,final_score,strategy,market,pick,odds,playable,confidence,result,counted_in_roi,profit_flat,resolution_note,source,reversal_pick,reversal_prob,favorite_gap,favorite_odd,support_score,odds_band_score,odds_band_label,league_reversal_rate,league_strict_rev_rate,referee_strict_rev_rate,surprise_score,reason_codes,pick_reason
|
||||
b6uzz042mizu0dqpci538z4lw,2025-09-06,Süper Kupa,V. Sarsfield vs C. Cordoba,0-0,2-0,v25_aggressive,HTFT,2/1,34.5,True,21.0,LOST,True,-1.0,actual=X/1,v25.aggressive_pick,2/1,,,,,,,,,,,,
|
||||
b6uzz042mizu0dqpci538z4lw,2025-09-06,Süper Kupa,V. Sarsfield vs C. Cordoba,0-0,2-0,v26_aggressive,HTFT,1/1,3.26,True,16.0,LOST,True,-1.0,actual=X/1,v26.aggressive_pick,1/1,,,,,,,,,,,,
|
||||
ytsc38rm4j22govgwo3as6j8,2025-09-06,DK Elemeler,Avusturya vs G. Kıbrıs Rum Kesimi,0-0,1-0,v25_aggressive,HTFT,X/1,4.01,True,11.4,WON,True,3.01,actual=X/1,v25.aggressive_pick,X/1,,,,,,,,,,,,
|
||||
cfar57gsu6hy770n7e58u8duc,2025-09-06,Eerste Divisie,Cambuur vs Willem II,0-1,2-2,v25_aggressive,HTFT,2/1,20.7,True,15.6,LOST,True,-1.0,actual=2/X,v25.aggressive_pick,2/1,,,,,,,,,,,,
|
||||
cfar57gsu6hy770n7e58u8duc,2025-09-06,Eerste Divisie,Cambuur vs Willem II,0-1,2-2,v26_aggressive,HTFT,1/1,2.13,True,14.4,LOST,True,-1.0,actual=2/X,v26.aggressive_pick,1/1,,,,,,,,,,,,
|
||||
99r0d7ggi1169dhklo4eroopg,2025-09-06,2. Lig,Bromley vs Gillingham,2-0,2-2,v25_aggressive,HTFT,2/1,30.5,True,25.6,LOST,True,-1.0,actual=1/X,v25.aggressive_pick,2/1,,,,,,,,,,,,
|
||||
99r0d7ggi1169dhklo4eroopg,2025-09-06,2. Lig,Bromley vs Gillingham,2-0,2-2,v26_surprise,HTFT,1/2,34.5,False,4.9,LOST,False,0.0,actual=1/X,v26.surprise_pick,1/2,0.0679,1.02,1.92,73.5,0.642,,0.04,0.0,0.0,62.7,"favorite_gap_large,favorite_price_supported,reversal_prob_warm,quality_supports_reversal,favorite_odds_band_reversal_window,favorite_streak_break_window",
|
||||
99r0d7ggi1169dhklo4eroopg,2025-09-06,2. Lig,Bromley vs Gillingham,2-0,2-2,v26_aggressive,HTFT,1/1,3.16,True,16.1,LOST,True,-1.0,actual=1/X,v26.aggressive_pick,1/1,,,,,,,,,,,,
|
||||
790qnaweqoyffb5ndxnb4hlas,2025-09-06,Ulusal Lig,Aldershot vs Brackley,1-0,2-2,v25_aggressive,HTFT,X/2,6.07,True,9.0,LOST,True,-1.0,actual=1/X,v25.aggressive_pick,X/2,,,,,,,,,,,,
|
||||
790qnaweqoyffb5ndxnb4hlas,2025-09-06,Ulusal Lig,Aldershot vs Brackley,1-0,2-2,v26_aggressive,HTFT,2/2,3.8,True,37.1,LOST,True,-1.0,actual=1/X,v26.aggressive_pick,2/2,,,,,,,,,,,,
|
||||
8808y1x2hmz52k3mr598orqc4,2025-09-06,DK Elemeler,Ermenistan vs Portekiz,0-3,0-5,v25_aggressive,HTFT,X/2,4.03,True,11.6,LOST,True,-1.0,actual=2/2,v25.aggressive_pick,X/2,,,,,,,,,,,,
|
||||
7gfnwoxqz5o2clin40a85uqdw,2025-09-06,1. Lig,Port Vale vs Leyton Orient,1-2,2-3,v25_aggressive,HTFT,X/1,4.84,True,12.3,LOST,True,-1.0,actual=2/2,v25.aggressive_pick,X/1,,,,,,,,,,,,
|
||||
7gfnwoxqz5o2clin40a85uqdw,2025-09-06,1. Lig,Port Vale vs Leyton Orient,1-2,2-3,v26_surprise,HTFT,1/2,33.0,False,5.4,LOST,False,0.0,actual=2/2,v26.surprise_pick,1/2,0.0749,0.62,1.97,52.1,0.642,,0.044,0.0,0.0,57.0,"favorite_gap_large,favorite_price_supported,reversal_prob_warm,draw_swing_support,quality_supports_reversal,favorite_odds_band_reversal_window",
|
||||
7gfnwoxqz5o2clin40a85uqdw,2025-09-06,1. Lig,Port Vale vs Leyton Orient,1-2,2-3,v26_aggressive,HTFT,2/2,4.51,True,17.3,WON,True,3.51,actual=2/2,v26.aggressive_pick,2/2,,,,,,,,,,,,
|
||||
7fld91ykj1kfuoc8wn4r2frbo,2025-09-06,1. Lig,Lincoln City vs Wigan Ath,2-1,2-2,v25_aggressive,HTFT,X/1,4.7,True,19.2,LOST,True,-1.0,actual=1/X,v25.aggressive_pick,X/1,,,,,,,,,,,,
|
||||
7fld91ykj1kfuoc8wn4r2frbo,2025-09-06,1. Lig,Lincoln City vs Wigan Ath,2-1,2-2,v26_surprise,HTFT,1/2,34.5,False,7.1,LOST,False,0.0,actual=1/X,v26.surprise_pick,1/2,0.0984,0.71,2.0,61.5,0.642,,0.044,0.0,0.0,61.7,"favorite_gap_large,favorite_price_supported,reversal_prob_hot,upset_risk_detected,quality_supports_reversal,favorite_odds_band_reversal_window",
|
||||
7fld91ykj1kfuoc8wn4r2frbo,2025-09-06,1. Lig,Lincoln City vs Wigan Ath,2-1,2-2,v26_aggressive,HTFT,1/1,3.36,True,19.5,LOST,True,-1.0,actual=1/X,v26.aggressive_pick,1/1,,,,,,,,,,,,
|
||||
7i4nhkex1qssyp3x6rsgj03ro,2025-09-06,1. Lig,Wycombe vs Mansfield,1-0,2-0,v25_aggressive,HTFT,X/2,6.92,True,11.1,LOST,True,-1.0,actual=1/1,v25.aggressive_pick,X/2,,,,,,,,,,,,
|
||||
7i4nhkex1qssyp3x6rsgj03ro,2025-09-06,1. Lig,Wycombe vs Mansfield,1-0,2-0,v26_main_htft,HTFT,2/2,5.22,False,39.9,LOST,False,0.0,actual=1/1,v26.main_pick,,,,,,,,,,,0.0,,
|
||||
7hagai8xazmsj5exw7idwhhck,2025-09-06,1. Lig,Rotherham vs Exeter City,1-0,1-0,v25_aggressive,HTFT,X/2,6.28,True,6.7,LOST,True,-1.0,actual=1/1,v25.aggressive_pick,X/2,,,,,,,,,,,,
|
||||
7hagai8xazmsj5exw7idwhhck,2025-09-06,1. Lig,Rotherham vs Exeter City,1-0,1-0,v26_aggressive,HTFT,2/2,4.47,True,29.6,LOST,True,-1.0,actual=1/1,v26.aggressive_pick,2/2,,,,,,,,,,,,
|
||||
6e37x17qvk0snpokd1698lkb8,2025-09-06,Premiership,Crusaders vs Coleraine,0-3,0-4,v25_aggressive,HTFT,X/2,4.24,True,11.5,LOST,True,-1.0,actual=2/2,v25.aggressive_pick,X/2,,,,,,,,,,,,
|
||||
6e37x17qvk0snpokd1698lkb8,2025-09-06,Premiership,Crusaders vs Coleraine,0-3,0-4,v26_aggressive,HTFT,2/2,2.32,True,37.7,WON,True,1.32,actual=2/2,v26.aggressive_pick,2/2,,,,,,,,,,,,
|
||||
6f0fqlafaei9oj8yd9hdi6rdg,2025-09-06,Premiership,Linfield vs Portadown,0-0,3-0,v25_aggressive,HTFT,2/1,23.2,True,18.6,LOST,True,-1.0,actual=X/1,v25.aggressive_pick,2/1,,,,,,,,,,,,
|
||||
6f0fqlafaei9oj8yd9hdi6rdg,2025-09-06,Premiership,Linfield vs Portadown,0-0,3-0,v26_aggressive,HTFT,1/1,1.74,True,20.4,LOST,True,-1.0,actual=X/1,v26.aggressive_pick,1/1,,,,,,,,,,,,
|
||||
6dny1bmxcj6shc5382dno2al0,2025-09-06,Premiership,Carrick vs Cliftonville,0-1,1-2,v25_aggressive,HTFT,2/1,30.5,True,14.4,LOST,True,-1.0,actual=2/2,v25.aggressive_pick,2/1,,,,,,,,,,,,
|
||||
6dny1bmxcj6shc5382dno2al0,2025-09-06,Premiership,Carrick vs Cliftonville,0-1,1-2,v26_surprise,HTFT,2/1,30.5,False,10.1,LOST,False,0.0,actual=2/2,v26.surprise_pick,2/1,0.1401,0.59,1.97,62.8,0.642,,0.0667,0.0,0.0,66.2,"favorite_price_supported,reversal_prob_hot,quality_supports_reversal,favorite_odds_band_reversal_window,league_strict_reversal_prior,draw_pressure_supports_swing",
|
||||
6dny1bmxcj6shc5382dno2al0,2025-09-06,Premiership,Carrick vs Cliftonville,0-1,1-2,v26_aggressive,HTFT,1/1,4.36,True,14.6,LOST,True,-1.0,actual=2/2,v26.aggressive_pick,1/1,,,,,,,,,,,,
|
||||
9d9nwo82prx06riy5odgnr190,2025-09-06,2. Lig,Walsall vs Chesterfield,1-0,1-0,v25_aggressive,HTFT,X/2,5.38,True,10.3,LOST,True,-1.0,actual=1/1,v25.aggressive_pick,X/2,,,,,,,,,,,,
|
||||
9d9nwo82prx06riy5odgnr190,2025-09-06,2. Lig,Walsall vs Chesterfield,1-0,1-0,v26_aggressive,HTFT,2/2,3.78,True,35.1,LOST,True,-1.0,actual=1/1,v26.aggressive_pick,2/2,,,,,,,,,,,,
|
||||
7dwtx5tr7g66bsqkoc1wos9as,2025-09-06,1. Lig,Bolton vs Wimbledon,1-0,3-0,v25_aggressive,HTFT,X/1,3.76,True,12.1,LOST,True,-1.0,actual=1/1,v25.aggressive_pick,X/1,,,,,,,,,,,,
|
||||
7dwtx5tr7g66bsqkoc1wos9as,2025-09-06,1. Lig,Bolton vs Wimbledon,1-0,3-0,v26_aggressive,HTFT,1/1,1.81,True,33.3,WON,True,0.81,actual=1/1,v26.aggressive_pick,1/1,,,,,,,,,,,,
|
||||
7c85gguekhbhadtm7qdbgir6c,2025-09-06,Ulusal Lig,Tamworth vs Eastleigh,0-0,1-0,v25_aggressive,HTFT,1/2,34.5,True,14.1,LOST,True,-1.0,actual=X/1,v25.aggressive_pick,1/2,,,,,,,,,,,,
|
||||
7c85gguekhbhadtm7qdbgir6c,2025-09-06,Ulusal Lig,Tamworth vs Eastleigh,0-0,1-0,v26_aggressive,HTFT,2/2,5.53,True,14.8,LOST,True,-1.0,actual=X/1,v26.aggressive_pick,2/2,,,,,,,,,,,,
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,149 +0,0 @@
|
||||
# HT/FT + Upset Backtest
|
||||
|
||||
- Sample: last 120 finished football matches
|
||||
- Scope: only HT/FT reversal and upset-oriented picks
|
||||
- ROI: flat `1 unit` per played pick
|
||||
- Generated at: 2026-04-21T12:32:14.419378+00:00
|
||||
|
||||
## Strategy Summary
|
||||
|
||||
| Strategy | Candidates | Played | Won | Lost | Hit Rate | Profit | ROI |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|
|
||||
| v25_aggressive | 16 | 16 | 1 | 15 | 6.25% | -11.99 | -74.94% |
|
||||
| v26_surprise | 4 | 0 | 0 | 0 | 0.0% | +0.00 | +0.00% |
|
||||
| v26_aggressive | 13 | 13 | 3 | 10 | 23.08% | -4.36 | -33.54% |
|
||||
| v26_main_htft | 1 | 0 | 0 | 0 | 0.0% | +0.00 | +0.00% |
|
||||
|
||||
## v26 Surprise By Reversal Type
|
||||
|
||||
| Reversal | Candidates | Played | Won | Lost | Profit | ROI |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| 1/2 | 3 | 0 | 0 | 0 | +0.00 | +0.00% |
|
||||
| 2/1 | 1 | 0 | 0 | 0 | +0.00 | +0.00% |
|
||||
| X/1 | 0 | 0 | 0 | 0 | +0.00 | +0.00% |
|
||||
| X/2 | 0 | 0 | 0 | 0 | +0.00 | +0.00% |
|
||||
|
||||
## Match Detail
|
||||
|
||||
| Date | Match | HT | FT | v25 aggressive | v26 surprise | v26 aggressive | v26 main HTFT |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 2025-09-06 | Forge FC vs Hfx Wan | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Estoril vs Santa Clara | 0-0 | 0-1 | - | - | - | - |
|
||||
| 2025-09-06 | Tenerife vs Merida AD | 1-0 | 3-0 | - | - | - | - |
|
||||
| 2025-09-06 | Ibiza vs Hercules | 2-1 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | V. Sarsfield vs C. Cordoba | 0-0 | 2-0 | 2/1 (LOST, played, -1.00) | - | 1/1 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Botafogo vs Paranaense | 0-1 | 1-3 | - | - | - | - |
|
||||
| 2025-09-06 | San Felipe vs Curico Unido | 2-0 | 4-2 | - | - | - | - |
|
||||
| 2025-09-06 | Nacional Potosi vs Independiente | 0-1 | 0-3 | - | - | - | - |
|
||||
| 2025-09-06 | Avusturya vs G. Kıbrıs Rum Kesimi | 0-0 | 1-0 | X/1 (WON, played, +3.01) | - | - | - |
|
||||
| 2025-09-06 | CD Estepona FS vs Linares | 1-0 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Pergolettese vs Cittadella | 2-1 | 3-1 | - | - | - | - |
|
||||
| 2025-09-06 | Union Brescia vs Pro Vercelli | 2-0 | 5-0 | - | - | - | - |
|
||||
| 2025-09-06 | Casertana vs Potenza | 1-1 | 3-2 | - | - | - | - |
|
||||
| 2025-09-06 | Montevideo vs Danubio | 0-2 | 0-2 | - | - | - | - |
|
||||
| 2025-09-06 | Hoogstraten vs W. Beveren | 0-0 | 1-3 | - | - | - | - |
|
||||
| 2025-09-06 | Cambuur vs Willem II | 0-1 | 2-2 | 2/1 (LOST, played, -1.00) | - | 1/1 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Tlaxcala vs A. Oaxaca | 1-0 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | JS Kabylie vs Olympique Akbou | 0-0 | 0-0 | - | - | - | - |
|
||||
| 2025-09-06 | CD A. Baleares vs Castellon II | 3-0 | 3-0 | - | - | - | - |
|
||||
| 2025-09-06 | Karlovac 1919 vs Dugopolje | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | San Sebastian R. vs RSD Alcala | 1-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Hindistan U23 vs Katar U23 | 0-1 | 1-2 | - | - | - | - |
|
||||
| 2025-09-06 | Estradense vs Boiro | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Yeclano II vs El Palmar | 1-0 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | Montlouis vs La Roche | 0-3 | 1-4 | - | - | - | - |
|
||||
| 2025-09-06 | L'Hospitalet vs San Cristobal | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Marchamalo vs Guadalajara II | 1-1 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Real Zaragoza vs R. Valladolid | 0-0 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Bromley vs Gillingham | 2-0 | 2-2 | 2/1 (LOST, played, -1.00) | 1/2 (LOST, not played, +0.00) | 1/1 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Ajman Club vs Al Wahda | 2-1 | 2-4 | - | - | - | - |
|
||||
| 2025-09-06 | Aldershot vs Brackley | 1-0 | 2-2 | X/2 (LOST, played, -1.00) | - | 2/2 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Barbastro vs UE Olot | 1-0 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | CA Atlanta vs Guemes | 1-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Pulpileno vs UCAM Murcia II | 0-0 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Mojados vs Santa Marta | 0-2 | 1-3 | - | - | - | - |
|
||||
| 2025-09-06 | Ermenistan vs Portekiz | 0-3 | 0-5 | X/2 (LOST, played, -1.00) | - | - | - |
|
||||
| 2025-09-06 | USM Khenchela vs CR Belouizdad | 1-0 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Nafta vs Gorica | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | CS Cerrito vs Atenas | 0-0 | 0-3 | - | - | - | - |
|
||||
| 2025-09-06 | VfB Oldenburg vs Hannoverscher | 4-1 | 6-1 | - | - | - | - |
|
||||
| 2025-09-06 | Lealtad vs Numancia | 1-1 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Astorga vs Real Avila | 0-0 | 1-3 | - | - | - | - |
|
||||
| 2025-09-06 | Grindavik vs IR Reykjavik | 2-1 | 3-1 | - | - | - | - |
|
||||
| 2025-09-06 | Andratx vs Sant Andreu | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Arenas Getxo vs Cacereno | 2-1 | 2-2 | - | - | - | - |
|
||||
| 2025-09-06 | Aegir vs Dalvik / Reynir | 1-1 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | Laval II vs Cesson | 0-0 | 0-1 | - | - | - | - |
|
||||
| 2025-09-06 | Palencia CF vs Arandina | 3-0 | 4-0 | - | - | - | - |
|
||||
| 2025-09-06 | Leioa vs Pasaia | 0-0 | 0-1 | - | - | - | - |
|
||||
| 2025-09-06 | Vic vs UE Cornella | 0-0 | 0-1 | - | - | - | - |
|
||||
| 2025-09-06 | Voltigeurs vs Granville | 1-2 | 3-2 | - | - | - | - |
|
||||
| 2025-09-06 | Bobigny vs Creteil | 0-1 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Beauvais vs Furiani Agliani | 0-0 | 2-2 | - | - | - | - |
|
||||
| 2025-09-06 | Oissel vs Caen II | 0-1 | 0-2 | - | - | - | - |
|
||||
| 2025-09-06 | Cartagena LU II vs Un. Molinense | 0-2 | 1-2 | - | - | - | - |
|
||||
| 2025-09-06 | Hercules II vs Atzeneta | 0-1 | 0-3 | - | - | - | - |
|
||||
| 2025-09-06 | Sassari vs Pianese | 0-0 | 0-2 | - | - | - | - |
|
||||
| 2025-09-06 | Giugliano vs Foggia | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Sorrento vs Trapani 1905 | 0-0 | 1-2 | - | - | - | - |
|
||||
| 2025-09-06 | Ascoli vs Juventus U23 | 0-0 | 0-0 | - | - | - | - |
|
||||
| 2025-09-06 | Arezzo vs Vis Pesaro | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Ath. Carpi vs Campobasso | 0-0 | 2-2 | - | - | - | - |
|
||||
| 2025-09-06 | Oviedo II vs Sarriana | 2-0 | 3-2 | - | - | - | - |
|
||||
| 2025-09-06 | Llosetense vs Platges | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Le Havre (K) vs Strasbourg (K) | 2-2 | 2-2 | - | - | - | - |
|
||||
| 2025-09-06 | Montpellier (K) vs Fleury 91 (K) | 0-1 | 1-2 | - | - | - | - |
|
||||
| 2025-09-06 | Bistrica vs NK Krsko | 3-0 | 6-0 | - | - | - | - |
|
||||
| 2025-09-06 | Bilje vs Dravinja | 1-0 | 2-0 | - | - | - | - |
|
||||
| 2025-09-06 | Nantes (K) vs Saint-Etienne (K) | 2-1 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | Jadran Dekani vs NK Ilirija | 3-1 | 3-1 | - | - | - | - |
|
||||
| 2025-09-06 | Tabor Sezana vs Jesenice | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Alaves II vs Logrones | 0-0 | 2-0 | - | - | - | - |
|
||||
| 2025-09-06 | Unionistas II vs Tordesillas | 1-1 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Leganes II vs Alcorcon II | 0-0 | 0-0 | - | - | - | - |
|
||||
| 2025-09-06 | Volna Pinsk vs Bumprom | 0-1 | 0-1 | - | - | - | - |
|
||||
| 2025-09-06 | L. Mikulas vs S. Bratislava II | 0-0 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | Dubrava vs Hrvace | 0-1 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | Plymouth vs Stockport | 2-1 | 4-2 | - | - | - | - |
|
||||
| 2025-09-06 | Port Vale vs Leyton Orient | 1-2 | 2-3 | X/1 (LOST, played, -1.00) | 1/2 (LOST, not played, +0.00) | 2/2 (WON, played, +3.51) | - |
|
||||
| 2025-09-06 | Huddersfield vs Peterborough | 0-0 | 3-2 | - | - | - | - |
|
||||
| 2025-09-06 | Lincoln City vs Wigan Ath | 2-1 | 2-2 | X/1 (LOST, played, -1.00) | 1/2 (LOST, not played, +0.00) | 1/1 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Wycombe vs Mansfield | 1-0 | 2-0 | X/2 (LOST, played, -1.00) | - | - | 2/2 (LOST, not played, +0.00) |
|
||||
| 2025-09-06 | Rotherham vs Exeter City | 1-0 | 1-0 | X/2 (LOST, played, -1.00) | - | 2/2 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Ballymena Utd vs Glentoran | 0-2 | 0-2 | - | - | - | - |
|
||||
| 2025-09-06 | Crusaders vs Coleraine | 0-3 | 0-4 | X/2 (LOST, played, -1.00) | - | 2/2 (WON, played, +1.32) | - |
|
||||
| 2025-09-06 | Glenavon vs Dungannon | 0-2 | 0-2 | - | - | - | - |
|
||||
| 2025-09-06 | Linfield vs Portadown | 0-0 | 3-0 | 2/1 (LOST, played, -1.00) | - | 1/1 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Colchester vs Crewe | 0-1 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | G. Morton vs Raith Rovers | 0-0 | 0-1 | - | - | - | - |
|
||||
| 2025-09-06 | Puchov vs Pohronie | 0-1 | 3-1 | - | - | - | - |
|
||||
| 2025-09-06 | Carrick vs Cliftonville | 0-1 | 1-2 | 2/1 (LOST, played, -1.00) | 2/1 (LOST, not played, +0.00) | 1/1 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Harrogate vs Crawley Town | 0-1 | 0-1 | - | - | - | - |
|
||||
| 2025-09-06 | Walsall vs Chesterfield | 1-0 | 1-0 | X/2 (LOST, played, -1.00) | - | 2/2 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Banik Lehota vs Samorin | 1-0 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | Bolton vs Wimbledon | 1-0 | 3-0 | X/1 (LOST, played, -1.00) | - | 1/1 (WON, played, +0.81) | - |
|
||||
| 2025-09-06 | Edinburgh vs Hearts II | 4-1 | 4-2 | - | - | - | - |
|
||||
| 2025-09-06 | Kelty Hearts vs Stranraer | 0-0 | 3-0 | - | - | - | - |
|
||||
| 2025-09-06 | Forfar vs Dundee II | 2-1 | 4-1 | - | - | - | - |
|
||||
| 2025-09-06 | Spartans vs Hibernian II | 2-0 | 5-1 | - | - | - | - |
|
||||
| 2025-09-06 | East Kilbride vs Hamilton | 0-2 | 2-4 | - | - | - | - |
|
||||
| 2025-09-06 | Annan Ath vs St. Mirren II | 0-1 | 2-2 | - | - | - | - |
|
||||
| 2025-09-06 | East Fife vs Dumbarton | 1-0 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Clyde vs Motherwell II | 3-0 | 4-0 | - | - | - | - |
|
||||
| 2025-09-06 | Peterhead vs Dundee United II | 2-0 | 3-0 | - | - | - | - |
|
||||
| 2025-09-06 | Montrose vs Aberdeen II | 2-1 | 3-1 | - | - | - | - |
|
||||
| 2025-09-06 | Elgin City vs Cove Rangers | 1-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Queen Of S. vs Rangers II | 0-0 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | Boston Utd vs Solihull | 1-1 | 1-2 | - | - | - | - |
|
||||
| 2025-09-06 | Carlisle vs Truro | 2-0 | 3-0 | - | - | - | - |
|
||||
| 2025-09-06 | Southend vs Halifax | 1-0 | 3-0 | - | - | - | - |
|
||||
| 2025-09-06 | Woking vs Gateshead | 2-0 | 5-0 | - | - | - | - |
|
||||
| 2025-09-06 | Tamworth vs Eastleigh | 0-0 | 1-0 | 1/2 (LOST, played, -1.00) | - | 2/2 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Alcorcon vs Teruel | 1-1 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | Merthyr vs Worksop | 0-0 | 2-0 | - | - | - | - |
|
||||
| 2025-09-06 | Chesham Utd vs Bath | 0-0 | 0-0 | - | - | - | - |
|
||||
| 2025-09-06 | Southport vs South Shields | 0-0 | 0-0 | - | - | - | - |
|
||||
| 2025-09-06 | Kidderminster vs Macclesfield | 0-0 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Throttur Vogar vs Höttur / Huginn | 0-0 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | Alfreton vs Kings Lynn | 0-0 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Buxton vs Oxford City | 1-0 | 2-1 | - | - | - | - |
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"trained_at": "2026-04-14 17:20:03",
|
||||
"trained_at": "2026-05-06 15:53:36",
|
||||
"market_results": {
|
||||
"MS": {
|
||||
"samples": 9791,
|
||||
"samples": 106428,
|
||||
"features_used": [
|
||||
"home_overall_elo",
|
||||
"away_overall_elo",
|
||||
@@ -107,19 +107,19 @@
|
||||
"home_goals_form",
|
||||
"away_goals_form"
|
||||
],
|
||||
"train_samples": 6853,
|
||||
"val_samples": 1469,
|
||||
"test_samples": 1469,
|
||||
"xgb_accuracy": 0.8938,
|
||||
"xgb_logloss": 0.2263,
|
||||
"lgb_accuracy": 0.8938,
|
||||
"lgb_logloss": 0.2214,
|
||||
"ensemble_accuracy": 0.8945,
|
||||
"ensemble_logloss": 0.2226,
|
||||
"train_samples": 74499,
|
||||
"val_samples": 15964,
|
||||
"test_samples": 15965,
|
||||
"xgb_accuracy": 0.5437,
|
||||
"xgb_logloss": 0.9429,
|
||||
"lgb_accuracy": 0.5436,
|
||||
"lgb_logloss": 0.9423,
|
||||
"ensemble_accuracy": 0.5442,
|
||||
"ensemble_logloss": 0.9418,
|
||||
"class_count": 3
|
||||
},
|
||||
"OU15": {
|
||||
"samples": 9791,
|
||||
"samples": 106428,
|
||||
"features_used": [
|
||||
"home_overall_elo",
|
||||
"away_overall_elo",
|
||||
@@ -224,19 +224,19 @@
|
||||
"home_goals_form",
|
||||
"away_goals_form"
|
||||
],
|
||||
"train_samples": 6853,
|
||||
"val_samples": 1469,
|
||||
"test_samples": 1469,
|
||||
"xgb_accuracy": 0.9088,
|
||||
"xgb_logloss": 0.1758,
|
||||
"lgb_accuracy": 0.9067,
|
||||
"lgb_logloss": 0.1783,
|
||||
"ensemble_accuracy": 0.9108,
|
||||
"ensemble_logloss": 0.1753,
|
||||
"train_samples": 74499,
|
||||
"val_samples": 15964,
|
||||
"test_samples": 15965,
|
||||
"xgb_accuracy": 0.753,
|
||||
"xgb_logloss": 0.5256,
|
||||
"lgb_accuracy": 0.7523,
|
||||
"lgb_logloss": 0.5262,
|
||||
"ensemble_accuracy": 0.7533,
|
||||
"ensemble_logloss": 0.5254,
|
||||
"class_count": 2
|
||||
},
|
||||
"OU25": {
|
||||
"samples": 9791,
|
||||
"samples": 106428,
|
||||
"features_used": [
|
||||
"home_overall_elo",
|
||||
"away_overall_elo",
|
||||
@@ -341,19 +341,19 @@
|
||||
"home_goals_form",
|
||||
"away_goals_form"
|
||||
],
|
||||
"train_samples": 6853,
|
||||
"val_samples": 1469,
|
||||
"test_samples": 1469,
|
||||
"xgb_accuracy": 0.9204,
|
||||
"xgb_logloss": 0.1535,
|
||||
"lgb_accuracy": 0.9224,
|
||||
"lgb_logloss": 0.1523,
|
||||
"ensemble_accuracy": 0.9217,
|
||||
"ensemble_logloss": 0.1518,
|
||||
"train_samples": 74499,
|
||||
"val_samples": 15964,
|
||||
"test_samples": 15965,
|
||||
"xgb_accuracy": 0.6253,
|
||||
"xgb_logloss": 0.635,
|
||||
"lgb_accuracy": 0.6246,
|
||||
"lgb_logloss": 0.6347,
|
||||
"ensemble_accuracy": 0.6262,
|
||||
"ensemble_logloss": 0.6343,
|
||||
"class_count": 2
|
||||
},
|
||||
"OU35": {
|
||||
"samples": 9791,
|
||||
"samples": 106428,
|
||||
"features_used": [
|
||||
"home_overall_elo",
|
||||
"away_overall_elo",
|
||||
@@ -458,19 +458,19 @@
|
||||
"home_goals_form",
|
||||
"away_goals_form"
|
||||
],
|
||||
"train_samples": 6853,
|
||||
"val_samples": 1469,
|
||||
"test_samples": 1469,
|
||||
"xgb_accuracy": 0.9578,
|
||||
"xgb_logloss": 0.1171,
|
||||
"lgb_accuracy": 0.9564,
|
||||
"lgb_logloss": 0.1144,
|
||||
"ensemble_accuracy": 0.9571,
|
||||
"ensemble_logloss": 0.1149,
|
||||
"train_samples": 74499,
|
||||
"val_samples": 15964,
|
||||
"test_samples": 15965,
|
||||
"xgb_accuracy": 0.7283,
|
||||
"xgb_logloss": 0.5463,
|
||||
"lgb_accuracy": 0.7304,
|
||||
"lgb_logloss": 0.546,
|
||||
"ensemble_accuracy": 0.7297,
|
||||
"ensemble_logloss": 0.5456,
|
||||
"class_count": 2
|
||||
},
|
||||
"BTTS": {
|
||||
"samples": 9791,
|
||||
"samples": 106428,
|
||||
"features_used": [
|
||||
"home_overall_elo",
|
||||
"away_overall_elo",
|
||||
@@ -575,19 +575,19 @@
|
||||
"home_goals_form",
|
||||
"away_goals_form"
|
||||
],
|
||||
"train_samples": 6853,
|
||||
"val_samples": 1469,
|
||||
"test_samples": 1469,
|
||||
"xgb_accuracy": 0.9238,
|
||||
"xgb_logloss": 0.1439,
|
||||
"lgb_accuracy": 0.9265,
|
||||
"lgb_logloss": 0.143,
|
||||
"ensemble_accuracy": 0.9265,
|
||||
"ensemble_logloss": 0.1424,
|
||||
"train_samples": 74499,
|
||||
"val_samples": 15964,
|
||||
"test_samples": 15965,
|
||||
"xgb_accuracy": 0.5894,
|
||||
"xgb_logloss": 0.6636,
|
||||
"lgb_accuracy": 0.5928,
|
||||
"lgb_logloss": 0.6633,
|
||||
"ensemble_accuracy": 0.5897,
|
||||
"ensemble_logloss": 0.6628,
|
||||
"class_count": 2
|
||||
},
|
||||
"HT_RESULT": {
|
||||
"samples": 9786,
|
||||
"samples": 103208,
|
||||
"features_used": [
|
||||
"home_overall_elo",
|
||||
"away_overall_elo",
|
||||
@@ -692,19 +692,19 @@
|
||||
"home_goals_form",
|
||||
"away_goals_form"
|
||||
],
|
||||
"train_samples": 6850,
|
||||
"val_samples": 1468,
|
||||
"test_samples": 1468,
|
||||
"xgb_accuracy": 0.5627,
|
||||
"xgb_logloss": 0.8712,
|
||||
"lgb_accuracy": 0.5715,
|
||||
"lgb_logloss": 0.8649,
|
||||
"ensemble_accuracy": 0.5811,
|
||||
"ensemble_logloss": 0.8649,
|
||||
"train_samples": 72245,
|
||||
"val_samples": 15481,
|
||||
"test_samples": 15482,
|
||||
"xgb_accuracy": 0.4695,
|
||||
"xgb_logloss": 1.0174,
|
||||
"lgb_accuracy": 0.4677,
|
||||
"lgb_logloss": 1.0166,
|
||||
"ensemble_accuracy": 0.4688,
|
||||
"ensemble_logloss": 1.0164,
|
||||
"class_count": 3
|
||||
},
|
||||
"HT_OU05": {
|
||||
"samples": 9786,
|
||||
"samples": 103208,
|
||||
"features_used": [
|
||||
"home_overall_elo",
|
||||
"away_overall_elo",
|
||||
@@ -809,19 +809,19 @@
|
||||
"home_goals_form",
|
||||
"away_goals_form"
|
||||
],
|
||||
"train_samples": 6850,
|
||||
"val_samples": 1468,
|
||||
"test_samples": 1468,
|
||||
"xgb_accuracy": 0.7221,
|
||||
"xgb_logloss": 0.5122,
|
||||
"lgb_accuracy": 0.7268,
|
||||
"lgb_logloss": 0.5092,
|
||||
"ensemble_accuracy": 0.7275,
|
||||
"ensemble_logloss": 0.5084,
|
||||
"train_samples": 72245,
|
||||
"val_samples": 15481,
|
||||
"test_samples": 15482,
|
||||
"xgb_accuracy": 0.7011,
|
||||
"xgb_logloss": 0.5939,
|
||||
"lgb_accuracy": 0.7002,
|
||||
"lgb_logloss": 0.5936,
|
||||
"ensemble_accuracy": 0.7009,
|
||||
"ensemble_logloss": 0.5932,
|
||||
"class_count": 2
|
||||
},
|
||||
"HT_OU15": {
|
||||
"samples": 9786,
|
||||
"samples": 103208,
|
||||
"features_used": [
|
||||
"home_overall_elo",
|
||||
"away_overall_elo",
|
||||
@@ -926,19 +926,19 @@
|
||||
"home_goals_form",
|
||||
"away_goals_form"
|
||||
],
|
||||
"train_samples": 6850,
|
||||
"val_samples": 1468,
|
||||
"test_samples": 1468,
|
||||
"xgb_accuracy": 0.752,
|
||||
"xgb_logloss": 0.5252,
|
||||
"lgb_accuracy": 0.7595,
|
||||
"lgb_logloss": 0.5213,
|
||||
"ensemble_accuracy": 0.7595,
|
||||
"ensemble_logloss": 0.5192,
|
||||
"train_samples": 72245,
|
||||
"val_samples": 15481,
|
||||
"test_samples": 15482,
|
||||
"xgb_accuracy": 0.6723,
|
||||
"xgb_logloss": 0.6126,
|
||||
"lgb_accuracy": 0.6736,
|
||||
"lgb_logloss": 0.6118,
|
||||
"ensemble_accuracy": 0.6734,
|
||||
"ensemble_logloss": 0.6117,
|
||||
"class_count": 2
|
||||
},
|
||||
"HTFT": {
|
||||
"samples": 9786,
|
||||
"samples": 103208,
|
||||
"features_used": [
|
||||
"home_overall_elo",
|
||||
"away_overall_elo",
|
||||
@@ -1043,19 +1043,19 @@
|
||||
"home_goals_form",
|
||||
"away_goals_form"
|
||||
],
|
||||
"train_samples": 6850,
|
||||
"val_samples": 1468,
|
||||
"test_samples": 1468,
|
||||
"xgb_accuracy": 0.5136,
|
||||
"xgb_logloss": 1.1384,
|
||||
"lgb_accuracy": 0.5184,
|
||||
"lgb_logloss": 1.1469,
|
||||
"ensemble_accuracy": 0.5143,
|
||||
"ensemble_logloss": 1.1339,
|
||||
"train_samples": 72245,
|
||||
"val_samples": 15481,
|
||||
"test_samples": 15482,
|
||||
"xgb_accuracy": 0.3337,
|
||||
"xgb_logloss": 1.8208,
|
||||
"lgb_accuracy": 0.3332,
|
||||
"lgb_logloss": 1.8203,
|
||||
"ensemble_accuracy": 0.3358,
|
||||
"ensemble_logloss": 1.8186,
|
||||
"class_count": 9
|
||||
},
|
||||
"ODD_EVEN": {
|
||||
"samples": 9791,
|
||||
"samples": 106428,
|
||||
"features_used": [
|
||||
"home_overall_elo",
|
||||
"away_overall_elo",
|
||||
@@ -1160,19 +1160,19 @@
|
||||
"home_goals_form",
|
||||
"away_goals_form"
|
||||
],
|
||||
"train_samples": 6853,
|
||||
"val_samples": 1469,
|
||||
"test_samples": 1469,
|
||||
"xgb_accuracy": 0.8863,
|
||||
"xgb_logloss": 0.3565,
|
||||
"lgb_accuracy": 0.8802,
|
||||
"lgb_logloss": 0.3338,
|
||||
"ensemble_accuracy": 0.8863,
|
||||
"ensemble_logloss": 0.3423,
|
||||
"train_samples": 74499,
|
||||
"val_samples": 15964,
|
||||
"test_samples": 15965,
|
||||
"xgb_accuracy": 0.5296,
|
||||
"xgb_logloss": 0.6841,
|
||||
"lgb_accuracy": 0.5359,
|
||||
"lgb_logloss": 0.6822,
|
||||
"ensemble_accuracy": 0.531,
|
||||
"ensemble_logloss": 0.6826,
|
||||
"class_count": 2
|
||||
},
|
||||
"CARDS_OU45": {
|
||||
"samples": 9791,
|
||||
"samples": 106428,
|
||||
"features_used": [
|
||||
"home_overall_elo",
|
||||
"away_overall_elo",
|
||||
@@ -1277,19 +1277,19 @@
|
||||
"home_goals_form",
|
||||
"away_goals_form"
|
||||
],
|
||||
"train_samples": 6853,
|
||||
"val_samples": 1469,
|
||||
"test_samples": 1469,
|
||||
"xgb_accuracy": 0.6283,
|
||||
"xgb_logloss": 0.6174,
|
||||
"lgb_accuracy": 0.6413,
|
||||
"lgb_logloss": 0.615,
|
||||
"ensemble_accuracy": 0.6372,
|
||||
"ensemble_logloss": 0.6142,
|
||||
"train_samples": 74499,
|
||||
"val_samples": 15964,
|
||||
"test_samples": 15965,
|
||||
"xgb_accuracy": 0.6009,
|
||||
"xgb_logloss": 0.6489,
|
||||
"lgb_accuracy": 0.5988,
|
||||
"lgb_logloss": 0.6487,
|
||||
"ensemble_accuracy": 0.6024,
|
||||
"ensemble_logloss": 0.6479,
|
||||
"class_count": 2
|
||||
},
|
||||
"HANDICAP_MS": {
|
||||
"samples": 9791,
|
||||
"samples": 106428,
|
||||
"features_used": [
|
||||
"home_overall_elo",
|
||||
"away_overall_elo",
|
||||
@@ -1394,15 +1394,15 @@
|
||||
"home_goals_form",
|
||||
"away_goals_form"
|
||||
],
|
||||
"train_samples": 6853,
|
||||
"val_samples": 1469,
|
||||
"test_samples": 1469,
|
||||
"xgb_accuracy": 0.936,
|
||||
"xgb_logloss": 0.1903,
|
||||
"lgb_accuracy": 0.9346,
|
||||
"lgb_logloss": 0.1843,
|
||||
"ensemble_accuracy": 0.936,
|
||||
"ensemble_logloss": 0.1861,
|
||||
"train_samples": 74499,
|
||||
"val_samples": 15964,
|
||||
"test_samples": 15965,
|
||||
"xgb_accuracy": 0.6058,
|
||||
"xgb_logloss": 0.8691,
|
||||
"lgb_accuracy": 0.608,
|
||||
"lgb_logloss": 0.8677,
|
||||
"ensemble_accuracy": 0.6068,
|
||||
"ensemble_logloss": 0.8677,
|
||||
"class_count": 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
{
|
||||
"trained_at": "2026-05-10 19:48:06",
|
||||
"trainer": "v25_pro",
|
||||
"optuna_trials": 50,
|
||||
"total_features": 114,
|
||||
"markets": {
|
||||
"MS": {
|
||||
"market": "MS",
|
||||
"samples": 106861,
|
||||
"train": 64116,
|
||||
"val": 16029,
|
||||
"cal": 10686,
|
||||
"test": 16030,
|
||||
"features_used": 114,
|
||||
"xgb_best_params": {
|
||||
"max_depth": 4,
|
||||
"eta": 0.022329400652878233,
|
||||
"subsample": 0.6690795757813364,
|
||||
"colsample_bytree": 0.5042256538541441,
|
||||
"min_child_weight": 6,
|
||||
"gamma": 9.960129417155444e-05,
|
||||
"reg_lambda": 0.5132295377582388,
|
||||
"reg_alpha": 6.804503659726287e-08
|
||||
},
|
||||
"lgb_best_params": {
|
||||
"max_depth": 4,
|
||||
"learning_rate": 0.023142410802706542,
|
||||
"feature_fraction": 0.5728681432360808,
|
||||
"bagging_fraction": 0.6781774410065095,
|
||||
"bagging_freq": 2,
|
||||
"min_child_samples": 26,
|
||||
"lambda_l1": 3.25216937188593e-05,
|
||||
"lambda_l2": 4.8081236902660474e-08
|
||||
},
|
||||
"xgb_best_iteration": 643,
|
||||
"lgb_best_iteration": 441,
|
||||
"xgb_optuna_best_logloss": 0.9155,
|
||||
"lgb_optuna_best_logloss": 0.9146,
|
||||
"test_xgb_raw": {
|
||||
"accuracy": 0.5442,
|
||||
"logloss": 0.943
|
||||
},
|
||||
"test_xgb_calibrated": {
|
||||
"accuracy": 0.5404,
|
||||
"logloss": 0.9438
|
||||
},
|
||||
"test_lgb_raw": {
|
||||
"accuracy": 0.5427,
|
||||
"logloss": 0.943
|
||||
},
|
||||
"test_lgb_calibrated": {
|
||||
"accuracy": 0.5417,
|
||||
"logloss": 0.9447
|
||||
},
|
||||
"test_ensemble_raw": {
|
||||
"accuracy": 0.5437,
|
||||
"logloss": 0.9426
|
||||
},
|
||||
"test_ensemble_calibrated": {
|
||||
"accuracy": 0.5418,
|
||||
"logloss": 0.9435
|
||||
}
|
||||
},
|
||||
"OU15": {
|
||||
"market": "OU15",
|
||||
"samples": 106861,
|
||||
"train": 64116,
|
||||
"val": 16029,
|
||||
"cal": 10686,
|
||||
"test": 16030,
|
||||
"features_used": 114,
|
||||
"xgb_best_params": {
|
||||
"max_depth": 5,
|
||||
"eta": 0.020779487257177966,
|
||||
"subsample": 0.8109935286948485,
|
||||
"colsample_bytree": 0.9525413847213635,
|
||||
"min_child_weight": 6,
|
||||
"gamma": 0.35330347775044696,
|
||||
"reg_lambda": 5.373541021746059e-07,
|
||||
"reg_alpha": 0.2959430087754284
|
||||
},
|
||||
"lgb_best_params": {
|
||||
"max_depth": 6,
|
||||
"learning_rate": 0.013402310027682367,
|
||||
"feature_fraction": 0.7404728146233901,
|
||||
"bagging_fraction": 0.9712026511549247,
|
||||
"bagging_freq": 6,
|
||||
"min_child_samples": 39,
|
||||
"lambda_l1": 0.39893027986899576,
|
||||
"lambda_l2": 0.0626443611997599
|
||||
},
|
||||
"xgb_best_iteration": 353,
|
||||
"lgb_best_iteration": 370,
|
||||
"xgb_optuna_best_logloss": 0.499,
|
||||
"lgb_optuna_best_logloss": 0.4989,
|
||||
"test_xgb_raw": {
|
||||
"accuracy": 0.7521,
|
||||
"logloss": 0.5267
|
||||
},
|
||||
"test_xgb_calibrated": {
|
||||
"accuracy": 0.7521,
|
||||
"logloss": 0.5344
|
||||
},
|
||||
"test_lgb_raw": {
|
||||
"accuracy": 0.7528,
|
||||
"logloss": 0.5261
|
||||
},
|
||||
"test_lgb_calibrated": {
|
||||
"accuracy": 0.7505,
|
||||
"logloss": 0.5362
|
||||
},
|
||||
"test_ensemble_raw": {
|
||||
"accuracy": 0.7518,
|
||||
"logloss": 0.5261
|
||||
},
|
||||
"test_ensemble_calibrated": {
|
||||
"accuracy": 0.7522,
|
||||
"logloss": 0.5364
|
||||
}
|
||||
},
|
||||
"OU25": {
|
||||
"market": "OU25",
|
||||
"samples": 106861,
|
||||
"train": 64116,
|
||||
"val": 16029,
|
||||
"cal": 10686,
|
||||
"test": 16030,
|
||||
"features_used": 114,
|
||||
"xgb_best_params": {
|
||||
"max_depth": 5,
|
||||
"eta": 0.01274409160014454,
|
||||
"subsample": 0.8300258899365814,
|
||||
"colsample_bytree": 0.7336425662264429,
|
||||
"min_child_weight": 9,
|
||||
"gamma": 2.5382243933649716e-06,
|
||||
"reg_lambda": 5.096723080351853e-05,
|
||||
"reg_alpha": 0.00040919711449493223
|
||||
},
|
||||
"lgb_best_params": {
|
||||
"max_depth": 6,
|
||||
"learning_rate": 0.02301514680733822,
|
||||
"feature_fraction": 0.9569492061944688,
|
||||
"bagging_fraction": 0.7249143523144639,
|
||||
"bagging_freq": 1,
|
||||
"min_child_samples": 40,
|
||||
"lambda_l1": 9.954995248644963e-08,
|
||||
"lambda_l2": 3.82413187126927e-06
|
||||
},
|
||||
"xgb_best_iteration": 475,
|
||||
"lgb_best_iteration": 235,
|
||||
"xgb_optuna_best_logloss": 0.6202,
|
||||
"lgb_optuna_best_logloss": 0.62,
|
||||
"test_xgb_raw": {
|
||||
"accuracy": 0.6221,
|
||||
"logloss": 0.6352
|
||||
},
|
||||
"test_xgb_calibrated": {
|
||||
"accuracy": 0.6226,
|
||||
"logloss": 0.6344
|
||||
},
|
||||
"test_lgb_raw": {
|
||||
"accuracy": 0.6236,
|
||||
"logloss": 0.6348
|
||||
},
|
||||
"test_lgb_calibrated": {
|
||||
"accuracy": 0.6231,
|
||||
"logloss": 0.6343
|
||||
},
|
||||
"test_ensemble_raw": {
|
||||
"accuracy": 0.6239,
|
||||
"logloss": 0.6349
|
||||
},
|
||||
"test_ensemble_calibrated": {
|
||||
"accuracy": 0.6236,
|
||||
"logloss": 0.6338
|
||||
}
|
||||
},
|
||||
"OU35": {
|
||||
"market": "OU35",
|
||||
"samples": 106861,
|
||||
"train": 64116,
|
||||
"val": 16029,
|
||||
"cal": 10686,
|
||||
"test": 16030,
|
||||
"features_used": 114,
|
||||
"xgb_best_params": {
|
||||
"max_depth": 4,
|
||||
"eta": 0.012538827444713596,
|
||||
"subsample": 0.7947923612828379,
|
||||
"colsample_bytree": 0.9717654601553765,
|
||||
"min_child_weight": 6,
|
||||
"gamma": 0.011265216242399128,
|
||||
"reg_lambda": 0.12152579364613436,
|
||||
"reg_alpha": 0.013995120492957489
|
||||
},
|
||||
"lgb_best_params": {
|
||||
"max_depth": 6,
|
||||
"learning_rate": 0.013456307557939324,
|
||||
"feature_fraction": 0.8208768633332759,
|
||||
"bagging_fraction": 0.929472334516626,
|
||||
"bagging_freq": 6,
|
||||
"min_child_samples": 35,
|
||||
"lambda_l1": 0.05522724221034949,
|
||||
"lambda_l2": 0.21689047644122147
|
||||
},
|
||||
"xgb_best_iteration": 696,
|
||||
"lgb_best_iteration": 412,
|
||||
"xgb_optuna_best_logloss": 0.552,
|
||||
"lgb_optuna_best_logloss": 0.5515,
|
||||
"test_xgb_raw": {
|
||||
"accuracy": 0.7314,
|
||||
"logloss": 0.5466
|
||||
},
|
||||
"test_xgb_calibrated": {
|
||||
"accuracy": 0.7293,
|
||||
"logloss": 0.5482
|
||||
},
|
||||
"test_lgb_raw": {
|
||||
"accuracy": 0.73,
|
||||
"logloss": 0.5462
|
||||
},
|
||||
"test_lgb_calibrated": {
|
||||
"accuracy": 0.7298,
|
||||
"logloss": 0.5485
|
||||
},
|
||||
"test_ensemble_raw": {
|
||||
"accuracy": 0.7312,
|
||||
"logloss": 0.5462
|
||||
},
|
||||
"test_ensemble_calibrated": {
|
||||
"accuracy": 0.7301,
|
||||
"logloss": 0.5478
|
||||
}
|
||||
},
|
||||
"BTTS": {
|
||||
"market": "BTTS",
|
||||
"samples": 106861,
|
||||
"train": 64116,
|
||||
"val": 16029,
|
||||
"cal": 10686,
|
||||
"test": 16030,
|
||||
"features_used": 114,
|
||||
"xgb_best_params": {
|
||||
"max_depth": 4,
|
||||
"eta": 0.023533647209064805,
|
||||
"subsample": 0.7469060816054074,
|
||||
"colsample_bytree": 0.8445418254808608,
|
||||
"min_child_weight": 8,
|
||||
"gamma": 1.0503733400514561e-08,
|
||||
"reg_lambda": 2.0919595769527735e-06,
|
||||
"reg_alpha": 0.027277017326535417
|
||||
},
|
||||
"lgb_best_params": {
|
||||
"max_depth": 4,
|
||||
"learning_rate": 0.03900730648793646,
|
||||
"feature_fraction": 0.6968255358438369,
|
||||
"bagging_fraction": 0.7078349435778689,
|
||||
"bagging_freq": 1,
|
||||
"min_child_samples": 46,
|
||||
"lambda_l1": 1.1796591413903922e-05,
|
||||
"lambda_l2": 1.574367227995052e-08
|
||||
},
|
||||
"xgb_best_iteration": 462,
|
||||
"lgb_best_iteration": 339,
|
||||
"xgb_optuna_best_logloss": 0.6557,
|
||||
"lgb_optuna_best_logloss": 0.6554,
|
||||
"test_xgb_raw": {
|
||||
"accuracy": 0.5908,
|
||||
"logloss": 0.6637
|
||||
},
|
||||
"test_xgb_calibrated": {
|
||||
"accuracy": 0.5885,
|
||||
"logloss": 0.6647
|
||||
},
|
||||
"test_lgb_raw": {
|
||||
"accuracy": 0.5891,
|
||||
"logloss": 0.6638
|
||||
},
|
||||
"test_lgb_calibrated": {
|
||||
"accuracy": 0.5891,
|
||||
"logloss": 0.6702
|
||||
},
|
||||
"test_ensemble_raw": {
|
||||
"accuracy": 0.5892,
|
||||
"logloss": 0.6635
|
||||
},
|
||||
"test_ensemble_calibrated": {
|
||||
"accuracy": 0.5885,
|
||||
"logloss": 0.6655
|
||||
}
|
||||
},
|
||||
"HT_RESULT": {
|
||||
"market": "HT_RESULT",
|
||||
"samples": 103641,
|
||||
"train": 62184,
|
||||
"val": 15546,
|
||||
"cal": 10364,
|
||||
"test": 15547,
|
||||
"features_used": 114,
|
||||
"xgb_best_params": {
|
||||
"max_depth": 4,
|
||||
"eta": 0.01736265891311687,
|
||||
"subsample": 0.8370935625192159,
|
||||
"colsample_bytree": 0.8091927356001175,
|
||||
"min_child_weight": 9,
|
||||
"gamma": 0.0006570311316367184,
|
||||
"reg_lambda": 0.5206211670360164,
|
||||
"reg_alpha": 0.0004530536252850605
|
||||
},
|
||||
"lgb_best_params": {
|
||||
"max_depth": 4,
|
||||
"learning_rate": 0.04842652289664568,
|
||||
"feature_fraction": 0.6277272818879166,
|
||||
"bagging_fraction": 0.9526964840164693,
|
||||
"bagging_freq": 3,
|
||||
"min_child_samples": 23,
|
||||
"lambda_l1": 0.09429192580834124,
|
||||
"lambda_l2": 5.5433175427148124e-08
|
||||
},
|
||||
"xgb_best_iteration": 516,
|
||||
"lgb_best_iteration": 136,
|
||||
"xgb_optuna_best_logloss": 1.0128,
|
||||
"lgb_optuna_best_logloss": 1.0126,
|
||||
"test_xgb_raw": {
|
||||
"accuracy": 0.4689,
|
||||
"logloss": 1.0174
|
||||
},
|
||||
"test_xgb_calibrated": {
|
||||
"accuracy": 0.4685,
|
||||
"logloss": 1.0193
|
||||
},
|
||||
"test_lgb_raw": {
|
||||
"accuracy": 0.4696,
|
||||
"logloss": 1.018
|
||||
},
|
||||
"test_lgb_calibrated": {
|
||||
"accuracy": 0.4685,
|
||||
"logloss": 1.0248
|
||||
},
|
||||
"test_ensemble_raw": {
|
||||
"accuracy": 0.4699,
|
||||
"logloss": 1.0172
|
||||
},
|
||||
"test_ensemble_calibrated": {
|
||||
"accuracy": 0.4693,
|
||||
"logloss": 1.0195
|
||||
}
|
||||
},
|
||||
"HT_OU05": {
|
||||
"market": "HT_OU05",
|
||||
"samples": 103641,
|
||||
"train": 62184,
|
||||
"val": 15546,
|
||||
"cal": 10364,
|
||||
"test": 15547,
|
||||
"features_used": 114,
|
||||
"xgb_best_params": {
|
||||
"max_depth": 4,
|
||||
"eta": 0.02440515089624656,
|
||||
"subsample": 0.7173767988211683,
|
||||
"colsample_bytree": 0.5705266148307722,
|
||||
"min_child_weight": 10,
|
||||
"gamma": 0.00010295747493868653,
|
||||
"reg_lambda": 0.00048367003442154754,
|
||||
"reg_alpha": 0.00018303274057896783
|
||||
},
|
||||
"lgb_best_params": {
|
||||
"max_depth": 4,
|
||||
"learning_rate": 0.043477055106943,
|
||||
"feature_fraction": 0.5704621124873813,
|
||||
"bagging_fraction": 0.9208787923016158,
|
||||
"bagging_freq": 1,
|
||||
"min_child_samples": 50,
|
||||
"lambda_l1": 0.015064619068942013,
|
||||
"lambda_l2": 6.143857495033091e-07
|
||||
},
|
||||
"xgb_best_iteration": 315,
|
||||
"lgb_best_iteration": 133,
|
||||
"xgb_optuna_best_logloss": 0.5756,
|
||||
"lgb_optuna_best_logloss": 0.5757,
|
||||
"test_xgb_raw": {
|
||||
"accuracy": 0.7021,
|
||||
"logloss": 0.5949
|
||||
},
|
||||
"test_xgb_calibrated": {
|
||||
"accuracy": 0.7011,
|
||||
"logloss": 0.5976
|
||||
},
|
||||
"test_lgb_raw": {
|
||||
"accuracy": 0.7009,
|
||||
"logloss": 0.5954
|
||||
},
|
||||
"test_lgb_calibrated": {
|
||||
"accuracy": 0.7019,
|
||||
"logloss": 0.6002
|
||||
},
|
||||
"test_ensemble_raw": {
|
||||
"accuracy": 0.7012,
|
||||
"logloss": 0.5947
|
||||
},
|
||||
"test_ensemble_calibrated": {
|
||||
"accuracy": 0.7016,
|
||||
"logloss": 0.5994
|
||||
}
|
||||
},
|
||||
"HT_OU15": {
|
||||
"market": "HT_OU15",
|
||||
"samples": 103641,
|
||||
"train": 62184,
|
||||
"val": 15546,
|
||||
"cal": 10364,
|
||||
"test": 15547,
|
||||
"features_used": 114,
|
||||
"xgb_best_params": {
|
||||
"max_depth": 4,
|
||||
"eta": 0.032235943414662994,
|
||||
"subsample": 0.9298749893021518,
|
||||
"colsample_bytree": 0.8077813949235508,
|
||||
"min_child_weight": 8,
|
||||
"gamma": 0.00020929324388600622,
|
||||
"reg_lambda": 3.2154973975232725e-05,
|
||||
"reg_alpha": 1.5945155621686738e-08
|
||||
},
|
||||
"lgb_best_params": {
|
||||
"max_depth": 5,
|
||||
"learning_rate": 0.013909897616748226,
|
||||
"feature_fraction": 0.5585477334219859,
|
||||
"bagging_fraction": 0.9398770580467641,
|
||||
"bagging_freq": 2,
|
||||
"min_child_samples": 22,
|
||||
"lambda_l1": 0.001865897980802303,
|
||||
"lambda_l2": 2.6934572591055333e-06
|
||||
},
|
||||
"xgb_best_iteration": 188,
|
||||
"lgb_best_iteration": 387,
|
||||
"xgb_optuna_best_logloss": 0.616,
|
||||
"lgb_optuna_best_logloss": 0.6159,
|
||||
"test_xgb_raw": {
|
||||
"accuracy": 0.6749,
|
||||
"logloss": 0.6109
|
||||
},
|
||||
"test_xgb_calibrated": {
|
||||
"accuracy": 0.6747,
|
||||
"logloss": 0.6137
|
||||
},
|
||||
"test_lgb_raw": {
|
||||
"accuracy": 0.6745,
|
||||
"logloss": 0.6112
|
||||
},
|
||||
"test_lgb_calibrated": {
|
||||
"accuracy": 0.6745,
|
||||
"logloss": 0.6201
|
||||
},
|
||||
"test_ensemble_raw": {
|
||||
"accuracy": 0.674,
|
||||
"logloss": 0.6109
|
||||
},
|
||||
"test_ensemble_calibrated": {
|
||||
"accuracy": 0.6744,
|
||||
"logloss": 0.6174
|
||||
}
|
||||
},
|
||||
"HTFT": {
|
||||
"market": "HTFT",
|
||||
"samples": 103641,
|
||||
"train": 62184,
|
||||
"val": 15546,
|
||||
"cal": 10364,
|
||||
"test": 15547,
|
||||
"features_used": 114,
|
||||
"xgb_best_params": {
|
||||
"max_depth": 4,
|
||||
"eta": 0.015239309183459821,
|
||||
"subsample": 0.7923828997985648,
|
||||
"colsample_bytree": 0.686316507387916,
|
||||
"min_child_weight": 6,
|
||||
"gamma": 0.005249577944740401,
|
||||
"reg_lambda": 2.1813455810361064e-08,
|
||||
"reg_alpha": 3.454483107951557e-06
|
||||
},
|
||||
"lgb_best_params": {
|
||||
"max_depth": 4,
|
||||
"learning_rate": 0.010347899501864056,
|
||||
"feature_fraction": 0.9585697341293057,
|
||||
"bagging_fraction": 0.9413628962257758,
|
||||
"bagging_freq": 2,
|
||||
"min_child_samples": 36,
|
||||
"lambda_l1": 0.0015332771659626943,
|
||||
"lambda_l2": 7.3640280079715765
|
||||
},
|
||||
"xgb_best_iteration": 714,
|
||||
"lgb_best_iteration": 602,
|
||||
"xgb_optuna_best_logloss": 1.7863,
|
||||
"lgb_optuna_best_logloss": 1.7862,
|
||||
"test_xgb_raw": {
|
||||
"accuracy": 0.3349,
|
||||
"logloss": 1.8179
|
||||
},
|
||||
"test_xgb_calibrated": {
|
||||
"accuracy": 0.3332,
|
||||
"logloss": 1.824
|
||||
},
|
||||
"test_lgb_raw": {
|
||||
"accuracy": 0.3367,
|
||||
"logloss": 1.8187
|
||||
},
|
||||
"test_lgb_calibrated": {
|
||||
"accuracy": 0.335,
|
||||
"logloss": 1.8338
|
||||
},
|
||||
"test_ensemble_raw": {
|
||||
"accuracy": 0.3363,
|
||||
"logloss": 1.8176
|
||||
},
|
||||
"test_ensemble_calibrated": {
|
||||
"accuracy": 0.3338,
|
||||
"logloss": 1.828
|
||||
}
|
||||
},
|
||||
"ODD_EVEN": {
|
||||
"market": "ODD_EVEN",
|
||||
"samples": 106861,
|
||||
"train": 64116,
|
||||
"val": 16029,
|
||||
"cal": 10686,
|
||||
"test": 16030,
|
||||
"features_used": 114,
|
||||
"xgb_best_params": {
|
||||
"max_depth": 8,
|
||||
"eta": 0.01010929937405026,
|
||||
"subsample": 0.9492996501687384,
|
||||
"colsample_bytree": 0.9061960005014683,
|
||||
"min_child_weight": 7,
|
||||
"gamma": 2.664416507237002e-08,
|
||||
"reg_lambda": 0.0003748192960525308,
|
||||
"reg_alpha": 0.005287068300306146
|
||||
},
|
||||
"lgb_best_params": {
|
||||
"max_depth": 8,
|
||||
"learning_rate": 0.0634879805509945,
|
||||
"feature_fraction": 0.9993568368122896,
|
||||
"bagging_fraction": 0.9246236397710591,
|
||||
"bagging_freq": 3,
|
||||
"min_child_samples": 16,
|
||||
"lambda_l1": 0.0016414429853061781,
|
||||
"lambda_l2": 6.112007631403553e-05
|
||||
},
|
||||
"xgb_best_iteration": 322,
|
||||
"lgb_best_iteration": 55,
|
||||
"xgb_optuna_best_logloss": 0.6777,
|
||||
"lgb_optuna_best_logloss": 0.6762,
|
||||
"test_xgb_raw": {
|
||||
"accuracy": 0.5216,
|
||||
"logloss": 0.684
|
||||
},
|
||||
"test_xgb_calibrated": {
|
||||
"accuracy": 0.5236,
|
||||
"logloss": 0.6834
|
||||
},
|
||||
"test_lgb_raw": {
|
||||
"accuracy": 0.5279,
|
||||
"logloss": 0.6826
|
||||
},
|
||||
"test_lgb_calibrated": {
|
||||
"accuracy": 0.5274,
|
||||
"logloss": 0.6861
|
||||
},
|
||||
"test_ensemble_raw": {
|
||||
"accuracy": 0.5239,
|
||||
"logloss": 0.6828
|
||||
},
|
||||
"test_ensemble_calibrated": {
|
||||
"accuracy": 0.5236,
|
||||
"logloss": 0.6861
|
||||
}
|
||||
},
|
||||
"CARDS_OU45": {
|
||||
"market": "CARDS_OU45",
|
||||
"samples": 106861,
|
||||
"train": 64116,
|
||||
"val": 16029,
|
||||
"cal": 10686,
|
||||
"test": 16030,
|
||||
"features_used": 114,
|
||||
"xgb_best_params": {
|
||||
"max_depth": 8,
|
||||
"eta": 0.010098671964329344,
|
||||
"subsample": 0.9969616653360747,
|
||||
"colsample_bytree": 0.5085930751344795,
|
||||
"min_child_weight": 10,
|
||||
"gamma": 0.8600893137103568,
|
||||
"reg_lambda": 7.556243125116086,
|
||||
"reg_alpha": 0.5596869360839299
|
||||
},
|
||||
"lgb_best_params": {
|
||||
"max_depth": 8,
|
||||
"learning_rate": 0.0183440412249233,
|
||||
"feature_fraction": 0.5416111323291537,
|
||||
"bagging_fraction": 0.9754210612419695,
|
||||
"bagging_freq": 2,
|
||||
"min_child_samples": 5,
|
||||
"lambda_l1": 0.09157782079463243,
|
||||
"lambda_l2": 2.559000594641019
|
||||
},
|
||||
"xgb_best_iteration": 973,
|
||||
"lgb_best_iteration": 503,
|
||||
"xgb_optuna_best_logloss": 0.6408,
|
||||
"lgb_optuna_best_logloss": 0.6407,
|
||||
"test_xgb_raw": {
|
||||
"accuracy": 0.597,
|
||||
"logloss": 0.6501
|
||||
},
|
||||
"test_xgb_calibrated": {
|
||||
"accuracy": 0.6019,
|
||||
"logloss": 0.6471
|
||||
},
|
||||
"test_lgb_raw": {
|
||||
"accuracy": 0.5977,
|
||||
"logloss": 0.6486
|
||||
},
|
||||
"test_lgb_calibrated": {
|
||||
"accuracy": 0.6019,
|
||||
"logloss": 0.6498
|
||||
},
|
||||
"test_ensemble_raw": {
|
||||
"accuracy": 0.5964,
|
||||
"logloss": 0.6487
|
||||
},
|
||||
"test_ensemble_calibrated": {
|
||||
"accuracy": 0.6034,
|
||||
"logloss": 0.6467
|
||||
}
|
||||
},
|
||||
"HANDICAP_MS": {
|
||||
"market": "HANDICAP_MS",
|
||||
"samples": 106861,
|
||||
"train": 64116,
|
||||
"val": 16029,
|
||||
"cal": 10686,
|
||||
"test": 16030,
|
||||
"features_used": 114,
|
||||
"xgb_best_params": {
|
||||
"max_depth": 4,
|
||||
"eta": 0.01475719431584365,
|
||||
"subsample": 0.867899230696633,
|
||||
"colsample_bytree": 0.6518567347674479,
|
||||
"min_child_weight": 9,
|
||||
"gamma": 0.34932767754310273,
|
||||
"reg_lambda": 3.3257801082201637e-07,
|
||||
"reg_alpha": 4.6977721450875555e-06
|
||||
},
|
||||
"lgb_best_params": {
|
||||
"max_depth": 7,
|
||||
"learning_rate": 0.019649745228555244,
|
||||
"feature_fraction": 0.7903699430858344,
|
||||
"bagging_fraction": 0.7932436899357213,
|
||||
"bagging_freq": 3,
|
||||
"min_child_samples": 30,
|
||||
"lambda_l1": 9.496143774926949e-08,
|
||||
"lambda_l2": 0.0049885051588706136
|
||||
},
|
||||
"xgb_best_iteration": 1016,
|
||||
"lgb_best_iteration": 364,
|
||||
"xgb_optuna_best_logloss": 0.8328,
|
||||
"lgb_optuna_best_logloss": 0.8322,
|
||||
"test_xgb_raw": {
|
||||
"accuracy": 0.6062,
|
||||
"logloss": 0.871
|
||||
},
|
||||
"test_xgb_calibrated": {
|
||||
"accuracy": 0.6039,
|
||||
"logloss": 0.8729
|
||||
},
|
||||
"test_lgb_raw": {
|
||||
"accuracy": 0.6079,
|
||||
"logloss": 0.8713
|
||||
},
|
||||
"test_lgb_calibrated": {
|
||||
"accuracy": 0.6067,
|
||||
"logloss": 0.8736
|
||||
},
|
||||
"test_ensemble_raw": {
|
||||
"accuracy": 0.6072,
|
||||
"logloss": 0.8707
|
||||
},
|
||||
"test_ensemble_calibrated": {
|
||||
"accuracy": 0.6066,
|
||||
"logloss": 0.8728
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
"""
|
||||
Backtest for September 13th (Top Leagues Only)
|
||||
==============================================
|
||||
Simulates the NEW 'Skip Logic' on matches from Sept 13, 2025.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from datetime import datetime
|
||||
|
||||
# Load .env manually to ensure correct DB connection
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, project_root) # Add root to path if needed
|
||||
|
||||
def get_clean_dsn() -> str:
|
||||
return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db"
|
||||
|
||||
# ─── Configuration ─────────
|
||||
MIN_CONF_THRESHOLDS = {
|
||||
"MS": 45.0, "DC": 40.0, "OU15": 50.0, "OU25": 45.0,
|
||||
"OU35": 45.0, "BTTS": 45.0, "HT": 40.0,
|
||||
}
|
||||
|
||||
def run_backtest():
|
||||
print("🚀 Backtest: 13 Eylül 2024 - Top Leagues")
|
||||
print("="*60)
|
||||
|
||||
# 1. Load Top Leagues
|
||||
leagues_path = os.path.join(project_root, "top_leagues.json")
|
||||
try:
|
||||
with open(leagues_path, 'r') as f:
|
||||
top_leagues = json.load(f)
|
||||
# Ensure they are strings for SQL IN clause
|
||||
league_ids = tuple(str(lid) for lid in top_leagues)
|
||||
print(f"📋 Loaded {len(top_leagues)} top leagues.")
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading top_leagues.json: {e}")
|
||||
return
|
||||
|
||||
# 2. Define Date Range (Sept 13, 2024 UTC)
|
||||
start_dt = datetime(2024, 9, 13, 0, 0, 0)
|
||||
end_dt = datetime(2024, 9, 13, 23, 59, 59)
|
||||
start_ts = int(start_dt.timestamp() * 1000)
|
||||
end_ts = int(end_dt.timestamp() * 1000)
|
||||
|
||||
dsn = get_clean_dsn()
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
# 3. Fetch Matches & Predictions
|
||||
# We need matches that are FT and have a prediction
|
||||
query = """
|
||||
SELECT p.match_id, p.prediction_json,
|
||||
m.score_home, m.score_away, m.status, m.league_id
|
||||
FROM predictions p
|
||||
JOIN matches m ON p.match_id = m.id
|
||||
WHERE m.mst_utc BETWEEN %s AND %s
|
||||
AND m.league_id IN %s
|
||||
AND m.status = 'FT'
|
||||
AND p.prediction_json IS NOT NULL
|
||||
"""
|
||||
|
||||
try:
|
||||
cur.execute(query, (start_ts, end_ts, league_ids))
|
||||
rows = cur.fetchall()
|
||||
except Exception as e:
|
||||
print(f"❌ DB Error: {e}")
|
||||
cur.close()
|
||||
conn.close()
|
||||
return
|
||||
|
||||
print(f"📊 Found {len(rows)} matches with predictions on Sept 13, 2024.")
|
||||
|
||||
if not rows:
|
||||
print("⚠️ No predictions found for this date. The AI Engine might not have processed these historical matches yet.")
|
||||
print("💡 Tip: Run the feeder or AI engine on this date range to generate predictions first.")
|
||||
cur.close()
|
||||
conn.close()
|
||||
return
|
||||
|
||||
total_bets = 0
|
||||
winning_bets = 0
|
||||
skipped_bets = 0
|
||||
total_profit = 0.0
|
||||
|
||||
for row in rows:
|
||||
data = row['prediction_json']
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
|
||||
home_score = row['score_home'] or 0
|
||||
away_score = row['score_away'] or 0
|
||||
total_goals = home_score + away_score
|
||||
|
||||
# Extract Main Pick
|
||||
main_pick = None
|
||||
main_pick_conf = 0.0
|
||||
main_pick_odds = 0.0
|
||||
|
||||
if "main_pick" in data and isinstance(data["main_pick"], dict):
|
||||
mp = data["main_pick"]
|
||||
main_pick = mp.get("pick")
|
||||
main_pick_conf = mp.get("confidence", 0.0)
|
||||
main_pick_odds = mp.get("odds", 0.0)
|
||||
|
||||
if not main_pick or not main_pick_conf:
|
||||
continue
|
||||
|
||||
# Determine Market Type
|
||||
pick_str = str(main_pick).upper()
|
||||
market_type = "MS"
|
||||
if "1X" in pick_str or "X2" in pick_str or "12" in pick_str: market_type = "DC"
|
||||
elif "ÜST" in pick_str or "ALT" in pick_str or "OVER" in pick_str or "UNDER" in pick_str:
|
||||
if "1.5" in pick_str: market_type = "OU15"
|
||||
elif "3.5" in pick_str: market_type = "OU35"
|
||||
else: market_type = "OU25"
|
||||
elif "VAR" in pick_str or "YOK" in pick_str or "BTTS" in pick_str: market_type = "BTTS"
|
||||
|
||||
threshold = MIN_CONF_THRESHOLDS.get(market_type, 45.0)
|
||||
|
||||
# --- SKIP LOGIC ---
|
||||
# 1. Confidence Gate
|
||||
if main_pick_conf < threshold:
|
||||
skipped_bets += 1
|
||||
continue
|
||||
|
||||
# 2. Value Gate
|
||||
if main_pick_odds > 0:
|
||||
implied_prob = 1.0 / main_pick_odds
|
||||
my_prob = main_pick_conf / 100.0
|
||||
edge = my_prob - implied_prob
|
||||
if edge < -0.03:
|
||||
skipped_bets += 1
|
||||
continue
|
||||
|
||||
# --- BET PLAYED ---
|
||||
total_bets += 1
|
||||
is_won = False
|
||||
|
||||
# Resolve Result
|
||||
if market_type == "MS":
|
||||
if (main_pick == "1" or main_pick == "MS 1") and home_score > away_score: is_won = True
|
||||
elif (main_pick == "X" or main_pick == "MS X") and home_score == away_score: is_won = True
|
||||
elif (main_pick == "2" or main_pick == "MS 2") and away_score > home_score: is_won = True
|
||||
|
||||
elif market_type.startswith("OU"):
|
||||
line = 2.5
|
||||
if "1.5" in pick_str: line = 1.5
|
||||
elif "3.5" in pick_str: line = 3.5
|
||||
is_over = total_goals > line
|
||||
is_under = total_goals < line
|
||||
if ("ÜST" in pick_str or "OVER" in pick_str) and is_over: is_won = True
|
||||
elif ("ALT" in pick_str or "UNDER" in pick_str) and is_under: is_won = True
|
||||
|
||||
elif market_type == "BTTS":
|
||||
if home_score > 0 and away_score > 0:
|
||||
if "VAR" in pick_str: is_won = True
|
||||
else:
|
||||
if "YOK" in pick_str: is_won = True
|
||||
|
||||
elif market_type == "DC":
|
||||
if "1X" in pick_str and home_score >= away_score: is_won = True
|
||||
elif "X2" in pick_str and away_score >= home_score: is_won = True
|
||||
elif "12" in pick_str and home_score != away_score: is_won = True
|
||||
|
||||
if is_won:
|
||||
winning_bets += 1
|
||||
profit = main_pick_odds - 1.0
|
||||
total_profit += profit
|
||||
else:
|
||||
total_profit -= 1.0
|
||||
|
||||
# Report
|
||||
print("\n" + "="*60)
|
||||
print("📈 BACKTEST RESULTS: 13 EYLÜL 2025 (TOP LEAGUES)")
|
||||
print("="*60)
|
||||
print(f"Total Matches Analyzed: {len(rows)}")
|
||||
print(f"🚫 Bets SKIPPED (Low Conf/Bad Value): {skipped_bets}")
|
||||
print(f"✅ Bets PLAYED: {total_bets}")
|
||||
|
||||
if total_bets > 0:
|
||||
win_rate = (winning_bets / total_bets) * 100
|
||||
roi = (total_profit / total_bets) * 100
|
||||
|
||||
print(f"🏆 Winning Bets: {winning_bets}")
|
||||
print(f"💀 Losing Bets: {total_bets - winning_bets}")
|
||||
print("-" * 40)
|
||||
print(f" Win Rate: {win_rate:.2f}%")
|
||||
print(f"💰 Total Profit (Units): {total_profit:.2f}")
|
||||
print(f"📊 ROI: {roi:.2f}%")
|
||||
|
||||
if roi > 0:
|
||||
print("🟢 STRATEGY IS PROFITABLE!")
|
||||
else:
|
||||
print("🔴 STRATEGY IS LOSING")
|
||||
else:
|
||||
print("⚠️ No bets were played. Thresholds might be too high or no suitable matches found.")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_backtest()
|
||||
@@ -1,240 +0,0 @@
|
||||
"""
|
||||
Detailed Backtest with 50 Top League Matches
|
||||
============================================
|
||||
Runs AI Engine predictions on 50 real historical matches and shows
|
||||
exactly which predictions were correct and which were skipped.
|
||||
|
||||
Usage:
|
||||
python ai-engine/scripts/backtest_50_detailed.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
# Add paths
|
||||
AI_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT_DIR = os.path.dirname(AI_DIR)
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
|
||||
if "scripts" in os.path.basename(AI_DIR):
|
||||
ROOT_DIR = os.path.dirname(ROOT_DIR)
|
||||
|
||||
from services.single_match_orchestrator import get_single_match_orchestrator
|
||||
|
||||
def get_clean_dsn() -> str:
|
||||
return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db"
|
||||
|
||||
# 50 Match IDs from the query
|
||||
MATCH_IDS = [
|
||||
"v2ljcst50nk37x04xwimpi50", "7gz0bhb5yvdssazl3y5946kno", "7ftj7kbu4rzpewxravf3luuc4",
|
||||
"7f1z4e8ch1dm5q677644cky6s", "7ffq3aq3so22iymfdzch63nys", "rrkmeuymz7gzvoz8mplikzdg",
|
||||
"7hegc9covicy699bxsi81xkb8", "7gl7rpr1hjayk3e5ut0gr613o", "7g7d86i3738287xfvyfeffcwk",
|
||||
"7hs4boe4hv80muawocevvx2j8", "7ijhsloieg4t9yp5cxp0duln8", "7ixaiiptli5ek32kuybuni4gk",
|
||||
"7i5sfh41cjpwg4l972dm487x0", "eo7g4wunxxxr8uv45q8p5x638", "7dinds2937w4645wva2rddlas",
|
||||
"7b5ukdhvqh62wtndeqfg01ixg", "7bjptsj24gndoydn7n0202g44", "7cqxf3vo58ewrwmoom5xiyexg",
|
||||
"7bxjl9h2hnf165rlp3o1vfztg", "7eo8zrez08c342rqsezpvq39w", "7as1muhs98vdarlhsean4bspg",
|
||||
"7dwhj8cfxv6v6bzxpu5e3h05w", "7d4vq4417ps84yjzh95bnvvv8", "7ea9z501jgp9kxw3gay4myrkk",
|
||||
"7cd3401itlty6ded7c1wct0yc", "ebgpz9mcije2snv986n6587pw", "i7ar1dkhvcwpxmkyks65ib6c",
|
||||
"lyek7tyy6qk2xjs9vblucnx0", "hdn9qtyn3ysjwbc3i2trantg", "3y2bnssfqlajosiz2gpkn6xhw",
|
||||
"40pehd14s9djjtycujavbex3o", "3xnbfjznzmnwml20akbgnis5w", "2eovi2rcc2l4ha7fpb2w7e1hw",
|
||||
"2bwuikdjyyuithhru8ka8o00k", "2d3pcd76ya9ihi9yotxc553is", "1e9it04z4epy2etdxsffe7m6s",
|
||||
"7af49jgo4iulv1k8cplj9smj8", "5k3vrz619hdu9nx4rnx6uim1g", "amjppgpetnyr0iisi241kgkyc",
|
||||
"coqrhq09kxd16iejvgtzj3mz8", "d8ysan1qdctmkvjaz2adw7aqc", "9ttciz0gtb0z09ev1q5fe0ro4",
|
||||
"9u720o37yaddqu1w6hlszpnh0", "7ijezdjp8t0rjti91ac63hyxg", "72gvdvztbb3dn79jidzzxzcb8",
|
||||
"6uof1v2s6vrpieeml2bwo9tlg", "91dd8ia3m0bxoqzjgyo3ptsk", "3tj1nt3udsbvb9soqn2cs6gpg",
|
||||
"1br5g88o5idtjxka1fr6zg4k4", "akuesquthbmxlzckvnqmgles4"
|
||||
]
|
||||
|
||||
def run_detailed_backtest():
|
||||
print("🚀 DETAILED BACKTEST: 50 Top League Matches")
|
||||
print("🧠 Engine: V30 Ensemble (V20+V25) + Skip Logic")
|
||||
print("="*80)
|
||||
|
||||
dsn = get_clean_dsn()
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
# Fetch match details with odds
|
||||
placeholders = ','.join(['%s'] * len(MATCH_IDS))
|
||||
cur.execute(f"""
|
||||
SELECT m.id, m.match_name, m.home_team_id, m.away_team_id,
|
||||
m.score_home, m.score_away, m.league_id,
|
||||
t1.name as home_team, t2.name as away_team,
|
||||
l.name as league_name
|
||||
FROM matches m
|
||||
LEFT JOIN teams t1 ON m.home_team_id = t1.id
|
||||
LEFT JOIN teams t2 ON m.away_team_id = t2.id
|
||||
LEFT JOIN leagues l ON m.league_id = l.id
|
||||
WHERE m.id IN ({placeholders})
|
||||
AND m.status = 'FT'
|
||||
ORDER BY m.mst_utc DESC
|
||||
""", MATCH_IDS)
|
||||
|
||||
rows = cur.fetchall()
|
||||
print(f"📊 Found {len(rows)} matches. Starting AI Analysis...")
|
||||
|
||||
if not rows:
|
||||
print("⚠️ No matches found.")
|
||||
cur.close()
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# Initialize AI Engine
|
||||
try:
|
||||
orchestrator = get_single_match_orchestrator()
|
||||
print("✅ AI Engine Loaded.\n")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load AI Engine: {e}")
|
||||
cur.close()
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# ─── Backtest Loop ───
|
||||
results = []
|
||||
total_skipped = 0
|
||||
total_played = 0
|
||||
total_won = 0
|
||||
total_profit = 0.0
|
||||
MIN_CONF = 45.0
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
match_id = str(row['id'])
|
||||
home_team = row['home_team'] or "Unknown"
|
||||
away_team = row['away_team'] or "Unknown"
|
||||
league = row['league_name'] or "Unknown"
|
||||
home_score = row['score_home'] or 0
|
||||
away_score = row['score_away'] or 0
|
||||
total_goals = home_score + away_score
|
||||
|
||||
print(f"[{i+1}/{len(rows)}] {home_team} vs {away_team} ({league}) ... ", end="", flush=True)
|
||||
|
||||
try:
|
||||
prediction = orchestrator.analyze_match(match_id)
|
||||
|
||||
if not prediction:
|
||||
print("⚠️ No prediction")
|
||||
continue
|
||||
|
||||
# Extract Main Pick
|
||||
main_pick = prediction.get("main_pick") or {}
|
||||
pick_name = main_pick.get("pick", "")
|
||||
confidence = main_pick.get("confidence", 0)
|
||||
odds = main_pick.get("odds", 0)
|
||||
|
||||
# Apply Skip Logic
|
||||
if confidence < MIN_CONF:
|
||||
print(f"🚫 SKIP (Conf {confidence:.0f}%)")
|
||||
total_skipped += 1
|
||||
results.append({"match": f"{home_team} vs {away_team}", "pick": pick_name,
|
||||
"conf": confidence, "odds": odds, "result": "SKIPPED", "profit": 0})
|
||||
continue
|
||||
|
||||
if odds > 0:
|
||||
implied_prob = 1.0 / odds
|
||||
my_prob = confidence / 100.0
|
||||
if my_prob - implied_prob < -0.03:
|
||||
print(f"🚫 SKIP (Bad Value)")
|
||||
total_skipped += 1
|
||||
results.append({"match": f"{home_team} vs {away_team}", "pick": pick_name,
|
||||
"conf": confidence, "odds": odds, "result": "SKIPPED", "profit": 0})
|
||||
continue
|
||||
|
||||
# Bet Played
|
||||
total_played += 1
|
||||
won = False
|
||||
|
||||
# Resolve
|
||||
pick_clean = str(pick_name).upper()
|
||||
if pick_clean in ["1", "MS 1", "İY 1"] and home_score > away_score: won = True
|
||||
elif pick_clean in ["X", "MS X", "İY X"] and home_score == away_score: won = True
|
||||
elif pick_clean in ["2", "MS 2", "İY 2"] and away_score > home_score: won = True
|
||||
elif pick_clean in ["1X", "X2"] or ("1X" in pick_clean or "X2" in pick_clean):
|
||||
if "1X" in pick_clean and home_score >= away_score: won = True
|
||||
elif "X2" in pick_clean and away_score >= home_score: won = True
|
||||
elif pick_clean in ["12"] and home_score != away_score: won = True
|
||||
elif "ÜST" in pick_clean or "OVER" in pick_clean:
|
||||
line = 2.5
|
||||
if "1.5" in pick_clean: line = 1.5
|
||||
elif "3.5" in pick_clean: line = 3.5
|
||||
if total_goals > line: won = True
|
||||
elif "ALT" in pick_clean or "UNDER" in pick_clean:
|
||||
line = 2.5
|
||||
if "1.5" in pick_clean: line = 1.5
|
||||
elif "3.5" in pick_clean: line = 3.5
|
||||
if total_goals < line: won = True
|
||||
elif "VAR" in pick_clean and home_score > 0 and away_score > 0: won = True
|
||||
elif "YOK" in pick_clean and (home_score == 0 or away_score == 0): won = True
|
||||
|
||||
if won:
|
||||
total_won += 1
|
||||
profit = odds - 1.0
|
||||
print(f"✅ WON ({pick_name} @ {odds:.2f}, +{profit:.2f})")
|
||||
else:
|
||||
profit = -1.0
|
||||
print(f"❌ LOST ({pick_name} @ {odds:.2f})")
|
||||
|
||||
total_profit += profit
|
||||
results.append({"match": f"{home_team} vs {away_team}", "pick": pick_name,
|
||||
"conf": confidence, "odds": odds,
|
||||
"result": "WON" if won else "LOST", "profit": profit,
|
||||
"score": f"{home_score}-{away_score}"})
|
||||
|
||||
except Exception as e:
|
||||
print(f"💥 Error: {e}")
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# ─── DETAILED REPORT ───
|
||||
print("\n" + "="*80)
|
||||
print("📈 DETAILED BACKTEST RESULTS")
|
||||
print(f"⏱️ Time: {elapsed:.1f}s")
|
||||
print("="*80)
|
||||
print(f"📊 Total Matches: {len(rows)}")
|
||||
print(f"🚫 Skipped: {total_skipped}")
|
||||
print(f"🎲 Played: {total_played}")
|
||||
print(f"✅ Won: {total_won}")
|
||||
print(f"💀 Lost: {total_played - total_won}")
|
||||
print(f"💰 Profit: {total_profit:+.2f} units")
|
||||
|
||||
if total_played > 0:
|
||||
win_rate = (total_won / total_played) * 100
|
||||
roi = (total_profit / total_played) * 100
|
||||
print(f"📊 Win Rate: {win_rate:.1f}%")
|
||||
print(f"📊 ROI: {roi:.1f}%")
|
||||
if roi > 0:
|
||||
print("🟢 STRATEGY IS PROFITABLE!")
|
||||
else:
|
||||
print("🔴 STRATEGY IS LOSING")
|
||||
|
||||
# ─── TABLE OF ALL RESULTS ───
|
||||
print("\n" + "="*80)
|
||||
print("📋 DETAILED MATCH RESULTS")
|
||||
print("="*80)
|
||||
print(f"{'Match':<40} {'Pick':<15} {'Conf':<6} {'Odds':<6} {'Result':<8} {'Score':<6}")
|
||||
print("-"*80)
|
||||
for r in results:
|
||||
match_str = r['match'][:38]
|
||||
pick_str = str(r['pick'])[:13]
|
||||
conf_str = f"{r['conf']:.0f}%"
|
||||
odds_str = f"{r['odds']:.2f}" if r['odds'] > 0 else "N/A"
|
||||
res_str = r['result']
|
||||
score_str = r.get('score', '')
|
||||
|
||||
# Color coding
|
||||
if res_str == "WON": res_display = f"✅ {res_str}"
|
||||
elif res_str == "LOST": res_display = f"❌ {res_str}"
|
||||
else: res_display = f"🚫 {res_str}"
|
||||
|
||||
print(f"{match_str:<40} {pick_str:<15} {conf_str:<6} {odds_str:<6} {res_display:<12} {score_str:<6}")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_detailed_backtest()
|
||||
@@ -1,191 +0,0 @@
|
||||
"""
|
||||
Adaptive 500 Match Backtest
|
||||
=============================
|
||||
Skips NO match unless NO odds exist.
|
||||
Evaluates ALL available markets (MS, OU, BTTS) and picks the BEST value bet.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
AI_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT_DIR = os.path.dirname(AI_DIR)
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
if "scripts" in os.path.basename(AI_DIR):
|
||||
ROOT_DIR = os.path.dirname(ROOT_DIR)
|
||||
|
||||
from services.single_match_orchestrator import get_single_match_orchestrator
|
||||
|
||||
def get_clean_dsn() -> str:
|
||||
return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db"
|
||||
|
||||
def run_adaptive_backtest():
|
||||
print("🔄 ADAPTIVE 500 MATCH BACKTEST")
|
||||
print("="*60)
|
||||
|
||||
# 1. Load Top Leagues
|
||||
leagues_path = os.path.join(ROOT_DIR, "top_leagues.json")
|
||||
with open(leagues_path, 'r') as f:
|
||||
top_leagues = json.load(f)
|
||||
league_ids = tuple(str(lid) for lid in top_leagues)
|
||||
|
||||
dsn = get_clean_dsn()
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
# 2. Fetch 500 Finished Matches with Odds
|
||||
cur.execute("""
|
||||
SELECT m.id, m.match_name, m.home_team_id, m.away_team_id,
|
||||
m.score_home, m.score_away, m.league_id,
|
||||
t1.name as home_team, t2.name as away_team
|
||||
FROM matches m
|
||||
LEFT JOIN teams t1 ON m.home_team_id = t1.id
|
||||
LEFT JOIN teams t2 ON m.away_team_id = t2.id
|
||||
WHERE m.league_id IN %s
|
||||
AND m.status = 'FT'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM odd_categories oc WHERE oc.match_id = m.id)
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT 500
|
||||
""", (league_ids,))
|
||||
|
||||
rows = cur.fetchall()
|
||||
print(f"📊 Found {len(rows)} matches. Analyzing...\n")
|
||||
|
||||
if not rows:
|
||||
print("⚠️ No matches found.")
|
||||
return
|
||||
|
||||
try: orchestrator = get_single_match_orchestrator()
|
||||
except Exception as e:
|
||||
print(f"❌ AI Error: {e}")
|
||||
return
|
||||
|
||||
# Stats
|
||||
total_evaluated = 0
|
||||
total_bet = 0
|
||||
total_won = 0
|
||||
total_profit = 0.0
|
||||
skipped_count = 0
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
match_id = str(row['id'])
|
||||
home = row['home_team'] or "?"
|
||||
away = row['away_team'] or "?"
|
||||
h_score = row['score_home'] or 0
|
||||
a_score = row['score_away'] or 0
|
||||
|
||||
total_evaluated += 1
|
||||
# print(f"[{i+1}] {home} vs {away} ... ", end="", flush=True)
|
||||
|
||||
try:
|
||||
pred = orchestrator.analyze_match(match_id)
|
||||
if not pred:
|
||||
# print("⚠️ No Data")
|
||||
continue
|
||||
|
||||
# ─── ADAPTIVE PICKING ───
|
||||
# Check ALL recommendations (Expert or Standard) to find the BEST option
|
||||
candidates = []
|
||||
|
||||
# Add main picks
|
||||
if pred.get("expert_recommendation"):
|
||||
rec = pred["expert_recommendation"]
|
||||
if rec.get("main_pick"): candidates.append(rec["main_pick"])
|
||||
if rec.get("safe_alternative"): candidates.append(rec["safe_alternative"])
|
||||
if rec.get("value_picks"): candidates.extend(rec["value_picks"])
|
||||
elif pred.get("main_pick"):
|
||||
candidates.append(pred["main_pick"])
|
||||
|
||||
best_bet = None
|
||||
for c in candidates:
|
||||
if not c: continue
|
||||
conf = c.get("confidence", 0)
|
||||
odds = c.get("odds", 0)
|
||||
pick = c.get("pick")
|
||||
|
||||
# Flexible Criteria:
|
||||
# 1. Confidence > 60%
|
||||
# 2. Odds > 1.10 (Not "free" odds like 1.00)
|
||||
# 3. Edge > -2% (Slightly tolerant)
|
||||
if conf >= 60 and odds > 1.10:
|
||||
implied = 1.0 / odds
|
||||
edge = ((conf/100) - implied) * 100
|
||||
|
||||
# Prioritize positive edge, but accept small negative if confidence is high
|
||||
if edge > -2.0:
|
||||
if best_bet is None or (conf > best_bet.get("confidence", 0)):
|
||||
best_bet = c
|
||||
|
||||
if best_bet:
|
||||
pick = str(best_bet.get("pick")).upper()
|
||||
conf = best_bet.get("confidence")
|
||||
odds = best_bet.get("odds")
|
||||
|
||||
# Resolution Logic
|
||||
won = False
|
||||
if pick in ["1", "MS 1", "İY 1"] and h_score > a_score: won = True
|
||||
elif pick in ["X", "MS X", "İY X"] and h_score == a_score: won = True
|
||||
elif pick in ["2", "MS 2", "İY 2"] and a_score > h_score: won = True
|
||||
elif pick in ["1X", "X2"]:
|
||||
if "1X" in pick and h_score >= a_score: won = True
|
||||
elif "X2" in pick and a_score >= h_score: won = True
|
||||
elif pick == "12" and h_score != a_score: won = True
|
||||
elif "ÜST" in pick or "OVER" in pick:
|
||||
line = 2.5
|
||||
if "1.5" in pick: line = 1.5
|
||||
elif "3.5" in pick: line = 3.5
|
||||
if (h_score + a_score) > line: won = True
|
||||
elif "ALT" in pick or "UNDER" in pick:
|
||||
line = 2.5
|
||||
if "1.5" in pick: line = 1.5
|
||||
elif "3.5" in pick: line = 3.5
|
||||
if (h_score + a_score) < line: won = True
|
||||
elif "VAR" in pick and h_score > 0 and a_score > 0: won = True
|
||||
elif "YOK" in pick and (h_score == 0 or a_score == 0): won = True
|
||||
|
||||
total_bet += 1
|
||||
if won:
|
||||
total_won += 1
|
||||
profit = odds - 1.0
|
||||
total_profit += profit
|
||||
# print(f"✅ WON (+{profit:.2f}) | {pick}")
|
||||
else:
|
||||
total_profit -= 1.0
|
||||
# print(f"❌ LOST ({pick} @ {odds:.2f})")
|
||||
else:
|
||||
skipped_count += 1
|
||||
# print(f"🚫 SKIP (No Value)")
|
||||
|
||||
except Exception as e:
|
||||
# print(f"💥 Error: {e}")
|
||||
pass
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("🔄 ADAPTIVE BACKTEST RESULTS (500 Matches)")
|
||||
print("="*60)
|
||||
print(f"📊 Evaluated: {total_evaluated}")
|
||||
print(f"🎲 Played: {total_bet}")
|
||||
print(f"🚫 Skipped: {skipped_count}")
|
||||
print(f"✅ Won: {total_won}")
|
||||
|
||||
if total_bet > 0:
|
||||
win_rate = (total_won / total_bet) * 100
|
||||
roi = (total_profit / total_bet) * 100
|
||||
print(f"📈 Win Rate: {win_rate:.2f}%")
|
||||
print(f"💰 Total Profit: {total_profit:.2f} Units")
|
||||
print(f"📊 ROI: {roi:.2f}%")
|
||||
if total_profit > 0: print("🟢 KARLI STRATEJİ")
|
||||
else: print("🔴 ZARARDA")
|
||||
else:
|
||||
print("⚠️ Hiç bahis oynanmadı. Veri kalitesi çok düşük.")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_adaptive_backtest()
|
||||
@@ -0,0 +1,146 @@
|
||||
import os
|
||||
import sys
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
# Path ayarları
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from services.single_match_orchestrator import SingleMatchOrchestrator
|
||||
from services.feature_enrichment import FeatureEnrichmentService
|
||||
|
||||
DSN = "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db"
|
||||
|
||||
def run_backtest(target_date="2026-05-03"):
|
||||
conn = psycopg2.connect(DSN)
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
# 1. Hedef tarihteki bitmiş maçları ve takım isimlerini getir
|
||||
cur.execute("""
|
||||
SELECT m.id, m.score_home, m.score_away, m.mst_utc,
|
||||
t1.name as home_name, t2.name as away_name
|
||||
FROM matches m
|
||||
LEFT JOIN teams t1 ON m.home_team_id = t1.id
|
||||
LEFT JOIN teams t2 ON m.away_team_id = t2.id
|
||||
WHERE m.status IN ('FT', 'AET', 'PEN')
|
||||
AND to_timestamp(m.mst_utc / 1000.0)::date = %s::date
|
||||
AND m.score_home IS NOT NULL
|
||||
ORDER BY m.mst_utc ASC
|
||||
""", (target_date,))
|
||||
matches = cur.fetchall()
|
||||
|
||||
if not matches:
|
||||
print(f"❌ {target_date} tarihinde bitmiş maç bulunamadı.")
|
||||
return
|
||||
|
||||
print(f"🚀 {target_date} için Orkestratör Backtesti Başlatılıyor... ({len(matches)} maç bulundu)")
|
||||
print("-" * 60)
|
||||
|
||||
orchestrator = SingleMatchOrchestrator()
|
||||
|
||||
bets_placed = 0
|
||||
won = 0
|
||||
lost = 0
|
||||
total_odds_won = 0.0
|
||||
|
||||
for match in matches:
|
||||
# 3. Üst Akıl (Orkestratör) analizi yapar
|
||||
try:
|
||||
package = orchestrator.analyze_match(match['id'])
|
||||
except Exception as e:
|
||||
print(f"Hata ({match['id']}): {e}")
|
||||
continue
|
||||
|
||||
if not package:
|
||||
continue
|
||||
|
||||
package_data = package
|
||||
|
||||
# 4. Üst akıl bu maça bahis yapmaya karar verdi mi?
|
||||
bet_advice = package_data.get("bet_advice", {})
|
||||
if bet_advice.get("playable") == True:
|
||||
bets_placed += 1
|
||||
main_pick = package_data.get("main_pick", {})
|
||||
market = main_pick.get("market")
|
||||
pick = main_pick.get("pick")
|
||||
odds = float(main_pick.get("odds", 0.0) or 0.0)
|
||||
|
||||
# Skora göre kazanıp kazanmadığını kontrol et
|
||||
is_won = False
|
||||
h = match['score_home']
|
||||
a = match['score_away']
|
||||
|
||||
if market == "MS":
|
||||
if pick == "1" and h > a: is_won = True
|
||||
elif pick in ("X", "0") and h == a: is_won = True
|
||||
elif pick == "2" and a > h: is_won = True
|
||||
elif market == "OU25":
|
||||
if pick == "Üst" and (h+a) > 2.5: is_won = True
|
||||
elif pick == "Alt" and (h+a) < 2.5: is_won = True
|
||||
elif market == "OU15":
|
||||
if pick == "Üst" and (h+a) > 1.5: is_won = True
|
||||
elif pick == "Alt" and (h+a) < 1.5: is_won = True
|
||||
elif market == "BTTS":
|
||||
if pick == "KG Var" and h > 0 and a > 0: is_won = True
|
||||
elif pick == "KG Yok" and (h == 0 or a == 0): is_won = True
|
||||
elif market == "DC":
|
||||
if pick == "1X" and h >= a: is_won = True
|
||||
elif pick == "12" and h != a: is_won = True
|
||||
elif pick == "X2" and h <= a: is_won = True
|
||||
|
||||
if is_won:
|
||||
won += 1
|
||||
total_odds_won += odds
|
||||
res = "✅ KAZANDI"
|
||||
else:
|
||||
lost += 1
|
||||
res = "❌ KAYBETTİ"
|
||||
|
||||
print(f"[{res}] {match['home_name']} {h}-{a} {match['away_name']} | Tahmin: {market} {pick} (Oran: {odds})")
|
||||
else:
|
||||
main_pick = package_data.get("main_pick", {})
|
||||
reasons = main_pick.get("reasons", ["Bilinmeyen Neden"]) if main_pick else ["No main pick"]
|
||||
reason = " | ".join(reasons) if isinstance(reasons, list) else str(reasons)
|
||||
|
||||
market_board = package_data.get("market_board", {})
|
||||
main_pick_market = main_pick.get('market', 'N/A') if main_pick else 'N/A'
|
||||
main_pick_pick = main_pick.get('pick', 'N/A') if main_pick else 'N/A'
|
||||
print(f"[PAS] {match['home_name']} {match['score_home']}-{match['score_away']} {match['away_name']} | Reddedilen: {main_pick_market} {main_pick_pick} -> Neden: {reason}")
|
||||
if "market_passed_all_gates" in reason:
|
||||
print(f" DEBUG: bet_advice = {bet_advice}")
|
||||
|
||||
v25_ms = market_board.get("MS", {}).get("probs", {})
|
||||
v27_ms = {} # V27 is merged into V25 probabilities in market_board, or we don't have separate V27 access here
|
||||
|
||||
# Skora göre ms kontrolü
|
||||
h = match['score_home']
|
||||
a = match['score_away']
|
||||
actual_ms = "1" if h > a else ("X" if h == a else "2")
|
||||
|
||||
v25_top = max(v25_ms, key=v25_ms.get) if v25_ms else "N/A"
|
||||
v27_top = "N/A"
|
||||
|
||||
rejected_market = main_pick.get("market", "N/A") if main_pick else "N/A"
|
||||
rejected_pick = main_pick.get("pick", "N/A") if main_pick else "N/A"
|
||||
|
||||
print(f"[PAS] {match['home_name']} {h}-{a} {match['away_name']} | Reddedilen: {rejected_market} {rejected_pick} -> Neden: {reason}")
|
||||
print(f" [V25 MS Raw: {v25_top}] [Gerçek MS: {actual_ms}]")
|
||||
|
||||
# Sonuç Raporu
|
||||
print("\n" + "=" * 60)
|
||||
print(f"📊 BACKTEST SONUÇLARI ({target_date})")
|
||||
print("=" * 60)
|
||||
print(f"Toplam Maç Sayısı : {len(matches)}")
|
||||
print(f"Oynanan Bahis Sayısı: {bets_placed} (Oynama Oranı: %{bets_placed/len(matches)*100:.1f})")
|
||||
print(f"Riskli Bulunup Pas Geçilen: {len(matches) - bets_placed}")
|
||||
|
||||
if bets_placed > 0:
|
||||
win_rate = won / bets_placed * 100
|
||||
roi = ((total_odds_won - bets_placed) / bets_placed) * 100
|
||||
print(f"Kazanılan : {won}")
|
||||
print(f"Kaybedilen : {lost}")
|
||||
print(f"İsabet Oranı : %{win_rate:.1f}")
|
||||
print(f"Net Kar (ROI) : %{roi:.1f} {'📈' if roi > 0 else '📉'}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_backtest("2026-05-03")
|
||||
@@ -1,145 +0,0 @@
|
||||
"""
|
||||
Diagnostic Backtest - Hangi Pazar Kanıyor?
|
||||
===========================================
|
||||
Analyses the 500 matches to see WHICH markets are losing money.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from collections import defaultdict
|
||||
|
||||
AI_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT_DIR = os.path.dirname(AI_DIR)
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
if "scripts" in os.path.basename(AI_DIR):
|
||||
ROOT_DIR = os.path.dirname(ROOT_DIR)
|
||||
|
||||
from services.single_match_orchestrator import get_single_match_orchestrator
|
||||
|
||||
def get_clean_dsn() -> str:
|
||||
return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db"
|
||||
|
||||
def run_diagnostic():
|
||||
print("🔍 TANI BACKTESTİ: NEREDE KAYBETTİK?")
|
||||
print("="*60)
|
||||
|
||||
leagues_path = os.path.join(ROOT_DIR, "top_leagues.json")
|
||||
with open(leagues_path, 'r') as f:
|
||||
top_leagues = json.load(f)
|
||||
league_ids = tuple(str(lid) for lid in top_leagues)
|
||||
|
||||
dsn = get_clean_dsn()
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
cur.execute("""
|
||||
SELECT m.id, m.match_name, m.home_team_id, m.away_team_id,
|
||||
m.score_home, m.score_away, m.league_id,
|
||||
t1.name as home_team, t2.name as away_team
|
||||
FROM matches m
|
||||
LEFT JOIN teams t1 ON m.home_team_id = t1.id
|
||||
LEFT JOIN teams t2 ON m.away_team_id = t2.id
|
||||
WHERE m.league_id IN %s
|
||||
AND m.status = 'FT'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM odd_categories oc WHERE oc.match_id = m.id)
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT 500
|
||||
""", (league_ids,))
|
||||
|
||||
rows = cur.fetchall()
|
||||
print(f"📊 {len(rows)} maç analiz ediliyor...\n")
|
||||
|
||||
try: orchestrator = get_single_match_orchestrator()
|
||||
except Exception as e:
|
||||
print(f"❌ AI Hatası: {e}")
|
||||
return
|
||||
|
||||
# Market Stats: { "MS": {"won": 10, "lost": 20, "profit": -5.0}, ... }
|
||||
market_stats = defaultdict(lambda: {"won": 0, "lost": 0, "profit": 0.0, "total": 0})
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
match_id = str(row['id'])
|
||||
h_score = row['score_home'] or 0
|
||||
a_score = row['score_away'] or 0
|
||||
|
||||
try:
|
||||
pred = orchestrator.analyze_match(match_id)
|
||||
if not pred: continue
|
||||
|
||||
candidates = []
|
||||
if pred.get("expert_recommendation"):
|
||||
rec = pred["expert_recommendation"]
|
||||
if rec.get("main_pick"): candidates.append(rec["main_pick"])
|
||||
if rec.get("value_picks"): candidates.extend(rec["value_picks"])
|
||||
elif pred.get("main_pick"):
|
||||
candidates.append(pred["main_pick"])
|
||||
|
||||
played_this = False
|
||||
for c in candidates:
|
||||
if not c: continue
|
||||
conf = c.get("confidence", 0)
|
||||
odds = c.get("odds", 0)
|
||||
pick = str(c.get("pick")).upper()
|
||||
market_type = c.get("market_type", "Unknown")
|
||||
|
||||
# Criteria
|
||||
if conf >= 60 and odds > 1.10:
|
||||
implied = 1.0 / odds
|
||||
edge = ((conf/100) - implied) * 100
|
||||
if edge > -2.0:
|
||||
# Resolve
|
||||
won = False
|
||||
if pick in ["1", "MS 1"] and h_score > a_score: won = True
|
||||
elif pick in ["X", "MS X"] and h_score == a_score: won = True
|
||||
elif pick in ["2", "MS 2"] and a_score > h_score: won = True
|
||||
elif pick in ["1X", "X2"]:
|
||||
if "1X" in pick and h_score >= a_score: won = True
|
||||
elif "X2" in pick and a_score >= h_score: won = True
|
||||
elif pick == "12" and h_score != a_score: won = True
|
||||
elif "ÜST" in pick or "OVER" in pick:
|
||||
line = 2.5
|
||||
if "1.5" in pick: line = 1.5
|
||||
elif "3.5" in pick: line = 3.5
|
||||
if (h_score + a_score) > line: won = True
|
||||
elif "ALT" in pick or "UNDER" in pick:
|
||||
line = 2.5
|
||||
if "1.5" in pick: line = 1.5
|
||||
elif "3.5" in pick: line = 3.5
|
||||
if (h_score + a_score) < line: won = True
|
||||
elif "VAR" in pick and h_score > 0 and a_score > 0: won = True
|
||||
elif "YOK" in pick and (h_score == 0 or a_score == 0): won = True
|
||||
|
||||
market_stats[market_type]["total"] += 1
|
||||
if won:
|
||||
market_stats[market_type]["won"] += 1
|
||||
market_stats[market_type]["profit"] += (odds - 1.0)
|
||||
else:
|
||||
market_stats[market_type]["lost"] += 1
|
||||
market_stats[market_type]["profit"] -= 1.0
|
||||
|
||||
played_this = True
|
||||
break # Only one bet per match
|
||||
|
||||
except: pass
|
||||
|
||||
# Print Results
|
||||
print("\n" + "="*60)
|
||||
print("📊 PAZAR BAZLI KAR/ZARAR TABLOSU")
|
||||
print("="*60)
|
||||
print(f"{'Market':<15} {'Oynanan':<10} {'Kazanılan':<10} {'Win%':<8} {'Kâr':<10}")
|
||||
print("-" * 60)
|
||||
|
||||
for mkt, stats in sorted(market_stats.items(), key=lambda x: x[1]["profit"], reverse=True):
|
||||
wr = (stats["won"] / stats["total"] * 100) if stats["total"] > 0 else 0
|
||||
print(f"{mkt:<15} {stats['total']:<10} {stats['won']:<10} {wr:.1f}% {stats['profit']:+.2f} Units")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_diagnostic()
|
||||
@@ -1,215 +0,0 @@
|
||||
"""
|
||||
V27 FINAL BACKTEST — Conservative Flat Bet
|
||||
Only the strongest validated edges. No Kelly compounding.
|
||||
"""
|
||||
import pandas as pd, numpy as np
|
||||
|
||||
df = pd.read_csv('data/training_data_v27.csv', low_memory=False)
|
||||
for c in df.columns:
|
||||
if c not in ['match_id','league_name','home_team','away_team']:
|
||||
df[c] = pd.to_numeric(df[c], errors='coerce')
|
||||
df = df.dropna(subset=['odds_ms_h','odds_ms_d','odds_ms_a'])
|
||||
df = df[(df.odds_ms_h>1.01)&(df.odds_ms_d>1.01)&(df.odds_ms_a>1.01)]
|
||||
|
||||
n = len(df)
|
||||
# 5-fold walk-forward: train on 60%, validate patterns, test on remaining
|
||||
folds = 5
|
||||
fold_size = n // folds
|
||||
all_results = []
|
||||
|
||||
print("="*65)
|
||||
print(" V27 WALK-FORWARD FLAT-BET BACKTEST")
|
||||
print("="*65)
|
||||
|
||||
for fold in range(2, folds): # start from fold 2 so we have enough training data
|
||||
train_end = fold * fold_size
|
||||
test_start = train_end
|
||||
test_end = (fold+1)*fold_size if fold < folds-1 else n
|
||||
|
||||
train_df = df.iloc[:train_end]
|
||||
test_df = df.iloc[test_start:test_end]
|
||||
|
||||
print(f"\n --- Fold {fold}: train={len(train_df)}, test={len(test_df)} ---")
|
||||
|
||||
# Discover REST edges from training data
|
||||
strategies = []
|
||||
|
||||
for hr in [5, 7, 10, 14]:
|
||||
for ar in [3, 4, 5]:
|
||||
for cls, col in [(0,'odds_ms_h'), (2,'odds_ms_a')]:
|
||||
idx = (train_df.home_days_rest > hr) & (train_df.away_days_rest < ar)
|
||||
sub = train_df[idx]
|
||||
if len(sub) < 50:
|
||||
continue
|
||||
rate = (sub.label_ms == cls).mean()
|
||||
avg_odds = sub[col].mean()
|
||||
ev = rate * avg_odds
|
||||
if ev > 1.02: # only strong edges (>2% edge)
|
||||
strategies.append((hr, ar, cls, rate, avg_odds, ev, len(sub)))
|
||||
|
||||
if not strategies:
|
||||
print(" No strong edges found in training data")
|
||||
continue
|
||||
|
||||
# Apply best strategies to test
|
||||
strategies.sort(key=lambda x: x[5], reverse=True)
|
||||
best = strategies[:3] # top 3 only
|
||||
|
||||
fold_bets = 0
|
||||
fold_wins = 0
|
||||
fold_pnl = 0
|
||||
stake = 10 # flat 10 units
|
||||
|
||||
for _, row in test_df.iterrows():
|
||||
for hr, ar, cls, est_p, _, _, _ in best:
|
||||
if pd.isna(row.home_days_rest) or pd.isna(row.away_days_rest):
|
||||
continue
|
||||
if row.home_days_rest <= hr or row.away_days_rest >= ar:
|
||||
continue
|
||||
odds_col = ['odds_ms_h','odds_ms_d','odds_ms_a'][cls]
|
||||
odds_val = row[odds_col]
|
||||
if pd.isna(odds_val) or odds_val < 1.50 or odds_val > 5.0:
|
||||
continue
|
||||
# Additional filter: only bet when odds give reasonable EV
|
||||
if est_p * odds_val < 1.0:
|
||||
continue
|
||||
|
||||
won = (row.label_ms == cls)
|
||||
pnl = stake * (odds_val - 1) if won else -stake
|
||||
fold_bets += 1
|
||||
if won:
|
||||
fold_wins += 1
|
||||
fold_pnl += pnl
|
||||
all_results.append({'fold': fold, 'won': won, 'pnl': pnl,
|
||||
'odds': odds_val, 'stake': stake,
|
||||
'cls': ['H','D','A'][cls]})
|
||||
|
||||
if fold_bets > 0:
|
||||
roi = fold_pnl / (fold_bets * stake) * 100
|
||||
print(f" Best strategies: {[(h,a,['H','D','A'][c],f'EV={e:.3f}') for h,a,c,_,_,e,_ in best]}")
|
||||
print(f" Bets: {fold_bets}, Wins: {fold_wins} ({fold_wins/fold_bets*100:.1f}%), "
|
||||
f"ROI: {roi:+.1f}%, PnL: {fold_pnl:+.0f}")
|
||||
|
||||
# Overall
|
||||
print("\n" + "="*65)
|
||||
print(" OVERALL RESULTS")
|
||||
print("="*65)
|
||||
if all_results:
|
||||
total = len(all_results)
|
||||
wins = sum(1 for r in all_results if r['won'])
|
||||
total_pnl = sum(r['pnl'] for r in all_results)
|
||||
total_staked = sum(r['stake'] for r in all_results)
|
||||
roi = total_pnl / total_staked * 100
|
||||
|
||||
print(f" Total bets: {total}")
|
||||
print(f" Wins: {wins} ({wins/total*100:.1f}%)")
|
||||
print(f" Total staked: {total_staked:.0f}")
|
||||
print(f" PnL: {total_pnl:+.0f}")
|
||||
print(f" ROI: {roi:+.1f}%")
|
||||
print(f" Avg odds: {np.mean([r['odds'] for r in all_results]):.2f}")
|
||||
|
||||
# By class
|
||||
print("\n --- By Bet Type ---")
|
||||
for cls in ['H','A']:
|
||||
cb = [r for r in all_results if r['cls'] == cls]
|
||||
if cb:
|
||||
cw = sum(1 for r in cb if r['won'])
|
||||
cp = sum(r['pnl'] for r in cb)
|
||||
cs = sum(r['stake'] for r in cb)
|
||||
print(f" {cls}: {len(cb)} bets, hit={cw/len(cb)*100:.1f}%, ROI={cp/cs*100:+.1f}%")
|
||||
|
||||
# Cumulative PnL curve
|
||||
print("\n --- Cumulative PnL ---")
|
||||
cum = 0
|
||||
step = max(1, total // 15)
|
||||
for j in range(0, total, step):
|
||||
cum = sum(r['pnl'] for r in all_results[:j+1])
|
||||
print(f" After bet {j+1:4d}: PnL={cum:+.0f}")
|
||||
cum = sum(r['pnl'] for r in all_results)
|
||||
print(f" After bet {total:4d}: PnL={cum:+.0f} (FINAL)")
|
||||
else:
|
||||
print(" No bets placed!")
|
||||
|
||||
# ── Now combine with MODEL for smarter filtering ──
|
||||
print("\n" + "="*65)
|
||||
print(" COMBINED: Rest Rules + Fundamentals Model")
|
||||
print("="*65)
|
||||
|
||||
import pickle, json
|
||||
from pathlib import Path
|
||||
MODELS_DIR = Path("models/v27")
|
||||
|
||||
feat_cols = json.load(open(MODELS_DIR / "v27_feature_cols.json"))
|
||||
ms_models = {}
|
||||
for name in ['xgb','lgb','cb']:
|
||||
p = MODELS_DIR / f"v27_ms_{name}.pkl"
|
||||
if p.exists():
|
||||
with open(p,'rb') as f:
|
||||
ms_models[name] = pickle.load(f)
|
||||
|
||||
if ms_models:
|
||||
test_df = df.iloc[int(n*0.8):].copy()
|
||||
X_test = test_df[feat_cols].values
|
||||
|
||||
# Get model predictions
|
||||
preds = []
|
||||
for name, m in ms_models.items():
|
||||
if name == 'xgb':
|
||||
import xgboost as xgb
|
||||
dm = xgb.DMatrix(X_test, feature_names=feat_cols)
|
||||
preds.append(m.predict(dm))
|
||||
elif name == 'lgb':
|
||||
preds.append(m.predict(X_test))
|
||||
elif name == 'cb':
|
||||
preds.append(m.predict_proba(X_test))
|
||||
model_probs = np.mean(preds, axis=0) # (n, 3)
|
||||
|
||||
# Now apply rest rules + model agreement
|
||||
margin = 1/test_df.odds_ms_h.values + 1/test_df.odds_ms_d.values + 1/test_df.odds_ms_a.values
|
||||
impl = np.column_stack([
|
||||
(1/test_df.odds_ms_h.values)/margin,
|
||||
(1/test_df.odds_ms_d.values)/margin,
|
||||
(1/test_df.odds_ms_a.values)/margin,
|
||||
])
|
||||
|
||||
combo_bets = 0
|
||||
combo_wins = 0
|
||||
combo_pnl = 0
|
||||
|
||||
for j in range(len(test_df)):
|
||||
row = test_df.iloc[j]
|
||||
for hr, ar in [(14,5),(10,5),(7,5),(5,5)]:
|
||||
if pd.isna(row.home_days_rest) or pd.isna(row.away_days_rest):
|
||||
continue
|
||||
if row.home_days_rest <= hr or row.away_days_rest >= ar:
|
||||
continue
|
||||
for cls in [0, 2]:
|
||||
odds_val = [row.odds_ms_h, row.odds_ms_d, row.odds_ms_a][cls]
|
||||
if pd.isna(odds_val) or odds_val < 1.50 or odds_val > 5.0:
|
||||
continue
|
||||
|
||||
model_p = model_probs[j, cls]
|
||||
impl_p = impl[j, cls]
|
||||
|
||||
# DOUBLE FILTER: rest rule + model agrees (model_prob > implied)
|
||||
if model_p <= impl_p:
|
||||
continue # model disagrees, skip
|
||||
edge = model_p - impl_p
|
||||
if edge < 0.03:
|
||||
continue # too small
|
||||
|
||||
won = (row.label_ms == cls)
|
||||
pnl = 10 * (odds_val - 1) if won else -10
|
||||
combo_bets += 1
|
||||
if won:
|
||||
combo_wins += 1
|
||||
combo_pnl += pnl
|
||||
|
||||
if combo_bets > 0:
|
||||
roi = combo_pnl / (combo_bets * 10) * 100
|
||||
print(f" Bets: {combo_bets}")
|
||||
print(f" Wins: {combo_wins} ({combo_wins/combo_bets*100:.1f}%)")
|
||||
print(f" PnL: {combo_pnl:+.0f}")
|
||||
print(f" ROI: {roi:+.1f}%")
|
||||
else:
|
||||
print(" No combined bets triggered")
|
||||
@@ -1,223 +0,0 @@
|
||||
"""
|
||||
Real AI Engine Backtest Script
|
||||
==============================
|
||||
Uses the ACTUAL models (V20/V25 Ensemble) to predict historical matches.
|
||||
|
||||
Usage:
|
||||
python ai-engine/scripts/backtest_real.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from datetime import datetime
|
||||
|
||||
# Add paths
|
||||
AI_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT_DIR = os.path.dirname(AI_DIR)
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
|
||||
# Fix for Windows path issues in scripts
|
||||
if "scripts" in os.path.basename(AI_DIR):
|
||||
ROOT_DIR = os.path.dirname(ROOT_DIR) # One level up if inside scripts folder
|
||||
|
||||
from services.single_match_orchestrator import get_single_match_orchestrator, MatchData
|
||||
|
||||
def get_clean_dsn() -> str:
|
||||
return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db"
|
||||
|
||||
def run_backtest():
|
||||
print("🚀 REAL AI BACKTEST: Sept 13, 2024 - Top Leagues")
|
||||
print("🧠 Engine: V30 Ensemble (V20+V25)")
|
||||
print("="*60)
|
||||
|
||||
# Load Top Leagues
|
||||
leagues_path = os.path.join(ROOT_DIR, "top_leagues.json")
|
||||
try:
|
||||
with open(leagues_path, 'r') as f:
|
||||
top_leagues = json.load(f)
|
||||
league_ids = tuple(str(lid) for lid in top_leagues)
|
||||
print(f"📋 Loaded {len(top_leagues)} top leagues.")
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading top_leagues.json: {e}")
|
||||
return
|
||||
|
||||
# Date Range (Sept 13, 2024)
|
||||
start_dt = datetime(2024, 9, 13, 0, 0, 0)
|
||||
end_dt = datetime(2024, 9, 13, 23, 59, 59)
|
||||
start_ts = int(start_dt.timestamp() * 1000)
|
||||
end_ts = int(end_dt.timestamp() * 1000)
|
||||
|
||||
dsn = get_clean_dsn()
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
# Fetch Matches
|
||||
cur.execute("""
|
||||
SELECT m.id, m.match_name, m.home_team_id, m.away_team_id,
|
||||
m.mst_utc, m.league_id, m.status, m.score_home, m.score_away,
|
||||
t1.name as home_team, t2.name as away_team,
|
||||
l.name as league_name
|
||||
FROM matches m
|
||||
LEFT JOIN teams t1 ON m.home_team_id = t1.id
|
||||
LEFT JOIN teams t2 ON m.away_team_id = t2.id
|
||||
LEFT JOIN leagues l ON m.league_id = l.id
|
||||
WHERE m.mst_utc BETWEEN %s AND %s
|
||||
AND m.league_id IN %s
|
||||
AND m.status = 'FT'
|
||||
ORDER BY m.mst_utc ASC
|
||||
LIMIT 20 -- Limit to 20 matches to avoid running for hours on a single backtest
|
||||
""", (start_ts, end_ts, league_ids))
|
||||
|
||||
rows = cur.fetchall()
|
||||
print(f"📊 Found {len(rows)} finished matches. Starting AI Analysis...")
|
||||
|
||||
if not rows:
|
||||
print("⚠️ No matches found for this date.")
|
||||
cur.close()
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# Initialize AI Engine
|
||||
try:
|
||||
orchestrator = get_single_match_orchestrator()
|
||||
print("✅ AI Engine (SingleMatchOrchestrator) Loaded.")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to load AI Engine: {e}")
|
||||
print("💡 Make sure models are trained/present in ai-engine/models/")
|
||||
cur.close()
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# ─── Backtest Loop ───
|
||||
total_matches_analyzed = 0
|
||||
bets_skipped = 0
|
||||
bets_played = 0
|
||||
bets_won = 0
|
||||
total_profit = 0.0
|
||||
|
||||
# Thresholds matching the NEW Skip Logic
|
||||
MIN_CONF = 45.0
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
match_id = str(row['id'])
|
||||
home_team = row['home_team']
|
||||
away_team = row['away_team']
|
||||
home_score = row['score_home']
|
||||
away_score = row['score_away']
|
||||
|
||||
print(f"\n[{i+1}/{len(rows)}] Analyzing: {home_team} vs {away_team} ...")
|
||||
|
||||
try:
|
||||
# 1. AI PREDICTION (Actual Model Call)
|
||||
prediction = orchestrator.analyze_match(match_id)
|
||||
|
||||
if not prediction:
|
||||
print(f" ⚠️ AI returned no prediction.")
|
||||
continue
|
||||
|
||||
total_matches_analyzed += 1
|
||||
|
||||
# 2. Extract Main Pick
|
||||
main_pick = prediction.get("main_pick") or {}
|
||||
pick_name = main_pick.get("pick")
|
||||
confidence = main_pick.get("confidence", 0)
|
||||
odds = main_pick.get("odds", 0)
|
||||
|
||||
if not pick_name or not confidence:
|
||||
print(f" ⚠️ No main pick found in prediction.")
|
||||
continue
|
||||
|
||||
print(f" 🤖 Pick: {pick_name} | Conf: {confidence}% | Odds: {odds}")
|
||||
|
||||
# 3. Apply Skip Logic (New Backtest Logic)
|
||||
if confidence < MIN_CONF:
|
||||
print(f" 🚫 SKIPPED (Confidence {confidence}% < {MIN_CONF}%)")
|
||||
bets_skipped += 1
|
||||
continue
|
||||
|
||||
if odds > 0:
|
||||
implied_prob = 1.0 / odds
|
||||
my_prob = confidence / 100.0
|
||||
if my_prob - implied_prob < -0.03: # Negative edge
|
||||
print(f" 🚫 SKIPPED (Negative Edge)")
|
||||
bets_skipped += 1
|
||||
continue
|
||||
|
||||
# 4. Bet Played
|
||||
bets_played += 1
|
||||
print(f" 🎲 BET PLAYED: {pick_name} @ {odds}")
|
||||
|
||||
# 5. Resolve Bet
|
||||
won = False
|
||||
# Basic resolution logic (Need to parse pick_name like "1", "X", "2", "2.5 Üst", etc.)
|
||||
pick_clean = str(pick_name).upper()
|
||||
|
||||
# MS
|
||||
if pick_clean in ["1", "MS 1"] and home_score > away_score: won = True
|
||||
elif pick_clean in ["X", "MS X"] and home_score == away_score: won = True
|
||||
elif pick_clean in ["2", "MS 2"] and away_score > home_score: won = True
|
||||
|
||||
# OU25
|
||||
elif "ÜST" in pick_clean or "OVER" in pick_clean:
|
||||
if (home_score + away_score) > 2.5: won = True
|
||||
elif "ALT" in pick_clean or "UNDER" in pick_clean:
|
||||
if (home_score + away_score) < 2.5: won = True
|
||||
|
||||
# BTTS
|
||||
elif "VAR" in pick_clean and home_score > 0 and away_score > 0: won = True
|
||||
elif "YOK" in pick_clean and (home_score == 0 or away_score == 0): won = True
|
||||
|
||||
if won:
|
||||
bets_won += 1
|
||||
profit = odds - 1.0
|
||||
print(f" ✅ WON! (+{profit:.2f} units)")
|
||||
else:
|
||||
profit = -1.0
|
||||
print(f" ❌ LOST! (-1.00 units)")
|
||||
|
||||
total_profit += profit
|
||||
|
||||
except Exception as e:
|
||||
print(f" 💥 Error during analysis: {e}")
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# ─── FINAL REPORT ───
|
||||
print("\n" + "="*60)
|
||||
print("📈 REAL AI BACKTEST RESULTS")
|
||||
print(f"🕒 Time taken: {elapsed:.1f} seconds")
|
||||
print("="*60)
|
||||
print(f"📊 Matches Analyzed: {total_matches_analyzed}")
|
||||
print(f"🚫 Bets SKIPPED: {bets_skipped}")
|
||||
print(f"✅ Bets PLAYED: {bets_played}")
|
||||
|
||||
if bets_played > 0:
|
||||
win_rate = (bets_won / bets_played) * 100
|
||||
roi = (total_profit / bets_played) * 100
|
||||
yield_val = total_profit # Net Units
|
||||
|
||||
print(f"🏆 Bets Won: {bets_won}")
|
||||
print(f"💀 Bets Lost: {bets_played - bets_won}")
|
||||
print("-" * 40)
|
||||
print(f" Win Rate: {win_rate:.2f}%")
|
||||
print(f"💰 Total Profit (Units): {total_profit:.2f}")
|
||||
print(f"📊 ROI: {roi:.2f}%")
|
||||
|
||||
if roi > 0:
|
||||
print("🟢 STRATEGY IS PROFITABLE!")
|
||||
else:
|
||||
print("🔴 STRATEGY IS LOSING")
|
||||
else:
|
||||
print("⚠️ No bets were played. All were skipped or failed.")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_backtest()
|
||||
@@ -1,231 +0,0 @@
|
||||
"""
|
||||
Backtest ROI Engine
|
||||
===================
|
||||
Simulates the NEW "Skip Logic" on historical predictions.
|
||||
Answers: "What if we only played the bets the model was confident about?"
|
||||
|
||||
Usage:
|
||||
python ai-engine/scripts/backtest_roi.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from typing import Dict, List, Any
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env from project root (2 levels up from this script)
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
load_dotenv(os.path.join(project_root, ".env"))
|
||||
|
||||
def get_clean_dsn() -> str:
|
||||
"""Return a psycopg2-compatible DSN from DATABASE_URL."""
|
||||
# HARDCODED FOR BACKTEST (Bypassing dotenv issues)
|
||||
return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db"
|
||||
|
||||
# ─── Configuration (Matching the NEW BetRecommender Logic) ─────────
|
||||
# Minimum confidence to even consider a bet (Hard Gate)
|
||||
MIN_CONF_THRESHOLDS = {
|
||||
"MS": 45.0,
|
||||
"DC": 40.0,
|
||||
"OU15": 50.0,
|
||||
"OU25": 45.0,
|
||||
"OU35": 45.0,
|
||||
"BTTS": 45.0,
|
||||
"HT": 40.0,
|
||||
}
|
||||
|
||||
def get_market_type_from_key(key: str) -> str:
|
||||
"""Map prediction keys to market types for thresholding."""
|
||||
if key.startswith("ms_") or key in ["1", "X", "2"]: return "MS"
|
||||
if key.startswith("dc_") or key in ["1X", "X2", "12"]: return "DC"
|
||||
if key.startswith("ou15_") or key.startswith("1.5"): return "OU15"
|
||||
if key.startswith("ou25_") or key.startswith("2.5"): return "OU25"
|
||||
if key.startswith("ou35_") or key.startswith("3.5"): return "OU35"
|
||||
if key.startswith("btts_") or key in ["Var", "Yok"]: return "BTTS"
|
||||
if key.startswith("ht_") or key.startswith("İY"): return "HT"
|
||||
return "MS"
|
||||
|
||||
def simulate_backtest():
|
||||
print("🚀 Starting Backtest with NEW 'Skip Logic'...")
|
||||
print("="*60)
|
||||
|
||||
dsn = get_clean_dsn()
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
# 1. Fetch PREDICTIONS that have a confidence score
|
||||
# We limit to last 1000 finished matches to keep it fast but representative
|
||||
cur.execute("""
|
||||
SELECT p.match_id, p.prediction_json,
|
||||
m.score_home, m.score_away, m.status
|
||||
FROM predictions p
|
||||
JOIN matches m ON p.match_id = m.id
|
||||
WHERE m.status = 'FT'
|
||||
AND p.prediction_json IS NOT NULL
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT 2000
|
||||
""")
|
||||
predictions = cur.fetchall()
|
||||
|
||||
print(f"📊 Loaded {len(predictions)} historical predictions.")
|
||||
|
||||
total_bets = 0
|
||||
winning_bets = 0
|
||||
skipped_bets = 0
|
||||
total_profit = 0.0 # Assuming unit stake of 1.0
|
||||
|
||||
# 2. Process each prediction
|
||||
for pred_row in predictions:
|
||||
match_id = pred_row['match_id']
|
||||
data = pred_row['prediction_json']
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
|
||||
# Real result
|
||||
home_score = pred_row['score_home'] or 0
|
||||
away_score = pred_row['score_away'] or 0
|
||||
total_goals = home_score + away_score
|
||||
|
||||
# Extract prediction details from the JSON structure
|
||||
# The structure varies, but usually contains 'main_pick', 'bet_summary', or 'market_board'
|
||||
|
||||
# Try to get the main pick recommendation
|
||||
main_pick = None
|
||||
main_pick_conf = 0.0
|
||||
main_pick_odds = 0.0
|
||||
|
||||
# Navigate the V20+ JSON structure
|
||||
market_board = data.get("market_board", {})
|
||||
|
||||
# Check Main Pick
|
||||
if "main_pick" in data:
|
||||
mp = data["main_pick"]
|
||||
if isinstance(mp, dict):
|
||||
main_pick = mp.get("pick")
|
||||
main_pick_conf = mp.get("confidence", 0.0)
|
||||
main_pick_odds = mp.get("odds", 0.0)
|
||||
|
||||
# If no main pick, try bet_summary
|
||||
if not main_pick and "bet_summary" in data:
|
||||
summary = data["bet_summary"]
|
||||
if isinstance(summary, list) and len(summary) > 0:
|
||||
# Take the highest confidence one
|
||||
best = max(summary, key=lambda x: x.get("confidence", 0))
|
||||
main_pick = best.get("pick")
|
||||
main_pick_conf = best.get("confidence", 0.0)
|
||||
main_pick_odds = best.get("odds", 0.0)
|
||||
|
||||
if not main_pick or not main_pick_conf:
|
||||
continue
|
||||
|
||||
# ─── NEW LOGIC: APPLY FILTERS ───
|
||||
# 1. Determine Market Type
|
||||
# Simple heuristic based on pick string
|
||||
pick_str = str(main_pick).upper()
|
||||
market_type = "MS"
|
||||
if "1X" in pick_str or "X2" in pick_str or "12" in pick_str: market_type = "DC"
|
||||
elif "ÜST" in pick_str or "ALT" in pick_str or "OVER" in pick_str or "UNDER" in pick_str:
|
||||
if "1.5" in pick_str: market_type = "OU15"
|
||||
elif "3.5" in pick_str: market_type = "OU35"
|
||||
else: market_type = "OU25"
|
||||
elif "VAR" in pick_str or "YOK" in pick_str or "BTTS" in pick_str: market_type = "BTTS"
|
||||
|
||||
threshold = MIN_CONF_THRESHOLDS.get(market_type, 45.0)
|
||||
|
||||
# 2. Check Confidence Gate
|
||||
if main_pick_conf < threshold:
|
||||
skipped_bets += 1
|
||||
continue
|
||||
|
||||
# 3. Check Value Gate (Edge)
|
||||
if main_pick_odds > 0:
|
||||
implied_prob = 1.0 / main_pick_odds
|
||||
my_prob = main_pick_conf / 100.0
|
||||
edge = my_prob - implied_prob
|
||||
if edge < -0.03: # Negative value
|
||||
skipped_bets += 1
|
||||
continue
|
||||
|
||||
# ─── BET IS PLAYED ───
|
||||
total_bets += 1
|
||||
|
||||
# Determine if WON
|
||||
is_won = False
|
||||
|
||||
# Resolve MS (1, X, 2)
|
||||
if market_type == "MS":
|
||||
if main_pick == "1" and home_score > away_score: is_won = True
|
||||
elif main_pick == "X" and home_score == away_score: is_won = True
|
||||
elif main_pick == "2" and away_score > home_score: is_won = True
|
||||
elif main_pick == "MS 1" and home_score > away_score: is_won = True
|
||||
elif main_pick == "MS X" and home_score == away_score: is_won = True
|
||||
elif main_pick == "MS 2" and away_score > home_score: is_won = True
|
||||
|
||||
# Resolve OU (Over/Under)
|
||||
elif market_type.startswith("OU"):
|
||||
line = 2.5
|
||||
if "1.5" in pick_str: line = 1.5
|
||||
elif "3.5" in pick_str: line = 3.5
|
||||
|
||||
is_over = total_goals > line
|
||||
is_under = total_goals < line # Simplification (usually line is X.5 so no draw)
|
||||
|
||||
if "ÜST" in pick_str or "OVER" in pick_str:
|
||||
if is_over: is_won = True
|
||||
elif "ALT" in pick_str or "UNDER" in pick_str:
|
||||
if is_under: is_won = True
|
||||
|
||||
# Resolve BTTS
|
||||
elif market_type == "BTTS":
|
||||
if home_score > 0 and away_score > 0:
|
||||
if "VAR" in pick_str: is_won = True
|
||||
else:
|
||||
if "YOK" in pick_str: is_won = True
|
||||
|
||||
# Resolve DC (Double Chance) - Simplified
|
||||
elif market_type == "DC":
|
||||
if "1X" in pick_str and (home_score >= away_score): is_won = True
|
||||
elif "X2" in pick_str and (away_score >= home_score): is_won = True
|
||||
elif "12" in pick_str and (home_score != away_score): is_won = True
|
||||
|
||||
if is_won:
|
||||
winning_bets += 1
|
||||
profit = main_pick_odds - 1.0
|
||||
total_profit += profit
|
||||
else:
|
||||
total_profit -= 1.0
|
||||
|
||||
# ─── REPORT ───
|
||||
print("\n" + "="*60)
|
||||
print("📈 BACKTEST RESULTS (With NEW Skip Logic)")
|
||||
print("="*60)
|
||||
print(f"Total Historical Matches Analyzed: {len(predictions)}")
|
||||
print(f"🚫 Bets SKIPPED (Low Conf/Bad Value): {skipped_bets}")
|
||||
print(f"✅ Bets PLAYED: {total_bets}")
|
||||
|
||||
if total_bets > 0:
|
||||
win_rate = (winning_bets / total_bets) * 100
|
||||
roi = (total_profit / total_bets) * 100
|
||||
|
||||
print(f"🏆 Winning Bets: {winning_bets}")
|
||||
print(f"💀 Losing Bets: {total_bets - winning_bets}")
|
||||
print("-" * 40)
|
||||
print(f" Win Rate: {win_rate:.2f}%")
|
||||
print(f"💰 Total Profit (Units): {total_profit:.2f}")
|
||||
print(f"📊 ROI: {roi:.2f}%")
|
||||
|
||||
if roi > 0:
|
||||
print("🟢 STRATEGY IS PROFITABLE!")
|
||||
else:
|
||||
print("🔴 STRATEGY IS LOSING (Adjust thresholds!)")
|
||||
else:
|
||||
print("⚠️ No bets were played. Thresholds might be too high.")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
simulate_backtest()
|
||||
@@ -1,164 +0,0 @@
|
||||
"""
|
||||
SNIPER Backtest
|
||||
===============
|
||||
Sadece en yüksek güvenilirlik ve değere sahip bahisleri oynar.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from datetime import datetime
|
||||
|
||||
AI_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT_DIR = os.path.dirname(AI_DIR)
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
if "scripts" in os.path.basename(AI_DIR):
|
||||
ROOT_DIR = os.path.dirname(ROOT_DIR)
|
||||
|
||||
from services.single_match_orchestrator import get_single_match_orchestrator
|
||||
|
||||
def get_clean_dsn() -> str:
|
||||
return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db"
|
||||
|
||||
MATCH_IDS = [
|
||||
"v2ljcst50nk37x04xwimpi50", "7gz0bhb5yvdssazl3y5946kno", "7ftj7kbu4rzpewxravf3luuc4",
|
||||
"7f1z4e8ch1dm5q677644cky6s", "7ffq3aq3so22iymfdzch63nys", "rrkmeuymz7gzvoz8mplikzdg",
|
||||
"7hegc9covicy699bxsi81xkb8", "7gl7rpr1hjayk3e5ut0gr613o", "7g7d86i3738287xfvyfeffcwk",
|
||||
"7hs4boe4hv80muawocevvx2j8", "7ijhsloieg4t9yp5cxp0duln8", "7ixaiiptli5ek32kuybuni4gk",
|
||||
"7i5sfh41cjpwg4l972dm487x0", "eo7g4wunxxxr8uv45q8p5x638", "7dinds2937w4645wva2rddlas",
|
||||
"7b5ukdhvqh62wtndeqfg01ixg", "7bjptsj24gndoydn7n0202g44", "7cqxf3vo58ewrwmoom5xiyexg",
|
||||
"7bxjl9h2hnf165rlp3o1vfztg", "7eo8zrez08c342rqsezpvq39w", "7as1muhs98vdarlhsean4bspg",
|
||||
"7dwhj8cfxv6v6bzxpu5e3h05w", "7d4vq4417ps84yjzh95bnvvv8", "7ea9z501jgp9kxw3gay4myrkk",
|
||||
"7cd3401itlty6ded7c1wct0yc", "ebgpz9mcije2snv986n6587pw", "i7ar1dkhvcwpxmkyks65ib6c",
|
||||
"lyek7tyy6qk2xjs9vblucnx0", "hdn9qtyn3ysjwbc3i2trantg", "3y2bnssfqlajosiz2gpkn6xhw",
|
||||
"40pehd14s9djjtycujavbex3o", "3xnbfjznzmnwml20akbgnis5w", "2eovi2rcc2l4ha7fpb2w7e1hw",
|
||||
"2bwuikdjyyuithhru8ka8o00k", "2d3pcd76ya9ihi9yotxc553is", "1e9it04z4epy2etdxsffe7m6s",
|
||||
"7af49jgo4iulv1k8cplj9smj8", "5k3vrz619hdu9nx4rnx6uim1g", "amjppgpetnyr0iisi241kgkyc",
|
||||
"coqrhq09kxd16iejvgtzj3mz8", "d8ysan1qdctmkvjaz2adw7aqc", "9ttciz0gtb0z09ev1q5fe0ro4",
|
||||
"9u720o37yaddqu1w6hlszpnh0", "7ijezdjp8t0rjti91ac63hyxg", "72gvdvztbb3dn79jidzzxzcb8",
|
||||
"6uof1v2s6vrpieeml2bwo9tlg", "91dd8ia3m0bxoqzjgyo3ptsk", "3tj1nt3udsbvb9soqn2cs6gpg",
|
||||
"1br5g88o5idtjxka1fr6zg4k4", "akuesquthbmxlzckvnqmgles4"
|
||||
]
|
||||
|
||||
def run_sniper_backtest():
|
||||
print("🎯 SNIPER BACKTEST: SADECE NET OLANLAR")
|
||||
print("="*60)
|
||||
|
||||
dsn = get_clean_dsn()
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
placeholders = ','.join(['%s'] * len(MATCH_IDS))
|
||||
cur.execute(f"""
|
||||
SELECT m.id, m.match_name, m.home_team_id, m.away_team_id,
|
||||
m.score_home, m.score_away,
|
||||
t1.name as home_team, t2.name as away_team,
|
||||
l.name as league_name
|
||||
FROM matches m
|
||||
LEFT JOIN teams t1 ON m.home_team_id = t1.id
|
||||
LEFT JOIN teams t2 ON m.away_team_id = t2.id
|
||||
LEFT JOIN leagues l ON m.league_id = l.id
|
||||
WHERE m.id IN ({placeholders}) AND m.status = 'FT'
|
||||
""", MATCH_IDS)
|
||||
|
||||
rows = cur.fetchall()
|
||||
print(f"📊 Analiz edilecek {len(rows)} maç var.\n")
|
||||
|
||||
try:
|
||||
orchestrator = get_single_match_orchestrator()
|
||||
except Exception as e:
|
||||
print(f"❌ AI Hatası: {e}")
|
||||
return
|
||||
|
||||
total_bet = 0
|
||||
total_won = 0
|
||||
total_profit = 0.0
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
match_id = str(row['id'])
|
||||
home = row['home_team'] or "?"
|
||||
away = row['away_team'] or "?"
|
||||
h_score = row['score_home'] or 0
|
||||
a_score = row['score_away'] or 0
|
||||
|
||||
print(f"[{i+1}/{len(rows)}] {home} vs {away} ... ", end="", flush=True)
|
||||
|
||||
try:
|
||||
pred = orchestrator.analyze_match(match_id)
|
||||
if not pred:
|
||||
print("⚠️ Veri Yok")
|
||||
continue
|
||||
|
||||
pick_data = pred.get("expert_recommendation", {}).get("main_pick") or pred.get("main_pick", {})
|
||||
pick = pick_data.get("pick") or pick_data.get("market_type")
|
||||
conf = pick_data.get("confidence", 0)
|
||||
odds = pick_data.get("odds", 0)
|
||||
|
||||
# SNIPER FİLTRELERİ
|
||||
if conf < 75:
|
||||
print(f"🚫 PASS (Conf: {conf:.0f}%)")
|
||||
continue
|
||||
if odds < 1.35:
|
||||
print(f"🚫 PASS (Odds: {odds:.2f} çok düşük)")
|
||||
continue
|
||||
|
||||
# Value Control
|
||||
implied = 1.0 / odds
|
||||
if (conf/100) < implied:
|
||||
print(f"🚫 PASS (Negatif Value)")
|
||||
continue
|
||||
|
||||
# OYNA
|
||||
total_bet += 1
|
||||
won = False
|
||||
pick_clean = str(pick).upper()
|
||||
|
||||
if pick_clean in ["1", "MS 1"] and h_score > a_score: won = True
|
||||
elif pick_clean in ["X", "MS X"] and h_score == a_score: won = True
|
||||
elif pick_clean in ["2", "MS 2"] and a_score > h_score: won = True
|
||||
elif "ÜST" in pick_clean or "OVER" in pick_clean:
|
||||
line = 2.5
|
||||
if "1.5" in pick_clean: line = 1.5
|
||||
elif "3.5" in pick_clean: line = 3.5
|
||||
if (h_score + a_score) > line: won = True
|
||||
elif "ALT" in pick_clean or "UNDER" in pick_clean:
|
||||
line = 2.5
|
||||
if "1.5" in pick_clean: line = 1.5
|
||||
elif "3.5" in pick_clean: line = 3.5
|
||||
if (h_score + a_score) < line: won = True
|
||||
elif "VAR" in pick_clean and h_score > 0 and a_score > 0: won = True
|
||||
elif "YOK" in pick_clean and (h_score == 0 or a_score == 0): won = True
|
||||
|
||||
if won:
|
||||
total_won += 1
|
||||
profit = odds - 1.0
|
||||
total_profit += profit
|
||||
print(f"✅ WON! (+{profit:.2f})")
|
||||
else:
|
||||
total_profit -= 1.0
|
||||
print(f"❌ LOST! ({pick} @ {odds:.2f})")
|
||||
|
||||
except Exception as e:
|
||||
print(f"💥 Hata: {e}")
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("🎯 SNIPER SONUÇLARI")
|
||||
print("="*60)
|
||||
print(f"Oynanan: {total_bet}")
|
||||
print(f"Kazanılan: {total_won}")
|
||||
print(f"Kazanma Oranı: %{(total_won/total_bet)*100:.1f}" if total_bet > 0 else "Kazanma Oranı: N/A")
|
||||
print(f"Toplam Kâr: {total_profit:.2f} Units")
|
||||
|
||||
if total_profit > 0:
|
||||
print("🟢 PARA KAZANDIK!")
|
||||
else:
|
||||
print("🔴 PARA KAYBETTİK!")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_sniper_backtest()
|
||||
@@ -1,162 +0,0 @@
|
||||
"""
|
||||
Strict Sniper Backtest (Calibrated)
|
||||
===================================
|
||||
Sadece Güven > %75 ve Oran > 1.30 olan bahisleri oynar.
|
||||
Modelin şişirilmiş özgüvenini elemek için yapıldı.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
AI_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT_DIR = os.path.dirname(AI_DIR)
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
if "scripts" in os.path.basename(AI_DIR):
|
||||
ROOT_DIR = os.path.dirname(ROOT_DIR)
|
||||
|
||||
from services.single_match_orchestrator import get_single_match_orchestrator
|
||||
|
||||
def get_clean_dsn() -> str:
|
||||
return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db"
|
||||
|
||||
def run_strict_backtest():
|
||||
print("🎯 STRICT SNIPER BACKTEST (Conf > 75%)")
|
||||
print("="*60)
|
||||
|
||||
leagues_path = os.path.join(ROOT_DIR, "top_leagues.json")
|
||||
with open(leagues_path, 'r') as f:
|
||||
top_leagues = json.load(f)
|
||||
league_ids = tuple(str(lid) for lid in top_leagues)
|
||||
|
||||
dsn = get_clean_dsn()
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
cur.execute("""
|
||||
SELECT m.id, m.match_name, m.home_team_id, m.away_team_id,
|
||||
m.score_home, m.score_away,
|
||||
t1.name as home_team, t2.name as away_team
|
||||
FROM matches m
|
||||
LEFT JOIN teams t1 ON m.home_team_id = t1.id
|
||||
LEFT JOIN teams t2 ON m.away_team_id = t2.id
|
||||
WHERE m.league_id IN %s
|
||||
AND m.status = 'FT'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM odd_categories oc WHERE oc.match_id = m.id)
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT 500
|
||||
""", (league_ids,))
|
||||
|
||||
rows = cur.fetchall()
|
||||
print(f"📊 {len(rows)} maç taranıyor. Sadece NET OLANLAR oynanacak...\n")
|
||||
|
||||
try: orchestrator = get_single_match_orchestrator()
|
||||
except Exception as e:
|
||||
print(f"❌ AI Hatası: {e}")
|
||||
return
|
||||
|
||||
total_bet = 0
|
||||
total_won = 0
|
||||
total_profit = 0.0
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
match_id = str(row['id'])
|
||||
home = row['home_team'] or "?"
|
||||
away = row['away_team'] or "?"
|
||||
h_score = row['score_home'] or 0
|
||||
a_score = row['score_away'] or 0
|
||||
|
||||
try:
|
||||
pred = orchestrator.analyze_match(match_id)
|
||||
if not pred: continue
|
||||
|
||||
# Check all picks for a HIGH CONFIDENCE bet
|
||||
candidates = []
|
||||
if pred.get("expert_recommendation"):
|
||||
rec = pred["expert_recommendation"]
|
||||
if rec.get("main_pick"): candidates.append(rec["main_pick"])
|
||||
if rec.get("value_picks"): candidates.extend(rec["value_picks"])
|
||||
elif pred.get("main_pick"):
|
||||
candidates.append(pred["main_pick"])
|
||||
|
||||
best_bet = None
|
||||
for c in candidates:
|
||||
if not c: continue
|
||||
# Access attributes safely (Dict or Object)
|
||||
conf = c.get("confidence", 0) if isinstance(c, dict) else getattr(c, 'confidence', 0)
|
||||
odds = c.get("odds", 0) if isinstance(c, dict) else getattr(c, 'odds', 0)
|
||||
pick = c.get("pick", "") if isinstance(c, dict) else getattr(c, 'pick', "")
|
||||
|
||||
# STRICT CRITERIA
|
||||
if conf >= 75.0 and odds >= 1.30:
|
||||
# Check Value (Edge)
|
||||
implied = 1.0 / odds
|
||||
edge = ((conf/100) - implied) * 100
|
||||
if edge > -5.0: # Tolerant edge
|
||||
if best_bet is None or (conf > (best_bet.get("confidence", 0) if isinstance(best_bet, dict) else getattr(best_bet, 'confidence', 0))):
|
||||
best_bet = c
|
||||
|
||||
if best_bet:
|
||||
pick = str(best_bet.get("pick") if isinstance(best_bet, dict) else getattr(best_bet, 'pick', "")).upper()
|
||||
conf = best_bet.get("confidence", 0) if isinstance(best_bet, dict) else getattr(best_bet, 'confidence', 0)
|
||||
odds = best_bet.get("odds", 0) if isinstance(best_bet, dict) else getattr(best_bet, 'odds', 0)
|
||||
|
||||
# Resolution
|
||||
won = False
|
||||
if pick in ["1", "MS 1"] and h_score > a_score: won = True
|
||||
elif pick in ["X", "MS X"] and h_score == a_score: won = True
|
||||
elif pick in ["2", "MS 2"] and a_score > h_score: won = True
|
||||
elif pick in ["1X", "X2"]:
|
||||
if "1X" in pick and h_score >= a_score: won = True
|
||||
elif "X2" in pick and a_score >= h_score: won = True
|
||||
elif "ÜST" in pick or "OVER" in pick:
|
||||
line = 2.5
|
||||
if "1.5" in pick: line = 1.5
|
||||
elif "3.5" in pick: line = 3.5
|
||||
if (h_score + a_score) > line: won = True
|
||||
elif "ALT" in pick or "UNDER" in pick:
|
||||
line = 2.5
|
||||
if "1.5" in pick: line = 1.5
|
||||
elif "3.5" in pick: line = 3.5
|
||||
if (h_score + a_score) < line: won = True
|
||||
elif "VAR" in pick and h_score > 0 and a_score > 0: won = True
|
||||
elif "YOK" in pick and (h_score == 0 or a_score == 0): won = True
|
||||
|
||||
total_bet += 1
|
||||
if won:
|
||||
total_won += 1
|
||||
profit = odds - 1.0
|
||||
total_profit += profit
|
||||
print(f"[{i+1}] ✅ {home} vs {away} | {pick} ({conf:.0f}%) -> WON (+{profit:.2f})")
|
||||
else:
|
||||
total_profit -= 1.0
|
||||
print(f"[{i+1}] ❌ {home} vs {away} | {pick} ({conf:.0f}%) -> LOST")
|
||||
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("🎯 STRICT SNIPER SONUÇLARI")
|
||||
print("="*60)
|
||||
print(f"Oynanan Bahis: {total_bet}")
|
||||
print(f"Kazanılan: {total_won}")
|
||||
|
||||
if total_bet > 0:
|
||||
win_rate = (total_won / total_bet) * 100
|
||||
roi = (total_profit / total_bet) * 100
|
||||
print(f"Kazanma Oranı: %{win_rate:.2f}")
|
||||
print(f"Toplam Kâr: {total_profit:.2f} Units")
|
||||
if total_profit > 0: print("🟢 PARA KAZANDIK!")
|
||||
else: print("🔴 PARA KAYBETTİK!")
|
||||
else:
|
||||
print("⚠️ Yeteri kadar NET maç bulunamadı.")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_strict_backtest()
|
||||
@@ -1,94 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
|
||||
AI_ENGINE_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(AI_ENGINE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(AI_ENGINE_DIR))
|
||||
|
||||
from services.single_match_orchestrator import SingleMatchOrchestrator
|
||||
|
||||
|
||||
def _resolve_dsn() -> str:
|
||||
env_path = AI_ENGINE_DIR / ".env"
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("DATABASE_URL="):
|
||||
return line.split("=", 1)[1].strip().split("?schema=")[0]
|
||||
raise SystemExit("DATABASE_URL not found in ai-engine/.env")
|
||||
|
||||
|
||||
def _fetch_matches(dsn: str, limit: int = 60) -> list[str]:
|
||||
query = """
|
||||
SELECT m.id
|
||||
FROM matches m
|
||||
WHERE m.status = 'FT'
|
||||
AND m.sport = 'football'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.score_away IS NOT NULL
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
with psycopg2.connect(dsn) as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(query, (limit,))
|
||||
return [str(row["id"]) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def _score_prediction(package: dict) -> dict[str, float]:
|
||||
rows = package.get("bet_summary", []) or []
|
||||
playable = [row for row in rows if row.get("playable")]
|
||||
return {
|
||||
"playable_count": float(len(playable)),
|
||||
"avg_edge": round(
|
||||
sum(float(row.get("ev_edge", 0.0)) for row in playable) / len(playable),
|
||||
4,
|
||||
)
|
||||
if playable
|
||||
else 0.0,
|
||||
"avg_confidence": round(
|
||||
sum(float(row.get("calibrated_confidence", 0.0)) for row in playable)
|
||||
/ len(playable),
|
||||
2,
|
||||
)
|
||||
if playable
|
||||
else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
dsn = _resolve_dsn()
|
||||
match_ids = _fetch_matches(dsn)
|
||||
orchestrator = SingleMatchOrchestrator()
|
||||
|
||||
results: list[dict[str, object]] = []
|
||||
for match_id in match_ids:
|
||||
orchestrator.engine_mode = "v25"
|
||||
v25 = orchestrator.analyze_match(match_id)
|
||||
orchestrator.engine_mode = "v26"
|
||||
v26 = orchestrator.analyze_match(match_id)
|
||||
if not v25 or not v26:
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"match_id": match_id,
|
||||
"v25": _score_prediction(v25),
|
||||
"v26": _score_prediction(v26),
|
||||
"v25_main": (v25.get("main_pick") or {}).get("pick"),
|
||||
"v26_main": (v26.get("main_pick") or {}).get("pick"),
|
||||
}
|
||||
)
|
||||
|
||||
out_path = AI_ENGINE_DIR / "reports" / "backtest_v26_shadow.json"
|
||||
out_path.write_text(json.dumps(results, indent=2), encoding="utf-8")
|
||||
print(f"[OK] Shadow backtest summary written to {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,505 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
|
||||
AI_ENGINE_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(AI_ENGINE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(AI_ENGINE_DIR))
|
||||
|
||||
from services.single_match_orchestrator import SingleMatchOrchestrator
|
||||
|
||||
|
||||
STRATEGIES = ("v25_aggressive", "v26_surprise", "v26_aggressive", "v26_main_htft")
|
||||
REVERSAL_LABELS = ("1/2", "2/1", "X/1", "X/2")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchContext:
|
||||
match_id: str
|
||||
match_date_ms: int
|
||||
league: str
|
||||
home_team: str
|
||||
away_team: str
|
||||
final_home: int
|
||||
final_away: int
|
||||
ht_home: Optional[int]
|
||||
ht_away: Optional[int]
|
||||
|
||||
@property
|
||||
def match_name(self) -> str:
|
||||
return f"{self.home_team} vs {self.away_team}"
|
||||
|
||||
@property
|
||||
def final_score(self) -> str:
|
||||
return f"{self.final_home}-{self.final_away}"
|
||||
|
||||
@property
|
||||
def ht_score(self) -> str:
|
||||
if self.ht_home is None or self.ht_away is None:
|
||||
return "-"
|
||||
return f"{self.ht_home}-{self.ht_away}"
|
||||
|
||||
|
||||
def _resolve_dsn() -> str:
|
||||
env_path = AI_ENGINE_DIR / ".env"
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("DATABASE_URL="):
|
||||
return line.split("=", 1)[1].strip().split("?schema=")[0]
|
||||
raise SystemExit("DATABASE_URL not found in ai-engine/.env")
|
||||
|
||||
|
||||
def _fetch_matches(dsn: str, limit: int) -> list[MatchContext]:
|
||||
query = """
|
||||
SELECT
|
||||
m.id,
|
||||
m.mst_utc,
|
||||
COALESCE(l.name, 'Unknown League') AS league,
|
||||
COALESCE(ht.name, 'Home') AS home_team,
|
||||
COALESCE(at.name, 'Away') AS away_team,
|
||||
COALESCE(m.score_home, 0) AS score_home,
|
||||
COALESCE(m.score_away, 0) AS score_away,
|
||||
m.ht_score_home,
|
||||
m.ht_score_away
|
||||
FROM matches m
|
||||
LEFT JOIN leagues l ON l.id = m.league_id
|
||||
LEFT JOIN teams ht ON ht.id = m.home_team_id
|
||||
LEFT JOIN teams at ON at.id = m.away_team_id
|
||||
WHERE m.status = 'FT'
|
||||
AND m.sport = 'football'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.score_away IS NOT NULL
|
||||
AND m.ht_score_home IS NOT NULL
|
||||
AND m.ht_score_away IS NOT NULL
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
with psycopg2.connect(dsn) as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(query, (limit,))
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
MatchContext(
|
||||
match_id=str(row["id"]),
|
||||
match_date_ms=int(row["mst_utc"] or 0),
|
||||
league=str(row["league"] or "Unknown League"),
|
||||
home_team=str(row["home_team"] or "Home"),
|
||||
away_team=str(row["away_team"] or "Away"),
|
||||
final_home=int(row["score_home"] or 0),
|
||||
final_away=int(row["score_away"] or 0),
|
||||
ht_home=int(row["ht_score_home"]) if row.get("ht_score_home") is not None else None,
|
||||
ht_away=int(row["ht_score_away"]) if row.get("ht_score_away") is not None else None,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _safe_float(value: Any) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _outcome_symbol(home: int, away: int) -> str:
|
||||
if home > away:
|
||||
return "1"
|
||||
if home < away:
|
||||
return "2"
|
||||
return "X"
|
||||
|
||||
|
||||
def _resolve_htft(pick: str, context: MatchContext) -> Dict[str, Any]:
|
||||
if not pick or "/" not in str(pick):
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "htft_pick_invalid"}
|
||||
actual = f"{_outcome_symbol(context.ht_home or 0, context.ht_away or 0)}/{_outcome_symbol(context.final_home, context.final_away)}"
|
||||
won = str(pick).strip().upper() == actual
|
||||
return {"result": "WON" if won else "LOST", "won": won, "note": f"actual={actual}"}
|
||||
|
||||
|
||||
def _market_odds(odds: Dict[str, Any], market: str, pick: str) -> float:
|
||||
mapping = {
|
||||
"HTFT": {
|
||||
"1/1": "htft_11",
|
||||
"1/X": "htft_1x",
|
||||
"1/2": "htft_12",
|
||||
"X/1": "htft_x1",
|
||||
"X/X": "htft_xx",
|
||||
"X/2": "htft_x2",
|
||||
"2/1": "htft_21",
|
||||
"2/X": "htft_2x",
|
||||
"2/2": "htft_22",
|
||||
},
|
||||
"MS": {"1": "ms_h", "X": "ms_d", "2": "ms_a"},
|
||||
}
|
||||
key = mapping.get(market, {}).get(str(pick))
|
||||
if not key:
|
||||
return 0.0
|
||||
value = _safe_float((odds or {}).get(key))
|
||||
return value if value > 1.0 else 0.0
|
||||
|
||||
|
||||
def _evaluate_pick(
|
||||
*,
|
||||
strategy: str,
|
||||
market: str,
|
||||
pick: str,
|
||||
odds: Any,
|
||||
playable: bool,
|
||||
confidence: Any,
|
||||
extra: Optional[Dict[str, Any]],
|
||||
context: MatchContext,
|
||||
) -> Dict[str, Any]:
|
||||
odds_value = _safe_float(odds)
|
||||
if market == "HT/FT":
|
||||
market = "HTFT"
|
||||
resolution = _resolve_htft(pick, context) if market == "HTFT" else {
|
||||
"result": "UNRESOLVED",
|
||||
"won": None,
|
||||
"note": "non_htft_market",
|
||||
}
|
||||
counted = bool(playable and market == "HTFT" and odds_value > 1.01 and resolution["result"] in {"WON", "LOST"})
|
||||
profit = 0.0
|
||||
if counted:
|
||||
profit = (odds_value - 1.0) if resolution["result"] == "WON" else -1.0
|
||||
row = {
|
||||
"strategy": strategy,
|
||||
"market": market,
|
||||
"pick": pick,
|
||||
"odds": round(odds_value, 2),
|
||||
"playable": playable,
|
||||
"confidence": round(_safe_float(confidence), 1),
|
||||
"result": resolution["result"],
|
||||
"counted_in_roi": counted,
|
||||
"profit_flat": round(profit, 4),
|
||||
"resolution_note": resolution["note"],
|
||||
}
|
||||
if extra:
|
||||
row.update(extra)
|
||||
return row
|
||||
|
||||
|
||||
def _extract_strategy_rows(
|
||||
*,
|
||||
context: MatchContext,
|
||||
odds_data: Dict[str, Any],
|
||||
v25: Dict[str, Any],
|
||||
v26: Dict[str, Any],
|
||||
) -> Dict[str, Optional[Dict[str, Any]]]:
|
||||
strategies: Dict[str, Optional[Dict[str, Any]]] = {name: None for name in STRATEGIES}
|
||||
|
||||
v25_aggressive = v25.get("aggressive_pick") or {}
|
||||
if v25_aggressive.get("pick"):
|
||||
pick = str(v25_aggressive.get("pick"))
|
||||
strategies["v25_aggressive"] = _evaluate_pick(
|
||||
strategy="v25_aggressive",
|
||||
market=str(v25_aggressive.get("market") or "HTFT"),
|
||||
pick=pick,
|
||||
odds=_market_odds(odds_data, "HTFT", pick),
|
||||
playable=True,
|
||||
confidence=v25_aggressive.get("confidence"),
|
||||
extra={
|
||||
"source": "v25.aggressive_pick",
|
||||
"reversal_pick": pick,
|
||||
},
|
||||
context=context,
|
||||
)
|
||||
|
||||
v26_surprise = v26.get("surprise_pick") or {}
|
||||
v26_hunter = v26.get("surprise_hunter") or {}
|
||||
if v26_surprise.get("pick"):
|
||||
pick = str(v26_surprise.get("raw_pick") or v26_surprise.get("pick"))
|
||||
strategies["v26_surprise"] = _evaluate_pick(
|
||||
strategy="v26_surprise",
|
||||
market=str(v26_surprise.get("market") or "HTFT"),
|
||||
pick=pick,
|
||||
odds=v26_surprise.get("odds") or _market_odds(odds_data, "HTFT", pick),
|
||||
playable=bool(v26_surprise.get("playable")),
|
||||
confidence=v26_surprise.get("calibrated_confidence", v26_surprise.get("confidence")),
|
||||
extra={
|
||||
"source": "v26.surprise_pick",
|
||||
"surprise_score": round(_safe_float(v26_surprise.get("surprise_score")), 1),
|
||||
"support_score": round(_safe_float(v26_surprise.get("support_score")), 1),
|
||||
"reversal_pick": v26_hunter.get("reversal_pick"),
|
||||
"reversal_prob": round(_safe_float(v26_hunter.get("reversal_prob")), 4),
|
||||
"favorite_gap": round(_safe_float(v26_hunter.get("favorite_gap")), 3),
|
||||
"favorite_odd": round(_safe_float(v26_hunter.get("favorite_odd")), 2),
|
||||
"odds_band_score": round(_safe_float(v26_hunter.get("odds_band_score")), 3),
|
||||
"odds_band_label": str(v26_hunter.get("odds_band_label") or ""),
|
||||
"league_reversal_rate": round(_safe_float(v26_hunter.get("league_reversal_rate")), 4),
|
||||
"league_strict_rev_rate": round(_safe_float(v26_hunter.get("league_strict_rev_rate")), 4),
|
||||
"referee_strict_rev_rate": round(_safe_float(v26_hunter.get("referee_strict_rev_rate")), 4),
|
||||
"reason_codes": ",".join(v26_hunter.get("reason_codes", [])),
|
||||
},
|
||||
context=context,
|
||||
)
|
||||
|
||||
v26_aggressive = v26.get("aggressive_pick") or {}
|
||||
if v26_aggressive.get("pick"):
|
||||
pick = str(v26_aggressive.get("pick"))
|
||||
strategies["v26_aggressive"] = _evaluate_pick(
|
||||
strategy="v26_aggressive",
|
||||
market=str(v26_aggressive.get("market") or "HTFT"),
|
||||
pick=pick,
|
||||
odds=v26_aggressive.get("odds") or _market_odds(odds_data, "HTFT", pick),
|
||||
playable=True,
|
||||
confidence=v26_aggressive.get("confidence"),
|
||||
extra={
|
||||
"source": "v26.aggressive_pick",
|
||||
"reversal_pick": pick,
|
||||
},
|
||||
context=context,
|
||||
)
|
||||
|
||||
v26_main = v26.get("main_pick") or {}
|
||||
if str(v26_main.get("market") or "") == "HTFT" and v26_main.get("pick"):
|
||||
pick = str(v26_main.get("raw_pick") or v26_main.get("pick"))
|
||||
strategies["v26_main_htft"] = _evaluate_pick(
|
||||
strategy="v26_main_htft",
|
||||
market="HTFT",
|
||||
pick=pick,
|
||||
odds=v26_main.get("odds") or _market_odds(odds_data, "HTFT", pick),
|
||||
playable=bool(v26_main.get("playable")),
|
||||
confidence=v26_main.get("calibrated_confidence", v26_main.get("confidence")),
|
||||
extra={
|
||||
"source": "v26.main_pick",
|
||||
"pick_reason": v26_main.get("pick_reason"),
|
||||
"surprise_score": round(_safe_float(v26_main.get("surprise_score")), 1),
|
||||
},
|
||||
context=context,
|
||||
)
|
||||
|
||||
return strategies
|
||||
|
||||
|
||||
def _summarize_bucket(bucket: Dict[str, float]) -> Dict[str, Any]:
|
||||
played = int(bucket["played"])
|
||||
won = int(bucket["won"])
|
||||
lost = int(bucket["lost"])
|
||||
candidate = int(bucket["candidate"])
|
||||
profit = round(bucket["profit"], 4)
|
||||
roi = round((profit / played) * 100.0, 2) if played else 0.0
|
||||
hit = round((won / played) * 100.0, 2) if played else 0.0
|
||||
return {
|
||||
"candidates": candidate,
|
||||
"played": played,
|
||||
"won": won,
|
||||
"lost": lost,
|
||||
"profit_flat": profit,
|
||||
"roi_flat_pct": roi,
|
||||
"hit_rate_pct": hit,
|
||||
}
|
||||
|
||||
|
||||
def _format_date(ms: int) -> str:
|
||||
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _build_markdown(report: Dict[str, Any]) -> str:
|
||||
lines: list[str] = []
|
||||
lines.append("# HT/FT + Upset Backtest")
|
||||
lines.append("")
|
||||
lines.append(f"- Sample: last {report['sample_size']} finished football matches")
|
||||
lines.append("- Scope: only HT/FT reversal and upset-oriented picks")
|
||||
lines.append("- ROI: flat `1 unit` per played pick")
|
||||
lines.append(f"- Generated at: {report['generated_at']}")
|
||||
lines.append("")
|
||||
lines.append("## Strategy Summary")
|
||||
lines.append("")
|
||||
lines.append("| Strategy | Candidates | Played | Won | Lost | Hit Rate | Profit | ROI |")
|
||||
lines.append("|---|---:|---:|---:|---:|---:|---:|---:|")
|
||||
for strategy in STRATEGIES:
|
||||
payload = report["summary"]["strategies"][strategy]
|
||||
lines.append(
|
||||
f"| {strategy} | {payload['candidates']} | {payload['played']} | {payload['won']} | "
|
||||
f"{payload['lost']} | {payload['hit_rate_pct']}% | {payload['profit_flat']:+.2f} | {payload['roi_flat_pct']:+.2f}% |"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("## v26 Surprise By Reversal Type")
|
||||
lines.append("")
|
||||
lines.append("| Reversal | Candidates | Played | Won | Lost | Profit | ROI |")
|
||||
lines.append("|---|---:|---:|---:|---:|---:|---:|")
|
||||
for reversal, payload in report["summary"]["v26_surprise_by_pick"].items():
|
||||
lines.append(
|
||||
f"| {reversal} | {payload['candidates']} | {payload['played']} | {payload['won']} | "
|
||||
f"{payload['lost']} | {payload['profit_flat']:+.2f} | {payload['roi_flat_pct']:+.2f}% |"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("## Match Detail")
|
||||
lines.append("")
|
||||
lines.append("| Date | Match | HT | FT | v25 aggressive | v26 surprise | v26 aggressive | v26 main HTFT |")
|
||||
lines.append("|---|---|---|---|---|---|---|---|")
|
||||
for match in report["matches"]:
|
||||
lines.append(
|
||||
f"| {_format_date(match['match_date_ms'])} | {match['match_name']} | {match['ht_score']} | {match['final_score']} | "
|
||||
f"{match['v25_aggressive']} | {match['v26_surprise']} | {match['v26_aggressive']} | {match['v26_main_htft']} |"
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="HT/FT + upset focused backtest.")
|
||||
parser.add_argument("--limit", type=int, default=120, help="Number of finished matches to analyze.")
|
||||
args = parser.parse_args()
|
||||
|
||||
dsn = _resolve_dsn()
|
||||
orchestrator = SingleMatchOrchestrator()
|
||||
matches = _fetch_matches(dsn, max(1, args.limit))
|
||||
|
||||
strategy_buckets: Dict[str, Dict[str, float]] = {name: defaultdict(float) for name in STRATEGIES}
|
||||
v26_reversal_buckets: Dict[str, Dict[str, float]] = {label: defaultdict(float) for label in REVERSAL_LABELS}
|
||||
report_matches: list[Dict[str, Any]] = []
|
||||
csv_rows: list[Dict[str, Any]] = []
|
||||
|
||||
for context in matches:
|
||||
data = orchestrator._load_match_data(context.match_id) # noqa: SLF001
|
||||
if data is None:
|
||||
continue
|
||||
|
||||
orchestrator.engine_mode = "v25"
|
||||
v25 = orchestrator.analyze_match(context.match_id) or {}
|
||||
orchestrator.engine_mode = "v26"
|
||||
v26 = orchestrator.analyze_match(context.match_id) or {}
|
||||
|
||||
extracted = _extract_strategy_rows(
|
||||
context=context,
|
||||
odds_data=data.odds_data or {},
|
||||
v25=v25,
|
||||
v26=v26,
|
||||
)
|
||||
|
||||
match_row: Dict[str, Any] = {
|
||||
"match_id": context.match_id,
|
||||
"match_name": context.match_name,
|
||||
"league": context.league,
|
||||
"match_date_ms": context.match_date_ms,
|
||||
"ht_score": context.ht_score,
|
||||
"final_score": context.final_score,
|
||||
}
|
||||
|
||||
for strategy, payload in extracted.items():
|
||||
if payload:
|
||||
strategy_buckets[strategy]["candidate"] += 1
|
||||
if payload["counted_in_roi"]:
|
||||
strategy_buckets[strategy]["played"] += 1
|
||||
if payload["result"] == "WON":
|
||||
strategy_buckets[strategy]["won"] += 1
|
||||
else:
|
||||
strategy_buckets[strategy]["lost"] += 1
|
||||
strategy_buckets[strategy]["profit"] += payload["profit_flat"]
|
||||
|
||||
if strategy == "v26_surprise":
|
||||
reversal_label = str(payload.get("reversal_pick") or "")
|
||||
if reversal_label in v26_reversal_buckets:
|
||||
v26_reversal_buckets[reversal_label]["candidate"] += 1
|
||||
if payload["counted_in_roi"]:
|
||||
v26_reversal_buckets[reversal_label]["played"] += 1
|
||||
if payload["result"] == "WON":
|
||||
v26_reversal_buckets[reversal_label]["won"] += 1
|
||||
else:
|
||||
v26_reversal_buckets[reversal_label]["lost"] += 1
|
||||
v26_reversal_buckets[reversal_label]["profit"] += payload["profit_flat"]
|
||||
|
||||
summary = (
|
||||
f"{payload['pick']} ({payload['result']}, {'played' if payload['counted_in_roi'] else 'not played'}, {payload['profit_flat']:+.2f})"
|
||||
)
|
||||
match_row[strategy] = summary
|
||||
|
||||
csv_rows.append(
|
||||
{
|
||||
"match_id": context.match_id,
|
||||
"date": _format_date(context.match_date_ms),
|
||||
"league": context.league,
|
||||
"match": context.match_name,
|
||||
"ht_score": context.ht_score,
|
||||
"final_score": context.final_score,
|
||||
**payload,
|
||||
}
|
||||
)
|
||||
else:
|
||||
match_row[strategy] = "-"
|
||||
|
||||
report_matches.append(match_row)
|
||||
|
||||
report = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"sample_size": len(report_matches),
|
||||
"summary": {
|
||||
"strategies": {
|
||||
strategy: _summarize_bucket(bucket)
|
||||
for strategy, bucket in strategy_buckets.items()
|
||||
},
|
||||
"v26_surprise_by_pick": {
|
||||
label: _summarize_bucket(bucket)
|
||||
for label, bucket in v26_reversal_buckets.items()
|
||||
},
|
||||
},
|
||||
"matches": report_matches,
|
||||
}
|
||||
|
||||
report_dir = AI_ENGINE_DIR / "reports"
|
||||
json_path = report_dir / "backtest_v26_shadow_htft_upset.json"
|
||||
csv_path = report_dir / "backtest_v26_shadow_htft_upset.csv"
|
||||
md_path = report_dir / "backtest_v26_shadow_htft_upset.md"
|
||||
|
||||
json_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
with csv_path.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(
|
||||
handle,
|
||||
fieldnames=[
|
||||
"match_id",
|
||||
"date",
|
||||
"league",
|
||||
"match",
|
||||
"ht_score",
|
||||
"final_score",
|
||||
"strategy",
|
||||
"market",
|
||||
"pick",
|
||||
"odds",
|
||||
"playable",
|
||||
"confidence",
|
||||
"result",
|
||||
"counted_in_roi",
|
||||
"profit_flat",
|
||||
"resolution_note",
|
||||
"source",
|
||||
"reversal_pick",
|
||||
"reversal_prob",
|
||||
"favorite_gap",
|
||||
"favorite_odd",
|
||||
"support_score",
|
||||
"odds_band_score",
|
||||
"odds_band_label",
|
||||
"league_reversal_rate",
|
||||
"league_strict_rev_rate",
|
||||
"referee_strict_rev_rate",
|
||||
"surprise_score",
|
||||
"reason_codes",
|
||||
"pick_reason",
|
||||
],
|
||||
)
|
||||
writer.writeheader()
|
||||
writer.writerows(csv_rows)
|
||||
md_path.write_text(_build_markdown(report), encoding="utf-8")
|
||||
|
||||
print(f"[OK] JSON report written to {json_path}")
|
||||
print(f"[OK] CSV report written to {csv_path}")
|
||||
print(f"[OK] Markdown report written to {md_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,810 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
|
||||
AI_ENGINE_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(AI_ENGINE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(AI_ENGINE_DIR))
|
||||
|
||||
from services.single_match_orchestrator import SingleMatchOrchestrator
|
||||
from utils.top_leagues import load_top_league_ids
|
||||
|
||||
|
||||
MARKET_ORDER = [
|
||||
"MS",
|
||||
"DC",
|
||||
"OU15",
|
||||
"OU25",
|
||||
"OU35",
|
||||
"BTTS",
|
||||
"HT",
|
||||
"HT_OU05",
|
||||
"HT_OU15",
|
||||
"HTFT",
|
||||
"OE",
|
||||
"CARDS",
|
||||
"HCAP",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchContext:
|
||||
match_id: str
|
||||
match_date_ms: int
|
||||
league_id: Optional[str]
|
||||
league: str
|
||||
home_team: str
|
||||
away_team: str
|
||||
final_home: int
|
||||
final_away: int
|
||||
ht_home: Optional[int]
|
||||
ht_away: Optional[int]
|
||||
total_cards: Optional[float]
|
||||
|
||||
@property
|
||||
def match_name(self) -> str:
|
||||
return f"{self.home_team} vs {self.away_team}"
|
||||
|
||||
@property
|
||||
def final_score(self) -> str:
|
||||
return f"{self.final_home}-{self.final_away}"
|
||||
|
||||
@property
|
||||
def ht_score(self) -> Optional[str]:
|
||||
if self.ht_home is None or self.ht_away is None:
|
||||
return None
|
||||
return f"{self.ht_home}-{self.ht_away}"
|
||||
|
||||
@property
|
||||
def total_goals(self) -> int:
|
||||
return self.final_home + self.final_away
|
||||
|
||||
@property
|
||||
def total_ht_goals(self) -> Optional[int]:
|
||||
if self.ht_home is None or self.ht_away is None:
|
||||
return None
|
||||
return self.ht_home + self.ht_away
|
||||
|
||||
|
||||
def _resolve_dsn() -> str:
|
||||
env_path = AI_ENGINE_DIR / ".env"
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("DATABASE_URL="):
|
||||
return line.split("=", 1)[1].strip().split("?schema=")[0]
|
||||
raise SystemExit("DATABASE_URL not found in ai-engine/.env")
|
||||
|
||||
|
||||
def _fetch_matches(
|
||||
dsn: str,
|
||||
limit: int,
|
||||
top_league_ids: Optional[list[str]] = None,
|
||||
) -> list[MatchContext]:
|
||||
query = """
|
||||
SELECT
|
||||
m.id,
|
||||
m.mst_utc,
|
||||
m.league_id,
|
||||
COALESCE(l.name, 'Unknown League') AS league,
|
||||
COALESCE(ht.name, 'Home') AS home_team,
|
||||
COALESCE(at.name, 'Away') AS away_team,
|
||||
COALESCE(m.score_home, 0) AS score_home,
|
||||
COALESCE(m.score_away, 0) AS score_away,
|
||||
m.ht_score_home,
|
||||
m.ht_score_away,
|
||||
cards.total_cards
|
||||
FROM matches m
|
||||
LEFT JOIN leagues l ON l.id = m.league_id
|
||||
LEFT JOIN teams ht ON ht.id = m.home_team_id
|
||||
LEFT JOIN teams at ON at.id = m.away_team_id
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
mpe.match_id,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN mpe.event_type::text LIKE '%%yellow_card%%' THEN 1
|
||||
WHEN mpe.event_type::text LIKE '%%red_card%%' THEN 2
|
||||
ELSE 1
|
||||
END
|
||||
)::float AS total_cards
|
||||
FROM match_player_events mpe
|
||||
WHERE mpe.event_type::text LIKE '%%card%%'
|
||||
GROUP BY mpe.match_id
|
||||
) cards ON cards.match_id = m.id
|
||||
WHERE m.status = 'FT'
|
||||
AND m.sport = 'football'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.score_away IS NOT NULL
|
||||
"""
|
||||
params: list[Any] = []
|
||||
if top_league_ids:
|
||||
query += " AND m.league_id = ANY(%s)"
|
||||
params.append(top_league_ids)
|
||||
query += """
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
params.append(limit)
|
||||
with psycopg2.connect(dsn) as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(query, params)
|
||||
rows = cur.fetchall()
|
||||
|
||||
results: list[MatchContext] = []
|
||||
for row in rows:
|
||||
results.append(
|
||||
MatchContext(
|
||||
match_id=str(row["id"]),
|
||||
match_date_ms=int(row["mst_utc"] or 0),
|
||||
league_id=str(row["league_id"]) if row.get("league_id") else None,
|
||||
league=str(row["league"] or "Unknown League"),
|
||||
home_team=str(row["home_team"] or "Home"),
|
||||
away_team=str(row["away_team"] or "Away"),
|
||||
final_home=int(row["score_home"] or 0),
|
||||
final_away=int(row["score_away"] or 0),
|
||||
ht_home=(
|
||||
int(row["ht_score_home"])
|
||||
if row.get("ht_score_home") is not None
|
||||
else None
|
||||
),
|
||||
ht_away=(
|
||||
int(row["ht_score_away"])
|
||||
if row.get("ht_score_away") is not None
|
||||
else None
|
||||
),
|
||||
total_cards=(
|
||||
float(row["total_cards"])
|
||||
if row.get("total_cards") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def _odds_band(odds: float) -> str:
|
||||
if odds < 1.5:
|
||||
return "<1.50"
|
||||
if odds < 1.8:
|
||||
return "1.50-1.79"
|
||||
if odds < 2.1:
|
||||
return "1.80-2.09"
|
||||
if odds < 2.5:
|
||||
return "2.10-2.49"
|
||||
return "2.50+"
|
||||
|
||||
|
||||
def _confidence_band(confidence: float) -> str:
|
||||
if confidence < 55.0:
|
||||
return "<55"
|
||||
if confidence < 65.0:
|
||||
return "55-64.9"
|
||||
if confidence < 75.0:
|
||||
return "65-74.9"
|
||||
return "75+"
|
||||
|
||||
|
||||
def _edge_band(edge: float) -> str:
|
||||
if edge < 0.03:
|
||||
return "<0.03"
|
||||
if edge < 0.06:
|
||||
return "0.03-0.059"
|
||||
if edge < 0.10:
|
||||
return "0.06-0.099"
|
||||
return "0.10+"
|
||||
|
||||
|
||||
def _top_n_buckets(rows: Iterable[tuple[str, float]], limit: int = 10) -> list[dict[str, Any]]:
|
||||
ranked = sorted(rows, key=lambda item: (-item[1], item[0]))
|
||||
return [
|
||||
{"label": label, "count": int(count)}
|
||||
for label, count in ranked[:limit]
|
||||
]
|
||||
|
||||
|
||||
def _summarize_v26_losses(csv_rows: list[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
losses = [
|
||||
row for row in csv_rows
|
||||
if row.get("model") == "v26.shadow"
|
||||
and bool(row.get("counted_in_roi"))
|
||||
and row.get("result") == "LOST"
|
||||
]
|
||||
by_market: Dict[str, float] = defaultdict(float)
|
||||
by_league: Dict[str, float] = defaultdict(float)
|
||||
by_pick: Dict[str, float] = defaultdict(float)
|
||||
by_odds_band: Dict[str, float] = defaultdict(float)
|
||||
by_conf_band: Dict[str, float] = defaultdict(float)
|
||||
by_edge_band: Dict[str, float] = defaultdict(float)
|
||||
|
||||
for row in losses:
|
||||
market = str(row.get("market") or "UNKNOWN")
|
||||
league = str(row.get("league") or "Unknown League")
|
||||
pick = str(row.get("pick") or "")
|
||||
odds = _safe_float(row.get("odds"))
|
||||
confidence = _safe_float(row.get("confidence"))
|
||||
edge = _safe_float(row.get("edge"))
|
||||
|
||||
by_market[market] += 1
|
||||
by_league[league] += 1
|
||||
by_pick[f"{market} {pick}".strip()] += 1
|
||||
by_odds_band[_odds_band(odds)] += 1
|
||||
by_conf_band[_confidence_band(confidence)] += 1
|
||||
by_edge_band[_edge_band(edge)] += 1
|
||||
|
||||
return {
|
||||
"lost_bets": len(losses),
|
||||
"by_market": _top_n_buckets(by_market.items(), limit=20),
|
||||
"by_league": _top_n_buckets(by_league.items(), limit=15),
|
||||
"by_pick": _top_n_buckets(by_pick.items(), limit=15),
|
||||
"by_odds_band": _top_n_buckets(by_odds_band.items(), limit=10),
|
||||
"by_confidence_band": _top_n_buckets(by_conf_band.items(), limit=10),
|
||||
"by_edge_band": _top_n_buckets(by_edge_band.items(), limit=10),
|
||||
}
|
||||
|
||||
|
||||
def _safe_float(value: Any) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _normalize_text(value: Any) -> str:
|
||||
text = str(value or "").strip().upper()
|
||||
return (
|
||||
text.replace("İ", "I")
|
||||
.replace("İ", "I")
|
||||
.replace("Ş", "S")
|
||||
.replace("Ğ", "G")
|
||||
.replace("Ü", "U")
|
||||
.replace("Ö", "O")
|
||||
.replace("Ç", "C")
|
||||
)
|
||||
|
||||
|
||||
def _outcome_symbol(home: int, away: int) -> str:
|
||||
if home > away:
|
||||
return "1"
|
||||
if home < away:
|
||||
return "2"
|
||||
return "X"
|
||||
|
||||
|
||||
def _resolve_pick(
|
||||
market: str,
|
||||
pick: str,
|
||||
context: MatchContext,
|
||||
) -> Dict[str, Any]:
|
||||
market_code = _normalize_text(market).replace("/", "")
|
||||
pick_text = str(pick or "").strip()
|
||||
pick_norm = _normalize_text(pick_text)
|
||||
|
||||
if not market_code or not pick_norm:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "pick_missing"}
|
||||
|
||||
if market_code == "HTFT":
|
||||
market_code = "HTFT"
|
||||
if market_code == "HTFT" or market_code == "HTFT":
|
||||
if context.ht_home is None or context.ht_away is None:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "ht_score_missing"}
|
||||
if "/" not in pick_text:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "htft_pick_invalid"}
|
||||
ht_pick, ft_pick = pick_text.split("/", 1)
|
||||
actual = f"{_outcome_symbol(context.ht_home, context.ht_away)}/{_outcome_symbol(context.final_home, context.final_away)}"
|
||||
won = f"{_normalize_text(ht_pick)}/{_normalize_text(ft_pick)}" == actual
|
||||
return {"result": "WON" if won else "LOST", "won": won, "note": f"actual={actual}"}
|
||||
|
||||
if market_code == "MS":
|
||||
actual = _outcome_symbol(context.final_home, context.final_away)
|
||||
won = pick_norm in {actual, f"MS {actual}"}
|
||||
return {"result": "WON" if won else "LOST", "won": won, "note": f"actual={actual}"}
|
||||
|
||||
if market_code == "DC":
|
||||
actual = _outcome_symbol(context.final_home, context.final_away)
|
||||
winning = {
|
||||
"1X": {"1", "X"},
|
||||
"X2": {"X", "2"},
|
||||
"12": {"1", "2"},
|
||||
}
|
||||
won = actual in winning.get(pick_norm, set())
|
||||
return {"result": "WON" if won else "LOST", "won": won, "note": f"actual={actual}"}
|
||||
|
||||
if market_code in {"OU15", "OU25", "OU35", "HTOU05", "HTOU15", "HT_OU05", "HT_OU15"}:
|
||||
if market_code in {"HTOU05", "HTOU15", "HT_OU05", "HT_OU15"}:
|
||||
if context.total_ht_goals is None:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "ht_score_missing"}
|
||||
total = context.total_ht_goals
|
||||
line = 0.5 if "05" in market_code else 1.5
|
||||
else:
|
||||
total = context.total_goals
|
||||
line = {"OU15": 1.5, "OU25": 2.5, "OU35": 3.5}[market_code]
|
||||
|
||||
if "UST" in pick_norm or "OVER" in pick_norm:
|
||||
won = total > line
|
||||
side = "OVER"
|
||||
elif "ALT" in pick_norm or "UNDER" in pick_norm:
|
||||
won = total < line
|
||||
side = "UNDER"
|
||||
else:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "ou_side_unknown"}
|
||||
return {
|
||||
"result": "WON" if won else "LOST",
|
||||
"won": won,
|
||||
"note": f"actual_total={total} side={side} line={line}",
|
||||
}
|
||||
|
||||
if market_code == "BTTS":
|
||||
both_scored = context.final_home > 0 and context.final_away > 0
|
||||
if "VAR" in pick_norm or "YES" in pick_norm:
|
||||
won = both_scored
|
||||
side = "YES"
|
||||
elif "YOK" in pick_norm or pick_norm.endswith("NO") or pick_norm == "NO":
|
||||
won = not both_scored
|
||||
side = "NO"
|
||||
else:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "btts_side_unknown"}
|
||||
return {
|
||||
"result": "WON" if won else "LOST",
|
||||
"won": won,
|
||||
"note": f"actual_btts={'YES' if both_scored else 'NO'} side={side}",
|
||||
}
|
||||
|
||||
if market_code == "HT":
|
||||
if context.ht_home is None or context.ht_away is None:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "ht_score_missing"}
|
||||
actual = _outcome_symbol(context.ht_home, context.ht_away)
|
||||
won = pick_norm == actual
|
||||
return {"result": "WON" if won else "LOST", "won": won, "note": f"actual={actual}"}
|
||||
|
||||
if market_code == "OE":
|
||||
actual = "EVEN" if context.total_goals % 2 == 0 else "ODD"
|
||||
if pick_norm in {"CIFT", "EVEN"}:
|
||||
wanted = "EVEN"
|
||||
elif pick_norm in {"TEK", "ODD"}:
|
||||
wanted = "ODD"
|
||||
else:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "oe_pick_unknown"}
|
||||
won = actual == wanted
|
||||
return {"result": "WON" if won else "LOST", "won": won, "note": f"actual={actual}"}
|
||||
|
||||
if market_code == "CARDS":
|
||||
if context.total_cards is None:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "cards_missing"}
|
||||
if "UST" in pick_norm or "OVER" in pick_norm:
|
||||
won = context.total_cards > 4.5
|
||||
side = "OVER"
|
||||
elif "ALT" in pick_norm or "UNDER" in pick_norm:
|
||||
won = context.total_cards < 4.5
|
||||
side = "UNDER"
|
||||
else:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "cards_side_unknown"}
|
||||
return {
|
||||
"result": "WON" if won else "LOST",
|
||||
"won": won,
|
||||
"note": f"actual_cards={context.total_cards:.1f} side={side} line=4.5",
|
||||
}
|
||||
|
||||
if market_code == "HCAP":
|
||||
adjusted_home = context.final_home - 1.0
|
||||
adjusted_away = float(context.final_away)
|
||||
if adjusted_home > adjusted_away:
|
||||
actual = "1"
|
||||
elif adjusted_home < adjusted_away:
|
||||
actual = "2"
|
||||
else:
|
||||
actual = "X"
|
||||
won = pick_norm == actual
|
||||
return {
|
||||
"result": "WON" if won else "LOST",
|
||||
"won": won,
|
||||
"note": f"actual={actual} line_home=-1.0",
|
||||
}
|
||||
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "market_not_supported"}
|
||||
|
||||
|
||||
def _evaluate_row(
|
||||
market: str,
|
||||
pick: str,
|
||||
odds: Any,
|
||||
playable: bool,
|
||||
stake_units: Any,
|
||||
context: MatchContext,
|
||||
) -> Dict[str, Any]:
|
||||
resolution = _resolve_pick(market, pick, context)
|
||||
odds_value = _safe_float(odds)
|
||||
stake_value = _safe_float(stake_units)
|
||||
counted = bool(playable and odds_value > 1.01 and resolution["result"] in {"WON", "LOST"})
|
||||
|
||||
flat_profit = 0.0
|
||||
stake_profit = 0.0
|
||||
if counted:
|
||||
flat_profit = (odds_value - 1.0) if resolution["result"] == "WON" else -1.0
|
||||
stake_profit = flat_profit * (stake_value if stake_value > 0 else 1.0)
|
||||
|
||||
return {
|
||||
"result": resolution["result"],
|
||||
"won": resolution["won"],
|
||||
"resolution_note": resolution["note"],
|
||||
"counted_in_roi": counted,
|
||||
"profit_flat": round(flat_profit, 4),
|
||||
"profit_stake": round(stake_profit, 4),
|
||||
}
|
||||
|
||||
|
||||
def _summarize_bucket(bucket: Dict[str, float]) -> Dict[str, Any]:
|
||||
played = int(bucket["played"])
|
||||
won = int(bucket["won"])
|
||||
lost = int(bucket["lost"])
|
||||
unresolved = int(bucket["unresolved"])
|
||||
profit = round(bucket["profit"], 4)
|
||||
roi = round((profit / played) * 100.0, 2) if played else 0.0
|
||||
win_rate = round((won / played) * 100.0, 2) if played else 0.0
|
||||
return {
|
||||
"played": played,
|
||||
"won": won,
|
||||
"lost": lost,
|
||||
"unresolved": unresolved,
|
||||
"profit_flat": profit,
|
||||
"roi_flat_pct": roi,
|
||||
"win_rate_pct": win_rate,
|
||||
}
|
||||
|
||||
|
||||
def _format_date(ms: int) -> str:
|
||||
if ms <= 0:
|
||||
return "-"
|
||||
dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _build_markdown_report(report: Dict[str, Any]) -> str:
|
||||
lines: list[str] = []
|
||||
lines.append("# v25 vs v26.shadow ROI Report")
|
||||
lines.append("")
|
||||
lines.append(f"- Sample: last {report['sample_size']} finished football matches")
|
||||
if report.get("top_leagues_only"):
|
||||
lines.append("- Filter: top leagues only")
|
||||
lines.append("- ROI calculation: flat `1 unit` per playable and resolvable bet")
|
||||
lines.append(f"- Generated at: {report['generated_at']}")
|
||||
lines.append("")
|
||||
lines.append("## Overall Summary")
|
||||
lines.append("")
|
||||
lines.append("| Model | Played | Won | Lost | Win Rate | Profit | ROI | Main Pick ROI | Main Pick W/L |")
|
||||
lines.append("|---|---:|---:|---:|---:|---:|---:|---:|---|")
|
||||
for model_name, payload in report["summary"]["models"].items():
|
||||
main = payload["main_pick"]
|
||||
lines.append(
|
||||
f"| {model_name} | {payload['all_playable']['played']} | {payload['all_playable']['won']} | "
|
||||
f"{payload['all_playable']['lost']} | {payload['all_playable']['win_rate_pct']}% | "
|
||||
f"{payload['all_playable']['profit_flat']:+.2f} | {payload['all_playable']['roi_flat_pct']:+.2f}% | "
|
||||
f"{main['roi_flat_pct']:+.2f}% | {main['won']}/{main['played']} |"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("## Market Summary")
|
||||
lines.append("")
|
||||
lines.append("| Model | Market | Played | Won | Lost | Profit | ROI |")
|
||||
lines.append("|---|---|---:|---:|---:|---:|---:|")
|
||||
for model_name, markets in report["summary"]["markets"].items():
|
||||
for market_name in MARKET_ORDER:
|
||||
payload = markets.get(market_name)
|
||||
if not payload or payload["played"] == 0:
|
||||
continue
|
||||
lines.append(
|
||||
f"| {model_name} | {market_name} | {payload['played']} | {payload['won']} | {payload['lost']} | "
|
||||
f"{payload['profit_flat']:+.2f} | {payload['roi_flat_pct']:+.2f}% |"
|
||||
)
|
||||
lines.append("")
|
||||
loss_summary = report["summary"].get("v26_loss_analysis", {})
|
||||
if loss_summary:
|
||||
lines.append("## v26 Loss Analysis")
|
||||
lines.append("")
|
||||
lines.append(f"- Lost bets: {loss_summary.get('lost_bets', 0)}")
|
||||
lines.append("")
|
||||
lines.append("| Bucket | Top Items |")
|
||||
lines.append("|---|---|")
|
||||
for label, key in (
|
||||
("By market", "by_market"),
|
||||
("By league", "by_league"),
|
||||
("By pick", "by_pick"),
|
||||
("By odds band", "by_odds_band"),
|
||||
("By confidence band", "by_confidence_band"),
|
||||
("By edge band", "by_edge_band"),
|
||||
):
|
||||
items = loss_summary.get(key) or []
|
||||
rendered = ", ".join(f"{item['label']} ({item['count']})" for item in items[:6]) or "-"
|
||||
lines.append(f"| {label} | {rendered} |")
|
||||
lines.append("")
|
||||
lines.append("## Match By Match")
|
||||
lines.append("")
|
||||
lines.append("| Date | Match | Score | v25 Main | v25 Played Picks | v25 Profit | v26 Main | v26 Played Picks | v26 Profit |")
|
||||
lines.append("|---|---|---|---|---|---:|---|---|---:|")
|
||||
for match in report["matches"]:
|
||||
v25 = match["models"]["v25"]
|
||||
v26 = match["models"]["v26.shadow"]
|
||||
lines.append(
|
||||
f"| {_format_date(match['match_date_ms'])} | {match['match_name']} | {match['final_score']} | "
|
||||
f"{v25['main_pick']['summary']} | {v25['played_picks_summary']} | {v25['profit_flat']:+.2f} | "
|
||||
f"{v26['main_pick']['summary']} | {v26['played_picks_summary']} | {v26['profit_flat']:+.2f} |"
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Detailed ROI backtest for v25 vs v26.shadow.",
|
||||
)
|
||||
parser.add_argument("--limit", type=int, default=60, help="Number of finished matches to analyze.")
|
||||
parser.add_argument(
|
||||
"--top-leagues-only",
|
||||
action="store_true",
|
||||
help="Only analyze matches whose league_id exists in top_leagues.json.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
dsn = _resolve_dsn()
|
||||
top_league_ids = sorted(load_top_league_ids()) if args.top_leagues_only else None
|
||||
matches = _fetch_matches(dsn, max(1, args.limit), top_league_ids=top_league_ids)
|
||||
orchestrator = SingleMatchOrchestrator()
|
||||
|
||||
report_matches: list[Dict[str, Any]] = []
|
||||
model_aggregate: Dict[str, Dict[str, float]] = {
|
||||
"v25": defaultdict(float),
|
||||
"v26.shadow": defaultdict(float),
|
||||
}
|
||||
main_pick_aggregate: Dict[str, Dict[str, float]] = {
|
||||
"v25": defaultdict(float),
|
||||
"v26.shadow": defaultdict(float),
|
||||
}
|
||||
market_aggregate: Dict[str, Dict[str, Dict[str, float]]] = {
|
||||
"v25": defaultdict(lambda: defaultdict(float)),
|
||||
"v26.shadow": defaultdict(lambda: defaultdict(float)),
|
||||
}
|
||||
csv_rows: list[Dict[str, Any]] = []
|
||||
|
||||
for context in matches:
|
||||
match_payload = {
|
||||
"match_id": context.match_id,
|
||||
"match_name": context.match_name,
|
||||
"league": context.league,
|
||||
"match_date_ms": context.match_date_ms,
|
||||
"final_score": context.final_score,
|
||||
"ht_score": context.ht_score,
|
||||
"total_cards": context.total_cards,
|
||||
"models": {},
|
||||
}
|
||||
|
||||
for model_name, mode in (("v25", "v25"), ("v26.shadow", "v26")):
|
||||
orchestrator.engine_mode = mode
|
||||
package = orchestrator.analyze_match(context.match_id) or {}
|
||||
rows = package.get("bet_summary") or []
|
||||
evaluated_rows: list[Dict[str, Any]] = []
|
||||
match_profit = 0.0
|
||||
|
||||
for row in rows:
|
||||
market = str(row.get("market") or "")
|
||||
pick = str(row.get("pick") or "")
|
||||
evaluation = _evaluate_row(
|
||||
market=market,
|
||||
pick=pick,
|
||||
odds=row.get("odds"),
|
||||
playable=bool(row.get("playable")),
|
||||
stake_units=row.get("stake_units"),
|
||||
context=context,
|
||||
)
|
||||
combined = {
|
||||
"market": market,
|
||||
"pick": pick,
|
||||
"playable": bool(row.get("playable")),
|
||||
"bet_grade": row.get("bet_grade"),
|
||||
"odds": round(_safe_float(row.get("odds")), 2),
|
||||
"calibrated_confidence": round(_safe_float(row.get("calibrated_confidence")), 1),
|
||||
"edge": round(_safe_float(row.get("ev_edge", row.get("edge"))), 4),
|
||||
"stake_units": round(_safe_float(row.get("stake_units")), 2),
|
||||
**evaluation,
|
||||
}
|
||||
evaluated_rows.append(combined)
|
||||
|
||||
if combined["counted_in_roi"]:
|
||||
bucket = market_aggregate[model_name][market]
|
||||
bucket["played"] += 1
|
||||
if combined["result"] == "WON":
|
||||
bucket["won"] += 1
|
||||
else:
|
||||
bucket["lost"] += 1
|
||||
bucket["profit"] += combined["profit_flat"]
|
||||
|
||||
model_bucket = model_aggregate[model_name]
|
||||
model_bucket["played"] += 1
|
||||
if combined["result"] == "WON":
|
||||
model_bucket["won"] += 1
|
||||
else:
|
||||
model_bucket["lost"] += 1
|
||||
model_bucket["profit"] += combined["profit_flat"]
|
||||
match_profit += combined["profit_flat"]
|
||||
elif combined["playable"]:
|
||||
model_aggregate[model_name]["unresolved"] += 1
|
||||
market_aggregate[model_name][market]["unresolved"] += 1
|
||||
|
||||
csv_rows.append(
|
||||
{
|
||||
"match_id": context.match_id,
|
||||
"date": _format_date(context.match_date_ms),
|
||||
"league": context.league,
|
||||
"match": context.match_name,
|
||||
"final_score": context.final_score,
|
||||
"ht_score": context.ht_score or "",
|
||||
"model": model_name,
|
||||
"market": market,
|
||||
"pick": pick,
|
||||
"playable": combined["playable"],
|
||||
"bet_grade": combined["bet_grade"],
|
||||
"odds": combined["odds"],
|
||||
"confidence": combined["calibrated_confidence"],
|
||||
"edge": combined["edge"],
|
||||
"result": combined["result"],
|
||||
"counted_in_roi": combined["counted_in_roi"],
|
||||
"profit_flat": combined["profit_flat"],
|
||||
"resolution_note": combined["resolution_note"],
|
||||
}
|
||||
)
|
||||
|
||||
main_pick = package.get("main_pick") or {}
|
||||
main_eval = _evaluate_row(
|
||||
market=str(main_pick.get("market") or ""),
|
||||
pick=str(main_pick.get("pick") or ""),
|
||||
odds=main_pick.get("odds"),
|
||||
playable=bool(main_pick.get("playable")),
|
||||
stake_units=main_pick.get("stake_units"),
|
||||
context=context,
|
||||
)
|
||||
main_pick_summary = {
|
||||
"market": main_pick.get("market"),
|
||||
"pick": main_pick.get("pick"),
|
||||
"playable": bool(main_pick.get("playable")),
|
||||
"odds": round(_safe_float(main_pick.get("odds")), 2),
|
||||
"confidence": round(
|
||||
_safe_float(
|
||||
main_pick.get("calibrated_confidence", main_pick.get("confidence"))
|
||||
),
|
||||
1,
|
||||
),
|
||||
"edge": round(_safe_float(main_pick.get("ev_edge", main_pick.get("edge"))), 4),
|
||||
**main_eval,
|
||||
}
|
||||
|
||||
if main_pick_summary["counted_in_roi"]:
|
||||
summary_suffix = (
|
||||
f"{main_pick_summary['result']}, played, {main_pick_summary['profit_flat']:+.2f}"
|
||||
)
|
||||
elif main_pick_summary.get("market") and main_pick_summary.get("pick"):
|
||||
summary_suffix = f"{main_pick_summary['result']}, not played"
|
||||
else:
|
||||
summary_suffix = ""
|
||||
|
||||
if main_pick_summary["counted_in_roi"]:
|
||||
bucket = main_pick_aggregate[model_name]
|
||||
bucket["played"] += 1
|
||||
if main_pick_summary["result"] == "WON":
|
||||
bucket["won"] += 1
|
||||
else:
|
||||
bucket["lost"] += 1
|
||||
bucket["profit"] += main_pick_summary["profit_flat"]
|
||||
elif main_pick_summary["playable"]:
|
||||
main_pick_aggregate[model_name]["unresolved"] += 1
|
||||
|
||||
main_pick_summary["summary"] = (
|
||||
f"{main_pick_summary['market']} {main_pick_summary['pick']} "
|
||||
f"({summary_suffix})"
|
||||
if main_pick_summary.get("market") and main_pick_summary.get("pick")
|
||||
else "No main pick"
|
||||
)
|
||||
|
||||
played_rows = [row for row in evaluated_rows if row["counted_in_roi"]]
|
||||
played_picks_summary = (
|
||||
"; ".join(
|
||||
f"{row['market']} {row['pick']}={row['result']} ({row['profit_flat']:+.2f})"
|
||||
for row in played_rows
|
||||
)
|
||||
if played_rows
|
||||
else "-"
|
||||
)
|
||||
|
||||
match_payload["models"][model_name] = {
|
||||
"main_pick": main_pick_summary,
|
||||
"profit_flat": round(match_profit, 4),
|
||||
"played_picks_summary": played_picks_summary,
|
||||
"played_picks": played_rows,
|
||||
"all_picks": evaluated_rows,
|
||||
}
|
||||
|
||||
report_matches.append(match_payload)
|
||||
|
||||
summary = {
|
||||
"models": {
|
||||
model_name: {
|
||||
"all_playable": _summarize_bucket(model_aggregate[model_name]),
|
||||
"main_pick": _summarize_bucket(main_pick_aggregate[model_name]),
|
||||
}
|
||||
for model_name in ("v25", "v26.shadow")
|
||||
},
|
||||
"markets": {
|
||||
model_name: {
|
||||
market_name: _summarize_bucket(bucket)
|
||||
for market_name, bucket in sorted(
|
||||
market_aggregate[model_name].items(),
|
||||
key=lambda item: (
|
||||
MARKET_ORDER.index(item[0]) if item[0] in MARKET_ORDER else 999,
|
||||
item[0],
|
||||
),
|
||||
)
|
||||
}
|
||||
for model_name in ("v25", "v26.shadow")
|
||||
},
|
||||
"v26_loss_analysis": _summarize_v26_losses(csv_rows),
|
||||
}
|
||||
|
||||
report = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"sample_size": len(report_matches),
|
||||
"top_leagues_only": bool(args.top_leagues_only),
|
||||
"summary": summary,
|
||||
"matches": report_matches,
|
||||
}
|
||||
|
||||
report_dir = AI_ENGINE_DIR / "reports"
|
||||
json_path = report_dir / "backtest_v26_shadow_roi_detail.json"
|
||||
csv_path = report_dir / "backtest_v26_shadow_roi_picks.csv"
|
||||
md_path = report_dir / "backtest_v26_shadow_roi_report.md"
|
||||
|
||||
json_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
with csv_path.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(
|
||||
handle,
|
||||
fieldnames=[
|
||||
"match_id",
|
||||
"date",
|
||||
"league",
|
||||
"match",
|
||||
"final_score",
|
||||
"ht_score",
|
||||
"model",
|
||||
"market",
|
||||
"pick",
|
||||
"playable",
|
||||
"bet_grade",
|
||||
"odds",
|
||||
"confidence",
|
||||
"edge",
|
||||
"result",
|
||||
"counted_in_roi",
|
||||
"profit_flat",
|
||||
"resolution_note",
|
||||
],
|
||||
)
|
||||
writer.writeheader()
|
||||
writer.writerows(csv_rows)
|
||||
|
||||
md_path.write_text(_build_markdown_report(report), encoding="utf-8")
|
||||
|
||||
print(f"[OK] JSON report written to {json_path}")
|
||||
print(f"[OK] CSV report written to {csv_path}")
|
||||
print(f"[OK] Markdown report written to {md_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,230 +0,0 @@
|
||||
"""
|
||||
Backtest the live V2 predictor stack against recent finished football matches.
|
||||
|
||||
This script uses the same path as production:
|
||||
database -> feature extractor -> betting predictor -> quant ranking.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from core.quant import MarketPick, analyze_market
|
||||
from data.database import dispose_engine, get_session
|
||||
from features.extractor import extract_features
|
||||
from models.betting_engine import get_predictor
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestStats:
|
||||
sampled_matches: int = 0
|
||||
analyzed_matches: int = 0
|
||||
skipped_matches: int = 0
|
||||
ms_correct: int = 0
|
||||
ou25_correct: int = 0
|
||||
btts_correct: int = 0
|
||||
main_pick_count: int = 0
|
||||
main_pick_correct: int = 0
|
||||
playable_pick_count: int = 0
|
||||
playable_pick_correct: int = 0
|
||||
playable_units_staked: float = 0.0
|
||||
playable_units_profit: float = 0.0
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--limit", type=int, default=50)
|
||||
parser.add_argument("--days", type=int, default=45)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _actual_ms(score_home: int, score_away: int) -> str:
|
||||
if score_home > score_away:
|
||||
return "1"
|
||||
if score_home < score_away:
|
||||
return "2"
|
||||
return "X"
|
||||
|
||||
|
||||
def _actual_ou25(score_home: int, score_away: int) -> str:
|
||||
return "Over" if (score_home + score_away) > 2 else "Under"
|
||||
|
||||
|
||||
def _actual_btts(score_home: int, score_away: int) -> str:
|
||||
return "Yes" if score_home > 0 and score_away > 0 else "No"
|
||||
|
||||
|
||||
def _odds_map_from_features(feats) -> dict[str, dict[str, float]]:
|
||||
return {
|
||||
"MS": {"1": feats.odds_home, "X": feats.odds_draw, "2": feats.odds_away},
|
||||
"OU25": {"Under": feats.odds_under25, "Over": feats.odds_over25},
|
||||
"BTTS": {"No": feats.odds_btts_no, "Yes": feats.odds_btts_yes},
|
||||
}
|
||||
|
||||
|
||||
def _best_pick(feats, all_probs: dict[str, dict[str, float]]) -> MarketPick | None:
|
||||
odds_map = _odds_map_from_features(feats)
|
||||
picks = [
|
||||
analyze_market("MS", all_probs["MS"], odds_map["MS"], feats.data_quality_score),
|
||||
analyze_market("OU25", all_probs["OU25"], odds_map["OU25"], feats.data_quality_score),
|
||||
analyze_market("BTTS", all_probs["BTTS"], odds_map["BTTS"], feats.data_quality_score),
|
||||
]
|
||||
ranked = sorted(
|
||||
[pick for pick in picks if pick.pick],
|
||||
key=lambda pick: pick.play_score,
|
||||
reverse=True,
|
||||
)
|
||||
return ranked[0] if ranked else None
|
||||
|
||||
|
||||
def _pick_won(pick: MarketPick, actuals: dict[str, str]) -> bool:
|
||||
return actuals.get(pick.market) == pick.pick
|
||||
|
||||
|
||||
async def _load_match_rows(limit: int, days: int) -> list[dict[str, object]]:
|
||||
min_mst_utc = days * 86400000
|
||||
query = text("""
|
||||
SELECT
|
||||
m.id,
|
||||
m.match_name,
|
||||
m.score_home,
|
||||
m.score_away,
|
||||
m.mst_utc
|
||||
FROM matches m
|
||||
WHERE m.sport = 'football'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.score_away IS NOT NULL
|
||||
AND m.mst_utc >= (
|
||||
EXTRACT(EPOCH FROM NOW()) * 1000 - :min_mst_utc
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM odd_categories oc
|
||||
WHERE oc.match_id = m.id
|
||||
AND oc.name IN ('Maç Sonucu', '2,5 Alt/Üst', 'Karşılıklı Gol')
|
||||
)
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT :limit
|
||||
""")
|
||||
async with get_session() as session:
|
||||
result = await session.execute(
|
||||
query,
|
||||
{"limit": limit, "min_mst_utc": min_mst_utc},
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
async def _run(limit: int, days: int) -> BacktestStats:
|
||||
stats = BacktestStats()
|
||||
predictor = get_predictor()
|
||||
rows = await _load_match_rows(limit, days)
|
||||
stats.sampled_matches = len(rows)
|
||||
|
||||
async with get_session() as session:
|
||||
for row in rows:
|
||||
match_id = str(row["id"])
|
||||
score_home = int(row["score_home"])
|
||||
score_away = int(row["score_away"])
|
||||
feats = await extract_features(session, match_id)
|
||||
|
||||
if feats is None:
|
||||
stats.skipped_matches += 1
|
||||
continue
|
||||
|
||||
if feats.data_quality_score <= 0.0:
|
||||
stats.skipped_matches += 1
|
||||
continue
|
||||
|
||||
all_probs = predictor.predict_all(feats.to_model_array(), feats)
|
||||
stats.analyzed_matches += 1
|
||||
|
||||
actuals = {
|
||||
"MS": _actual_ms(score_home, score_away),
|
||||
"OU25": _actual_ou25(score_home, score_away),
|
||||
"BTTS": _actual_btts(score_home, score_away),
|
||||
}
|
||||
|
||||
if max(all_probs["MS"], key=all_probs["MS"].get) == actuals["MS"]:
|
||||
stats.ms_correct += 1
|
||||
if max(all_probs["OU25"], key=all_probs["OU25"].get) == actuals["OU25"]:
|
||||
stats.ou25_correct += 1
|
||||
if max(all_probs["BTTS"], key=all_probs["BTTS"].get) == actuals["BTTS"]:
|
||||
stats.btts_correct += 1
|
||||
|
||||
best_pick = _best_pick(feats, all_probs)
|
||||
if best_pick is None:
|
||||
continue
|
||||
|
||||
stats.main_pick_count += 1
|
||||
if _pick_won(best_pick, actuals):
|
||||
stats.main_pick_correct += 1
|
||||
|
||||
if best_pick.playable:
|
||||
stats.playable_pick_count += 1
|
||||
stats.playable_units_staked += best_pick.stake_units
|
||||
if _pick_won(best_pick, actuals):
|
||||
stats.playable_pick_correct += 1
|
||||
stats.playable_units_profit += best_pick.stake_units * (best_pick.odds - 1.0)
|
||||
else:
|
||||
stats.playable_units_profit -= best_pick.stake_units
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def _pct(numerator: int, denominator: int) -> float:
|
||||
if denominator <= 0:
|
||||
return 0.0
|
||||
return round((numerator / denominator) * 100.0, 2)
|
||||
|
||||
|
||||
def _roi(profit: float, staked: float) -> float:
|
||||
if staked <= 0:
|
||||
return 0.0
|
||||
return round((profit / staked) * 100.0, 2)
|
||||
|
||||
|
||||
def _print_summary(stats: BacktestStats) -> None:
|
||||
print("=== V2 Runtime Backtest ===")
|
||||
print(f"Sampled matches : {stats.sampled_matches}")
|
||||
print(f"Analyzed matches : {stats.analyzed_matches}")
|
||||
print(f"Skipped matches : {stats.skipped_matches}")
|
||||
print(f"MS accuracy : {_pct(stats.ms_correct, stats.analyzed_matches)}%")
|
||||
print(f"OU2.5 accuracy : {_pct(stats.ou25_correct, stats.analyzed_matches)}%")
|
||||
print(f"BTTS accuracy : {_pct(stats.btts_correct, stats.analyzed_matches)}%")
|
||||
print(
|
||||
"Main pick accuracy : "
|
||||
f"{_pct(stats.main_pick_correct, stats.main_pick_count)}% "
|
||||
f"({stats.main_pick_correct}/{stats.main_pick_count})"
|
||||
)
|
||||
print(
|
||||
"Playable accuracy : "
|
||||
f"{_pct(stats.playable_pick_correct, stats.playable_pick_count)}% "
|
||||
f"({stats.playable_pick_correct}/{stats.playable_pick_count})"
|
||||
)
|
||||
print(f"Units staked : {stats.playable_units_staked:.2f}")
|
||||
print(f"Units profit : {stats.playable_units_profit:.2f}")
|
||||
print(f"ROI : {_roi(stats.playable_units_profit, stats.playable_units_staked)}%")
|
||||
|
||||
|
||||
async def _main() -> None:
|
||||
args = _parse_args()
|
||||
try:
|
||||
stats = await _run(args.limit, args.days)
|
||||
_print_summary(stats)
|
||||
finally:
|
||||
await dispose_engine()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(_main())
|
||||
@@ -1,147 +0,0 @@
|
||||
"""
|
||||
Value Hunter Backtest
|
||||
=====================
|
||||
Sadece modelin büroyu yendiği (Pozitif Edge) maçları oynar.
|
||||
"""
|
||||
|
||||
import os, sys, json, time, psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
AI_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT_DIR = os.path.dirname(AI_DIR)
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
if "scripts" in os.path.basename(AI_DIR): ROOT_DIR = os.path.dirname(ROOT_DIR)
|
||||
from services.single_match_orchestrator import get_single_match_orchestrator
|
||||
|
||||
def get_clean_dsn() -> str:
|
||||
return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db"
|
||||
|
||||
MATCH_IDS = [
|
||||
"v2ljcst50nk37x04xwimpi50", "7gz0bhb5yvdssazl3y5946kno", "7ftj7kbu4rzpewxravf3luuc4",
|
||||
"7f1z4e8ch1dm5q677644cky6s", "7ffq3aq3so22iymfdzch63nys", "rrkmeuymz7gzvoz8mplikzdg",
|
||||
"7hegc9covicy699bxsi81xkb8", "7gl7rpr1hjayk3e5ut0gr613o", "7g7d86i3738287xfvyfeffcwk",
|
||||
"7hs4boe4hv80muawocevvx2j8", "7ijhsloieg4t9yp5cxp0duln8", "7ixaiiptli5ek32kuybuni4gk",
|
||||
"7i5sfh41cjpwg4l972dm487x0", "eo7g4wunxxxr8uv45q8p5x638", "7dinds2937w4645wva2rddlas",
|
||||
"7b5ukdhvqh62wtndeqfg01ixg", "7bjptsj24gndoydn7n0202g44", "7cqxf3vo58ewrwmoom5xiyexg",
|
||||
"7bxjl9h2hnf165rlp3o1vfztg", "7eo8zrez08c342rqsezpvq39w", "7as1muhs98vdarlhsean4bspg",
|
||||
"7dwhj8cfxv6v6bzxpu5e3h05w", "7d4vq4417ps84yjzh95bnvvv8", "7ea9z501jgp9kxw3gay4myrkk",
|
||||
"7cd3401itlty6ded7c1wct0yc", "ebgpz9mcije2snv986n6587pw", "i7ar1dkhvcwpxmkyks65ib6c",
|
||||
"lyek7tyy6qk2xjs9vblucnx0", "hdn9qtyn3ysjwbc3i2trantg", "3y2bnssfqlajosiz2gpkn6xhw",
|
||||
"40pehd14s9djjtycujavbex3o", "3xnbfjznzmnwml20akbgnis5w", "2eovi2rcc2l4ha7fpb2w7e1hw",
|
||||
"2bwuikdjyyuithhru8ka8o00k", "2d3pcd76ya9ihi9yotxc553is", "1e9it04z4epy2etdxsffe7m6s",
|
||||
"7af49jgo4iulv1k8cplj9smj8", "5k3vrz619hdu9nx4rnx6uim1g", "amjppgpetnyr0iisi241kgkyc",
|
||||
"coqrhq09kxd16iejvgtzj3mz8", "d8ysan1qdctmkvjaz2adw7aqc", "9ttciz0gtb0z09ev1q5fe0ro4",
|
||||
"9u720o37yaddqu1w6hlszpnh0", "7ijezdjp8t0rjti91ac63hyxg", "72gvdvztbb3dn79jidzzxzcb8",
|
||||
"6uof1v2s6vrpieeml2bwo9tlg", "91dd8ia3m0bxoqzjgyo3ptsk", "3tj1nt3udsbvb9soqn2cs6gpg",
|
||||
"1br5g88o5idtjxka1fr6zg4k4", "akuesquthbmxlzckvnqmgles4"
|
||||
]
|
||||
|
||||
def run_value_hunter():
|
||||
print("💎 VALUE HUNTER: SADECE HATALI ORANLARI YAKALA")
|
||||
print("="*60)
|
||||
|
||||
dsn = get_clean_dsn()
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
placeholders = ','.join(['%s'] * len(MATCH_IDS))
|
||||
cur.execute(f"""
|
||||
SELECT m.id, m.match_name, m.home_team_id, m.away_team_id,
|
||||
m.score_home, m.score_away,
|
||||
t1.name as home_team, t2.name as away_team
|
||||
FROM matches m
|
||||
LEFT JOIN teams t1 ON m.home_team_id = t1.id
|
||||
LEFT JOIN teams t2 ON m.away_team_id = t2.id
|
||||
WHERE m.id IN ({placeholders}) AND m.status = 'FT'
|
||||
""", MATCH_IDS)
|
||||
|
||||
rows = cur.fetchall()
|
||||
print(f"📊 {len(rows)} maç taranıyor...\n")
|
||||
|
||||
try: orchestrator = get_single_match_orchestrator()
|
||||
except Exception as e:
|
||||
print(f"❌ AI Hatası: {e}")
|
||||
return
|
||||
|
||||
total_bet = 0
|
||||
total_won = 0
|
||||
total_profit = 0.0
|
||||
total_edge_found = 0
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
match_id = str(row['id'])
|
||||
home = row['home_team'] or "?"
|
||||
away = row['away_team'] or "?"
|
||||
h_score = row['score_home'] or 0
|
||||
a_score = row['score_away'] or 0
|
||||
|
||||
try:
|
||||
pred = orchestrator.analyze_match(match_id)
|
||||
if not pred: continue
|
||||
|
||||
# Tüm önerileri kontrol et
|
||||
picks = pred.get("expert_recommendation", {}).get("value_picks", [])
|
||||
if not picks: picks = [pred.get("expert_recommendation", {}).get("main_pick")]
|
||||
|
||||
played_this_match = False
|
||||
|
||||
for pick_data in picks:
|
||||
if not pick_data: continue
|
||||
pick = pick_data.get("pick")
|
||||
conf = pick_data.get("confidence", 0)
|
||||
odds = pick_data.get("odds", 0)
|
||||
edge = pick_data.get("edge", 0)
|
||||
|
||||
# VALUE KURALI: Model bürodan en az %10 daha iyi olmalı
|
||||
if edge < 10: continue
|
||||
if odds < 1.20: continue
|
||||
|
||||
total_bet += 1
|
||||
total_edge_found += edge
|
||||
won = False
|
||||
pick_clean = str(pick).upper()
|
||||
|
||||
if pick_clean in ["1", "MS 1"] and h_score > a_score: won = True
|
||||
elif pick_clean in ["X", "MS X"] and h_score == a_score: won = True
|
||||
elif pick_clean in ["2", "MS 2"] and a_score > h_score: won = True
|
||||
elif "ÜST" in pick_clean or "OVER" in pick_clean:
|
||||
line = 2.5
|
||||
if "1.5" in pick_clean: line = 1.5
|
||||
if (h_score + a_score) > line: won = True
|
||||
elif "ALT" in pick_clean or "UNDER" in pick_clean:
|
||||
line = 2.5
|
||||
if "1.5" in pick_clean: line = 1.5
|
||||
if (h_score + a_score) < line: won = True
|
||||
elif "VAR" in pick_clean and h_score > 0 and a_score > 0: won = True
|
||||
elif "YOK" in pick_clean and (h_score == 0 or a_score == 0): won = True
|
||||
|
||||
if won:
|
||||
total_won += 1
|
||||
profit = odds - 1.0
|
||||
total_profit += profit
|
||||
print(f"[{i+1}] ✅ {home} vs {away} | {pick} ({edge:.0f}% Edge) -> WON! (+{profit:.2f})")
|
||||
else:
|
||||
total_profit -= 1.0
|
||||
print(f"[{i+1}] ❌ {home} vs {away} | {pick} ({edge:.0f}% Edge) -> LOST")
|
||||
|
||||
played_this_match = True
|
||||
break # Maç başına tek bahis
|
||||
|
||||
except Exception: pass
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("💎 VALUE HUNTER SONUÇLARI")
|
||||
print("="*60)
|
||||
print(f"Toplam Value Bulunan Bahis: {total_bet}")
|
||||
print(f"Ortalama Edge: {total_edge_found/total_bet:.1f}%" if total_bet > 0 else "N/A")
|
||||
print(f"Kazanılan: {total_won}")
|
||||
print(f"Toplam Kâr: {total_profit:.2f} Units")
|
||||
|
||||
if total_profit > 0: print("🟢 PARA KAZANDIK!")
|
||||
else: print("🔴 PARA KAYBETTİK!")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_value_hunter()
|
||||
@@ -1,153 +0,0 @@
|
||||
"""
|
||||
Value Sniper Backtest (High Odds)
|
||||
=================================
|
||||
Sadece Oran > 1.50 ve Güven > %70 olan bahisleri oynar.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
AI_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT_DIR = os.path.dirname(AI_DIR)
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
if "scripts" in os.path.basename(AI_DIR):
|
||||
ROOT_DIR = os.path.dirname(ROOT_DIR)
|
||||
|
||||
from services.single_match_orchestrator import get_single_match_orchestrator
|
||||
|
||||
def get_clean_dsn() -> str:
|
||||
return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db"
|
||||
|
||||
def run_value_sniper():
|
||||
print("💰 VALUE SNIPER BACKTEST (Odds > 1.50)")
|
||||
print("="*60)
|
||||
|
||||
leagues_path = os.path.join(ROOT_DIR, "top_leagues.json")
|
||||
with open(leagues_path, 'r') as f:
|
||||
top_leagues = json.load(f)
|
||||
league_ids = tuple(str(lid) for lid in top_leagues)
|
||||
|
||||
dsn = get_clean_dsn()
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
cur.execute("""
|
||||
SELECT m.id, m.match_name, m.home_team_id, m.away_team_id,
|
||||
m.score_home, m.score_away,
|
||||
t1.name as home_team, t2.name as away_team
|
||||
FROM matches m
|
||||
LEFT JOIN teams t1 ON m.home_team_id = t1.id
|
||||
LEFT JOIN teams t2 ON m.away_team_id = t2.id
|
||||
WHERE m.league_id IN %s
|
||||
AND m.status = 'FT'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM odd_categories oc WHERE oc.match_id = m.id)
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT 500
|
||||
""", (league_ids,))
|
||||
|
||||
rows = cur.fetchall()
|
||||
print(f"📊 {len(rows)} maç taranıyor...\n")
|
||||
|
||||
try: orchestrator = get_single_match_orchestrator()
|
||||
except Exception as e:
|
||||
print(f"❌ AI Hatası: {e}")
|
||||
return
|
||||
|
||||
total_bet = 0
|
||||
total_won = 0
|
||||
total_profit = 0.0
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
match_id = str(row['id'])
|
||||
home = row['home_team'] or "?"
|
||||
away = row['away_team'] or "?"
|
||||
h_score = row['score_home'] or 0
|
||||
a_score = row['score_away'] or 0
|
||||
|
||||
try:
|
||||
pred = orchestrator.analyze_match(match_id)
|
||||
if not pred: continue
|
||||
|
||||
candidates = []
|
||||
if pred.get("expert_recommendation"):
|
||||
rec = pred["expert_recommendation"]
|
||||
if rec.get("main_pick"): candidates.append(rec["main_pick"])
|
||||
if rec.get("value_picks"): candidates.extend(rec["value_picks"])
|
||||
elif pred.get("main_pick"):
|
||||
candidates.append(pred["main_pick"])
|
||||
|
||||
best_bet = None
|
||||
for c in candidates:
|
||||
if not c: continue
|
||||
conf = c.get("confidence", 0) if isinstance(c, dict) else getattr(c, 'confidence', 0)
|
||||
odds = c.get("odds", 0) if isinstance(c, dict) else getattr(c, 'odds', 0)
|
||||
|
||||
# VALUE CRITERIA: Odds > 1.50 AND Conf > 70%
|
||||
if conf >= 70.0 and odds >= 1.50:
|
||||
# Check Edge
|
||||
implied = 1.0 / odds
|
||||
edge = ((conf/100) - implied) * 100
|
||||
if edge > 0: # Must be positive value
|
||||
if best_bet is None or (conf > (best_bet.get("confidence", 0) if isinstance(best_bet, dict) else getattr(best_bet, 'confidence', 0))):
|
||||
best_bet = c
|
||||
|
||||
if best_bet:
|
||||
pick = str(best_bet.get("pick") if isinstance(best_bet, dict) else getattr(best_bet, 'pick', "")).upper()
|
||||
conf = best_bet.get("confidence", 0) if isinstance(best_bet, dict) else getattr(best_bet, 'confidence', 0)
|
||||
odds = best_bet.get("odds", 0) if isinstance(best_bet, dict) else getattr(best_bet, 'odds', 0)
|
||||
|
||||
won = False
|
||||
if pick in ["1", "MS 1"] and h_score > a_score: won = True
|
||||
elif pick in ["X", "MS X"] and h_score == a_score: won = True
|
||||
elif pick in ["2", "MS 2"] and a_score > h_score: won = True
|
||||
elif "ÜST" in pick or "OVER" in pick:
|
||||
line = 2.5
|
||||
if "1.5" in pick: line = 1.5
|
||||
elif "3.5" in pick: line = 3.5
|
||||
if (h_score + a_score) > line: won = True
|
||||
elif "ALT" in pick or "UNDER" in pick:
|
||||
line = 2.5
|
||||
if "1.5" in pick: line = 1.5
|
||||
elif "3.5" in pick: line = 3.5
|
||||
if (h_score + a_score) < line: won = True
|
||||
elif "VAR" in pick and h_score > 0 and a_score > 0: won = True
|
||||
elif "YOK" in pick and (h_score == 0 or a_score == 0): won = True
|
||||
|
||||
total_bet += 1
|
||||
if won:
|
||||
total_won += 1
|
||||
profit = odds - 1.0
|
||||
total_profit += profit
|
||||
print(f"[{i+1}] ✅ {home} vs {away} | {pick} ({odds:.2f}) -> WON (+{profit:.2f})")
|
||||
else:
|
||||
total_profit -= 1.0
|
||||
print(f"[{i+1}] ❌ {home} vs {away} | {pick} ({odds:.2f}) -> LOST")
|
||||
|
||||
except: pass
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("💰 VALUE SNIPER SONUÇLARI")
|
||||
print("="*60)
|
||||
print(f"Oynanan Bahis: {total_bet}")
|
||||
print(f"Kazanılan: {total_won}")
|
||||
|
||||
if total_bet > 0:
|
||||
win_rate = (total_won / total_bet) * 100
|
||||
roi = (total_profit / total_bet) * 100
|
||||
print(f"Kazanma Oranı: %{win_rate:.2f}")
|
||||
print(f"Toplam Kâr: {total_profit:.2f} Units")
|
||||
if total_profit > 0: print("🟢 PARA KAZANDIK!")
|
||||
else: print("🔴 PARA KAYBETTİK!")
|
||||
else:
|
||||
print("⚠️ Yeterli VALUE bulunamadı.")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_value_sniper()
|
||||
@@ -1,136 +0,0 @@
|
||||
"""
|
||||
VQWEN Full Backtest
|
||||
===================
|
||||
Tests all 3 VQWEN models (MS, OU25, BTTS) on 1000 historical matches.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import pickle
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
AI_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT_DIR = os.path.dirname(AI_DIR)
|
||||
PROJECT_ROOT = os.path.dirname(ROOT_DIR)
|
||||
|
||||
def get_clean_dsn() -> str:
|
||||
return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db"
|
||||
|
||||
def run_vqwen_backtest():
|
||||
print("🧠 VQWEN FULL BACKTEST")
|
||||
print("="*60)
|
||||
|
||||
# Load Models
|
||||
mdir = os.path.join(ROOT_DIR, 'models', 'vqwen')
|
||||
try:
|
||||
with open(os.path.join(mdir, 'vqwen_ms.pkl'), 'rb') as f: model_ms = pickle.load(f)
|
||||
with open(os.path.join(mdir, 'vqwen_ou25.pkl'), 'rb') as f: model_ou = pickle.load(f)
|
||||
with open(os.path.join(mdir, 'vqwen_btts.pkl'), 'rb') as f: model_btts = pickle.load(f)
|
||||
print("✅ VQWEN MS, OU25, BTTS modelleri yüklendi.")
|
||||
except Exception as e:
|
||||
print(f"❌ Model hatası: {e}")
|
||||
return
|
||||
|
||||
with open(os.path.join(PROJECT_ROOT, "top_leagues.json"), 'r') as f:
|
||||
league_ids = tuple(str(lid) for lid in json.load(f))
|
||||
|
||||
dsn = get_clean_dsn()
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
cur.execute("""
|
||||
SELECT m.id, m.home_team_id, m.away_team_id, m.score_home, m.score_away,
|
||||
t1.name as home_team, t2.name as away_team,
|
||||
(SELECT os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = m.id AND oc.name ILIKE 'Maç Sonucu' AND os.name = '1' LIMIT 1) as oh,
|
||||
(SELECT os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = m.id AND oc.name ILIKE 'Maç Sonucu' AND os.name = 'X' LIMIT 1) as od,
|
||||
(SELECT os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = m.id AND oc.name ILIKE 'Maç Sonucu' AND os.name = '2' LIMIT 1) as oa,
|
||||
COALESCE((SELECT AVG(CASE WHEN m2.home_team_id = m.home_team_id AND m2.score_home > m2.score_away THEN 3 WHEN m2.home_team_id = m.home_team_id AND m2.score_home = m2.score_away THEN 1 ELSE 0 END) FROM matches m2 WHERE m2.home_team_id = m.home_team_id AND m2.status = 'FT' AND m2.mst_utc < m.mst_utc LIMIT 5), 0) as h_form,
|
||||
COALESCE((SELECT AVG(CASE WHEN m2.away_team_id = m.away_team_id AND m2.score_away > m2.score_home THEN 3 WHEN m2.away_team_id = m.away_team_id AND m2.score_away = m2.score_home THEN 1 ELSE 0 END) FROM matches m2 WHERE m2.away_team_id = m.away_team_id AND m2.status = 'FT' AND m2.mst_utc < m.mst_utc LIMIT 5), 0) as a_form,
|
||||
COALESCE((SELECT AVG(m2.score_home) FROM matches m2 WHERE m2.home_team_id = m.home_team_id AND m2.status = 'FT' LIMIT 10), 1.2) as h_sc,
|
||||
COALESCE((SELECT AVG(m2.score_away) FROM matches m2 WHERE m2.away_team_id = m.home_team_id AND m2.status = 'FT' LIMIT 10), 1.2) as h_co,
|
||||
COALESCE((SELECT AVG(m2.score_away) FROM matches m2 WHERE m2.away_team_id = m.away_team_id AND m2.status = 'FT' LIMIT 10), 1.2) as a_sc,
|
||||
COALESCE((SELECT AVG(m2.score_home) FROM matches m2 WHERE m2.home_team_id = m.away_team_id AND m2.status = 'FT' LIMIT 10), 1.2) as a_co
|
||||
FROM matches m
|
||||
LEFT JOIN teams t1 ON m.home_team_id = t1.id
|
||||
LEFT JOIN teams t2 ON m.away_team_id = t2.id
|
||||
WHERE m.league_id IN %s AND m.status = 'FT' AND m.score_home IS NOT NULL
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT 1000
|
||||
""", (league_ids,))
|
||||
|
||||
rows = cur.fetchall()
|
||||
print(f"📊 {len(rows)} maç analiz ediliyor...")
|
||||
|
||||
results = {'ms': {'bet': 0, 'won': 0, 'profit': 0}, 'ou25': {'bet': 0, 'won': 0, 'profit': 0}, 'btts': {'bet': 0, 'won': 0, 'profit': 0}}
|
||||
|
||||
for row in rows:
|
||||
oh, od, oa = float(row['oh'] or 0), float(row['od'] or 0), float(row['oa'] or 0)
|
||||
if oh <= 1.0 or od <= 1.0 or oa <= 1.0: continue
|
||||
|
||||
h_xg = (float(row['h_sc'] or 1.2) + float(row['a_co'] or 1.2)) / 2
|
||||
a_xg = (float(row['a_sc'] or 1.2) + float(row['h_co'] or 1.2)) / 2
|
||||
h_p = (float(row['h_form'] or 0)*10) + (float(row['h_sc'] or 1.2)*5) - (float(row['h_co'] or 1.2)*5)
|
||||
a_p = (float(row['a_form'] or 0)*10) + (float(row['a_sc'] or 1.2)*5) - (float(row['a_co'] or 1.2)*5)
|
||||
|
||||
margin = (1/oh) + (1/od) + (1/oa)
|
||||
|
||||
# MS Prediction
|
||||
f_ms = pd.DataFrame([{'h_form': float(row['h_form']), 'a_form': float(row['a_form']), 'h_xg': h_xg, 'a_xg': a_xg,
|
||||
'pow_diff': h_p - a_p, 'imp_h': (1/oh)/margin, 'imp_d': (1/od)/margin, 'imp_a': (1/oa)/margin,
|
||||
'h_sot': 4.0, 'a_sot': 3.0}])
|
||||
ms_probs = model_ms.predict(f_ms)[0]
|
||||
|
||||
# MS Value Bet
|
||||
for i, (pick, prob, odd) in enumerate(zip(['1', 'X', '2'], ms_probs, [oh, od, oa])):
|
||||
if odd <= 1.0: continue
|
||||
edge = prob - (1/odd)
|
||||
if edge > 0.05 and prob > 0.50: # Value ve Güven
|
||||
results['ms']['bet'] += 1
|
||||
h, a = row['score_home'], row['score_away']
|
||||
w = (pick=='1' and h>a) or (pick=='X' and h==a) or (pick=='2' and a>h)
|
||||
if w: results['ms']['won'] += 1; results['ms']['profit'] += (odd - 1.0)
|
||||
else: results['ms']['profit'] -= 1.0
|
||||
break
|
||||
|
||||
# OU2.5 Prediction
|
||||
f_ou = pd.DataFrame([{'h_xg': h_xg, 'a_xg': a_xg, 'total_xg': h_xg+a_xg, 'h_sot': 4.0, 'a_sot': 3.0}])
|
||||
p_over = model_ou.predict(f_ou)[0]
|
||||
|
||||
# OU2.5 Value Bet
|
||||
if p_over > 0.55 and oh > 1.0: # Sadece örnek olarak over > %55 ise
|
||||
results['ou25']['bet'] += 1
|
||||
if (row['score_home'] + row['score_away']) > 2.5: results['ou25']['won'] += 1; results['ou25']['profit'] += 0.85 # Ortalama oran
|
||||
else: results['ou25']['profit'] -= 1.0
|
||||
|
||||
# BTTS Prediction
|
||||
f_btts = pd.DataFrame([{'h_xg': h_xg, 'a_xg': a_xg, 'h_sc': float(row['h_sc']), 'a_sc': float(row['a_sc'])}])
|
||||
p_btts = model_btts.predict(f_btts)[0]
|
||||
|
||||
# BTTS Value Bet
|
||||
if p_btts > 0.55:
|
||||
results['btts']['bet'] += 1
|
||||
if row['score_home'] > 0 and row['score_away'] > 0: results['btts']['won'] += 1; results['btts']['profit'] += 0.85
|
||||
else: results['btts']['profit'] -= 1.0
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("📊 VQWEN PAZAR BAZLI SONUÇLAR")
|
||||
print("="*60)
|
||||
for mkt in ['ms', 'ou25', 'btts']:
|
||||
r = results[mkt]
|
||||
wr = (r['won'] / r['bet'] * 100) if r['bet'] > 0 else 0
|
||||
print(f"{mkt.upper():<10} Oynanan: {r['bet']:<5} Kazanılan: {r['won']:<5} WR: {wr:.1f}% Kâr: {r['profit']:+.2f} Units")
|
||||
|
||||
total_profit = sum(r['profit'] for r in results.values())
|
||||
print(f"\n💰 TOPLAM KÂR: {total_profit:+.2f} Units")
|
||||
if total_profit > 0: print("🟢 PARA KAZANDIK!")
|
||||
else: print("🔴 ZARARDA")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_vqwen_backtest()
|
||||
@@ -1,141 +0,0 @@
|
||||
"""
|
||||
VQWEN Deep Backtest
|
||||
===================
|
||||
Tests the NEW Deep model with player & card data.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import pickle
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
AI_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT_DIR = os.path.dirname(AI_DIR)
|
||||
PROJECT_ROOT = os.path.dirname(ROOT_DIR)
|
||||
|
||||
def get_clean_dsn() -> str:
|
||||
return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db"
|
||||
|
||||
def run_vqwen_deep_backtest():
|
||||
print("🧠 VQWEN DEEP BACKTEST")
|
||||
print("="*60)
|
||||
|
||||
# Load Models
|
||||
mdir = os.path.join(ROOT_DIR, 'models', 'vqwen')
|
||||
try:
|
||||
with open(os.path.join(mdir, 'vqwen_ms.pkl'), 'rb') as f: model_ms = pickle.load(f)
|
||||
with open(os.path.join(mdir, 'vqwen_ou25.pkl'), 'rb') as f: model_ou = pickle.load(f)
|
||||
with open(os.path.join(mdir, 'vqwen_btts.pkl'), 'rb') as f: model_btts = pickle.load(f)
|
||||
print("✅ VQWEN Deep modelleri yüklendi.")
|
||||
except Exception as e:
|
||||
print(f"❌ Model hatası: {e}")
|
||||
return
|
||||
|
||||
with open(os.path.join(PROJECT_ROOT, "top_leagues.json"), 'r') as f:
|
||||
league_ids = tuple(str(lid) for lid in json.load(f))
|
||||
|
||||
dsn = get_clean_dsn()
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
cur.execute("""
|
||||
SELECT m.id, m.home_team_id, m.away_team_id, m.score_home, m.score_away,
|
||||
t1.name as home_team, t2.name as away_team,
|
||||
(SELECT os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = m.id AND oc.name ILIKE 'Maç Sonucu' AND os.name = '1' LIMIT 1) as oh,
|
||||
(SELECT os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = m.id AND oc.name ILIKE 'Maç Sonucu' AND os.name = 'X' LIMIT 1) as od,
|
||||
(SELECT os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = m.id AND oc.name ILIKE 'Maç Sonucu' AND os.name = '2' LIMIT 1) as oa,
|
||||
COALESCE((SELECT AVG(CASE WHEN m2.home_team_id = m.home_team_id AND m2.score_home > m2.score_away THEN 3 WHEN m2.home_team_id = m.home_team_id AND m2.score_home = m2.score_away THEN 1 ELSE 0 END) FROM matches m2 WHERE m2.home_team_id = m.home_team_id AND m2.status = 'FT' AND m2.mst_utc < m.mst_utc LIMIT 5), 0) as h_form,
|
||||
COALESCE((SELECT AVG(CASE WHEN m2.away_team_id = m.away_team_id AND m2.score_away > m2.score_home THEN 3 WHEN m2.away_team_id = m.away_team_id AND m2.score_away = m2.score_home THEN 1 ELSE 0 END) FROM matches m2 WHERE m2.away_team_id = m.away_team_id AND m2.status = 'FT' AND m2.mst_utc < m.mst_utc LIMIT 5), 0) as a_form,
|
||||
COALESCE((SELECT AVG(m2.score_home) FROM matches m2 WHERE m2.home_team_id = m.home_team_id AND m2.status = 'FT' LIMIT 10), 1.2) as h_sc,
|
||||
COALESCE((SELECT AVG(m2.score_away) FROM matches m2 WHERE m2.away_team_id = m.home_team_id AND m2.status = 'FT' LIMIT 10), 1.2) as h_co,
|
||||
COALESCE((SELECT AVG(m2.score_away) FROM matches m2 WHERE m2.away_team_id = m.away_team_id AND m2.status = 'FT' LIMIT 10), 1.2) as a_sc,
|
||||
COALESCE((SELECT AVG(m2.score_home) FROM matches m2 WHERE m2.home_team_id = m.away_team_id AND m2.status = 'FT' LIMIT 10), 1.2) as a_co,
|
||||
COALESCE((SELECT COUNT(*) FROM match_player_participation mp WHERE mp.match_id = m.id AND mp.team_id = m.home_team_id AND mp.is_starting = true), 0) as h_xi,
|
||||
COALESCE((SELECT COUNT(*) FROM match_player_participation mp WHERE mp.match_id = m.id AND mp.team_id = m.away_team_id AND mp.is_starting = true), 0) as a_xi,
|
||||
COALESCE((SELECT COUNT(*) FROM match_player_events mpe WHERE mpe.match_id = m.id AND mpe.event_type = 'card'), 0) as cards
|
||||
FROM matches m
|
||||
LEFT JOIN teams t1 ON m.home_team_id = t1.id
|
||||
LEFT JOIN teams t2 ON m.away_team_id = t2.id
|
||||
WHERE m.league_id IN %s AND m.status = 'FT' AND m.score_home IS NOT NULL
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT 1000
|
||||
""", (league_ids,))
|
||||
|
||||
rows = cur.fetchall()
|
||||
print(f"📊 {len(rows)} maç analiz ediliyor...")
|
||||
|
||||
results = {'ms': {'bet': 0, 'won': 0, 'profit': 0}, 'ou25': {'bet': 0, 'won': 0, 'profit': 0}, 'btts': {'bet': 0, 'won': 0, 'profit': 0}}
|
||||
|
||||
for row in rows:
|
||||
oh = float(row['oh'] or 0)
|
||||
od = float(row['od'] or 0)
|
||||
oa = float(row['oa'] or 0)
|
||||
if oh <= 1.0 or od <= 1.0 or oa <= 1.0: continue
|
||||
|
||||
h_xg = (float(row['h_sc'] or 1.2) + float(row['a_co'] or 1.2)) / 2
|
||||
a_xg = (float(row['a_sc'] or 1.2) + float(row['h_co'] or 1.2)) / 2
|
||||
h_p = (float(row['h_form'] or 0)*10) + (float(row['h_sc'] or 1.2)*5) - (float(row['h_co'] or 1.2)*5)
|
||||
a_p = (float(row['a_form'] or 0)*10) + (float(row['a_sc'] or 1.2)*5) - (float(row['a_co'] or 1.2)*5)
|
||||
|
||||
margin = (1/oh) + (1/od) + (1/oa)
|
||||
h_sot, a_sot = 4.0, 3.0
|
||||
|
||||
# Features
|
||||
f = pd.DataFrame([{
|
||||
'h_form': float(row['h_form']), 'a_form': float(row['a_form']),
|
||||
'h_xg': h_xg, 'a_xg': a_xg, 'pow_diff': h_p - a_p,
|
||||
'imp_h': (1/oh)/margin, 'imp_d': (1/od)/margin, 'imp_a': (1/oa)/margin,
|
||||
'h_sot': h_sot, 'a_sot': a_sot,
|
||||
'h_xi': float(row['h_xi']), 'a_xi': float(row['a_xi']),
|
||||
'xi_diff': float(row['h_xi'] - row['a_xi']),
|
||||
'cards': float(row['cards'])
|
||||
}])
|
||||
|
||||
# MS
|
||||
ms_probs = model_ms.predict(f)[0]
|
||||
for i, (pick, prob, odd) in enumerate(zip(['1', 'X', '2'], ms_probs, [oh, od, oa])):
|
||||
if odd <= 1.0: continue
|
||||
edge = prob - (1/odd)
|
||||
if edge > 0.05 and prob > 0.50:
|
||||
results['ms']['bet'] += 1
|
||||
h, a = row['score_home'], row['score_away']
|
||||
w = (pick=='1' and h>a) or (pick=='X' and h==a) or (pick=='2' and a>h)
|
||||
if w: results['ms']['won'] += 1; results['ms']['profit'] += (odd - 1.0)
|
||||
else: results['ms']['profit'] -= 1.0
|
||||
break
|
||||
|
||||
# OU2.5
|
||||
p_over = float(model_ou.predict(f)[0])
|
||||
if p_over > 0.55:
|
||||
results['ou25']['bet'] += 1
|
||||
if (row['score_home'] + row['score_away']) > 2.5: results['ou25']['won'] += 1; results['ou25']['profit'] += 0.85
|
||||
else: results['ou25']['profit'] -= 1.0
|
||||
|
||||
# BTTS
|
||||
p_btts = float(model_btts.predict(f)[0])
|
||||
if p_btts > 0.55:
|
||||
results['btts']['bet'] += 1
|
||||
if row['score_home'] > 0 and row['score_away'] > 0: results['btts']['won'] += 1; results['btts']['profit'] += 0.85
|
||||
else: results['btts']['profit'] -= 1.0
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("📊 VQWEN DEEP SONUÇLAR")
|
||||
print("="*60)
|
||||
for mkt in ['ms', 'ou25', 'btts']:
|
||||
r = results[mkt]
|
||||
wr = (r['won'] / r['bet'] * 100) if r['bet'] > 0 else 0
|
||||
print(f"{mkt.upper():<10} Oyn: {r['bet']:<5} Kaz: {r['won']:<5} WR: {wr:.1f}% Kâr: {r['profit']:+.2f}")
|
||||
|
||||
total = sum(r['profit'] for r in results.values())
|
||||
print(f"\n💰 TOPLAM: {total:+.2f} Units")
|
||||
print("🟢 PARA KAZANDIK!" if total > 0 else "🔴 ZARARDA")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_vqwen_deep_backtest()
|
||||
@@ -1,159 +0,0 @@
|
||||
"""
|
||||
VQWEN Final Backtest
|
||||
====================
|
||||
Tests the Final Model (ELO + Rest + Context).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import pickle
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
AI_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT_DIR = os.path.dirname(AI_DIR)
|
||||
PROJECT_ROOT = os.path.dirname(ROOT_DIR)
|
||||
|
||||
def get_clean_dsn() -> str:
|
||||
return "postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db"
|
||||
|
||||
def run_final_backtest():
|
||||
print("🧠 VQWEN FINAL BACKTEST (ELO + REST)")
|
||||
print("="*60)
|
||||
|
||||
# Load Models
|
||||
mdir = os.path.join(ROOT_DIR, 'models', 'vqwen')
|
||||
try:
|
||||
with open(os.path.join(mdir, 'vqwen_ms.pkl'), 'rb') as f: model_ms = pickle.load(f)
|
||||
with open(os.path.join(mdir, 'vqwen_ou25.pkl'), 'rb') as f: model_ou = pickle.load(f)
|
||||
with open(os.path.join(mdir, 'vqwen_btts.pkl'), 'rb') as f: model_btts = pickle.load(f)
|
||||
print("✅ VQWEN Final modelleri yüklendi.")
|
||||
except Exception as e:
|
||||
print(f"❌ Model hatası: {e}")
|
||||
return
|
||||
|
||||
with open(os.path.join(PROJECT_ROOT, "top_leagues.json"), 'r') as f:
|
||||
league_ids = tuple(str(lid) for lid in json.load(f))
|
||||
|
||||
dsn = get_clean_dsn()
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
cur.execute("""
|
||||
SELECT m.id, m.home_team_id, m.away_team_id, m.score_home, m.score_away,
|
||||
m.mst_utc,
|
||||
t1.name as home_team, t2.name as away_team,
|
||||
maf.home_elo, maf.away_elo,
|
||||
COALESCE((SELECT AVG(m2.score_home) FROM matches m2 WHERE m2.home_team_id = m.home_team_id AND m2.status = 'FT' AND m2.mst_utc < m.mst_utc), 1.2) as h_home_goals,
|
||||
COALESCE((SELECT AVG(m2.score_away) FROM matches m2 WHERE m2.away_team_id = m.away_team_id AND m2.status = 'FT' AND m2.mst_utc < m.mst_utc), 1.2) as a_away_goals,
|
||||
COALESCE(EXTRACT(EPOCH FROM (to_timestamp(m.mst_utc/1000) - (SELECT MAX(to_timestamp(m2.mst_utc/1000)) FROM matches m2 WHERE m2.home_team_id = m.home_team_id AND m2.status = 'FT' AND m2.mst_utc < m.mst_utc)) / 86400), 7) as h_rest,
|
||||
COALESCE(EXTRACT(EPOCH FROM (to_timestamp(m.mst_utc/1000) - (SELECT MAX(to_timestamp(m2.mst_utc/1000)) FROM matches m2 WHERE m2.away_team_id = m.away_team_id AND m2.status = 'FT' AND m2.mst_utc < m.mst_utc)) / 86400), 7) as a_rest,
|
||||
COALESCE((SELECT COUNT(*) FROM match_player_participation mp WHERE mp.match_id = m.id AND mp.team_id = m.home_team_id AND mp.is_starting = true), 11) as h_xi,
|
||||
COALESCE((SELECT COUNT(*) FROM match_player_participation mp WHERE mp.match_id = m.id AND mp.team_id = m.away_team_id AND mp.is_starting = true), 11) as a_xi,
|
||||
COALESCE((SELECT COUNT(*) FROM match_player_events mpe WHERE mpe.match_id = m.id AND mpe.event_type = 'card'), 4) as cards,
|
||||
(SELECT os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = m.id AND oc.name ILIKE 'Maç Sonucu' AND os.name = '1' LIMIT 1) as oh,
|
||||
(SELECT os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = m.id AND oc.name ILIKE 'Maç Sonucu' AND os.name = 'X' LIMIT 1) as od,
|
||||
(SELECT os.odd_value FROM odd_categories oc JOIN odd_selections os ON os.odd_category_db_id = oc.db_id WHERE oc.match_id = m.id AND oc.name ILIKE 'Maç Sonucu' AND os.name = '2' LIMIT 1) as oa
|
||||
FROM matches m
|
||||
LEFT JOIN teams t1 ON m.home_team_id = t1.id
|
||||
LEFT JOIN teams t2 ON m.away_team_id = t2.id
|
||||
LEFT JOIN football_ai_features maf ON maf.match_id = m.id
|
||||
WHERE m.league_id IN %s AND m.status = 'FT' AND m.score_home IS NOT NULL
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT 1000
|
||||
""", (league_ids,))
|
||||
|
||||
rows = cur.fetchall()
|
||||
print(f"📊 {len(rows)} maç analiz ediliyor...")
|
||||
|
||||
results = {'ms': {'bet': 0, 'won': 0, 'profit': 0}, 'ou25': {'bet': 0, 'won': 0, 'profit': 0}, 'btts': {'bet': 0, 'won': 0, 'profit': 0}}
|
||||
|
||||
for row in rows:
|
||||
oh = float(row['oh'] or 0)
|
||||
od = float(row['od'] or 0)
|
||||
oa = float(row['oa'] or 0)
|
||||
if oh <= 1.0 or od <= 1.0 or oa <= 1.0: continue
|
||||
|
||||
# Features
|
||||
h_elo = float(row['home_elo'] or 1500)
|
||||
a_elo = float(row['away_elo'] or 1500)
|
||||
h_home_goals = float(row['h_home_goals'] or 1.2)
|
||||
a_away_goals = float(row['a_away_goals'] or 1.2)
|
||||
h_rest = float(row['h_rest'] or 7)
|
||||
a_rest = float(row['a_rest'] or 7)
|
||||
h_xi = float(row['h_xi'] or 11)
|
||||
a_xi = float(row['a_xi'] or 11)
|
||||
cards = float(row['cards'] or 4)
|
||||
|
||||
def fatigue(rest):
|
||||
if rest < 3: return 0.85
|
||||
if rest < 5: return 0.95
|
||||
return 1.0
|
||||
|
||||
h_fat = fatigue(h_rest)
|
||||
a_fat = fatigue(a_rest)
|
||||
|
||||
h_xg = h_home_goals * h_fat
|
||||
a_xg = a_away_goals * a_fat
|
||||
total_xg = h_xg + a_xg
|
||||
|
||||
margin = (1/oh) + (1/od) + (1/oa)
|
||||
f = pd.DataFrame([{
|
||||
'elo_diff': h_elo - a_elo,
|
||||
'h_xg': h_xg, 'a_xg': a_xg,
|
||||
'total_xg': total_xg,
|
||||
'pow_diff': (h_elo/100)*h_fat - (a_elo/100)*a_fat,
|
||||
'rest_diff': h_rest - a_rest,
|
||||
'h_fatigue': h_fat, 'a_fatigue': a_fat,
|
||||
'imp_h': (1/oh)/margin, 'imp_d': (1/od)/margin, 'imp_a': (1/oa)/margin,
|
||||
'h_xi': h_xi, 'a_xi': a_xi,
|
||||
'cards': cards
|
||||
}])
|
||||
|
||||
# MS
|
||||
ms_probs = model_ms.predict(f)[0]
|
||||
for i, (pick, prob, odd) in enumerate(zip(['1', 'X', '2'], ms_probs, [oh, od, oa])):
|
||||
if odd <= 1.0: continue
|
||||
edge = prob - (1/odd)
|
||||
if edge > 0.05 and prob > 0.45:
|
||||
results['ms']['bet'] += 1
|
||||
h, a = row['score_home'], row['score_away']
|
||||
w = (pick=='1' and h>a) or (pick=='X' and h==a) or (pick=='2' and a>h)
|
||||
if w: results['ms']['won'] += 1; results['ms']['profit'] += (odd - 1.0)
|
||||
else: results['ms']['profit'] -= 1.0
|
||||
break
|
||||
|
||||
# OU2.5
|
||||
p_over = float(model_ou.predict(f)[0])
|
||||
if p_over > 0.55:
|
||||
results['ou25']['bet'] += 1
|
||||
if (row['score_home'] + row['score_away']) > 2.5: results['ou25']['won'] += 1; results['ou25']['profit'] += 0.85
|
||||
else: results['ou25']['profit'] -= 1.0
|
||||
|
||||
# BTTS
|
||||
p_btts = float(model_btts.predict(f)[0])
|
||||
if p_btts > 0.55:
|
||||
results['btts']['bet'] += 1
|
||||
if row['score_home'] > 0 and row['score_away'] > 0: results['btts']['won'] += 1; results['btts']['profit'] += 0.85
|
||||
else: results['btts']['profit'] -= 1.0
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("📊 VQWEN FINAL SONUÇLAR")
|
||||
print("="*60)
|
||||
for mkt in ['ms', 'ou25', 'btts']:
|
||||
r = results[mkt]
|
||||
wr = (r['won'] / r['bet'] * 100) if r['bet'] > 0 else 0
|
||||
print(f"{mkt.upper():<10} Oyn: {r['bet']:<5} Kaz: {r['won']:<5} WR: {wr:.1f}% Kâr: {r['profit']:+.2f}")
|
||||
|
||||
total = sum(r['profit'] for r in results.values())
|
||||
print(f"\n💰 TOPLAM: {total:+.2f} Units")
|
||||
print("🟢 PARA KAZANDIK!" if total > 0 else "🔴 ZARARDA")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_final_backtest()
|
||||
@@ -1,182 +0,0 @@
|
||||
"""
|
||||
VQWEN v3 Shared-Contract Backtest
|
||||
=================================
|
||||
|
||||
Evaluates the retrained VQWEN models on the temporal validation slice using
|
||||
the exact same pre-match feature contract as training/runtime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import psycopg2
|
||||
from dotenv import load_dotenv
|
||||
|
||||
AI_DIR = Path(__file__).resolve().parent
|
||||
ENGINE_DIR = AI_DIR.parent
|
||||
REPO_DIR = ENGINE_DIR.parent
|
||||
MODELS_DIR = ENGINE_DIR / "models" / "vqwen"
|
||||
|
||||
if str(ENGINE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ENGINE_DIR))
|
||||
|
||||
from features.vqwen_contract import FEATURE_COLUMNS # noqa: E402
|
||||
from train_vqwen_v3 import ( # noqa: E402
|
||||
_enrich_pre_match_context,
|
||||
_fetch_dataframe,
|
||||
_prepare_features,
|
||||
_temporal_split,
|
||||
load_top_league_ids,
|
||||
)
|
||||
|
||||
|
||||
def _load_env() -> None:
|
||||
load_dotenv(REPO_DIR / ".env", override=False)
|
||||
load_dotenv(ENGINE_DIR / ".env", override=False)
|
||||
|
||||
|
||||
def get_clean_dsn() -> str:
|
||||
_load_env()
|
||||
raw = os.getenv("DATABASE_URL", "").strip().strip('"').strip("'")
|
||||
if not raw:
|
||||
raise RuntimeError("DATABASE_URL is missing.")
|
||||
return raw.split("?", 1)[0]
|
||||
|
||||
|
||||
def _accuracy(y_true: np.ndarray, y_pred: np.ndarray) -> float:
|
||||
if len(y_true) == 0:
|
||||
return 0.0
|
||||
return float((y_true == y_pred).mean())
|
||||
|
||||
|
||||
def _binary_metrics(prob: np.ndarray, y_true: np.ndarray) -> tuple[float, float]:
|
||||
pred = (prob >= 0.5).astype(int)
|
||||
acc = _accuracy(y_true, pred)
|
||||
brier = float(np.mean((prob - y_true) ** 2)) if len(y_true) else 1.0
|
||||
return acc, brier
|
||||
|
||||
|
||||
def _multiclass_brier(prob: np.ndarray, y_true: np.ndarray, n_classes: int = 3) -> float:
|
||||
if len(y_true) == 0:
|
||||
return 1.0
|
||||
target = np.zeros((len(y_true), n_classes), dtype=np.float64)
|
||||
target[np.arange(len(y_true)), y_true.astype(int)] = 1.0
|
||||
return float(np.mean(np.sum((prob - target) ** 2, axis=1)))
|
||||
|
||||
|
||||
def _band_label(probability: float) -> str:
|
||||
if probability >= 0.70:
|
||||
return "HIGH"
|
||||
if probability >= 0.60:
|
||||
return "MEDIUM"
|
||||
if probability >= 0.50:
|
||||
return "LOW"
|
||||
return "NO_BET"
|
||||
|
||||
|
||||
def _summarize_bands(
|
||||
name: str,
|
||||
confidence: np.ndarray,
|
||||
is_correct: np.ndarray,
|
||||
) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for band in ("HIGH", "MEDIUM", "LOW"):
|
||||
mask = np.array([_band_label(float(p)) == band for p in confidence], dtype=bool)
|
||||
count = int(mask.sum())
|
||||
accuracy = float(is_correct[mask].mean()) if count else 0.0
|
||||
avg_conf = float(confidence[mask].mean()) if count else 0.0
|
||||
lines.append(
|
||||
f"{name} {band:<6} count={count:<4} accuracy={accuracy*100:5.1f}% avg_conf={avg_conf*100:5.1f}%"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def run_v3_backtest() -> None:
|
||||
print("VQWEN v3 SHARED-CONTRACT BACKTEST")
|
||||
print("=" * 60)
|
||||
|
||||
league_ids = load_top_league_ids()
|
||||
dsn = get_clean_dsn()
|
||||
|
||||
with psycopg2.connect(dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
df = _fetch_dataframe(cur, league_ids)
|
||||
df = _enrich_pre_match_context(cur, df)
|
||||
df = _prepare_features(df)
|
||||
|
||||
train_df, valid_df = _temporal_split(df)
|
||||
print(f"Toplam ornek: {len(df)} | Train: {len(train_df)} | Valid: {len(valid_df)}")
|
||||
|
||||
with (MODELS_DIR / "vqwen_ms.pkl").open("rb") as handle:
|
||||
model_ms = pickle.load(handle)
|
||||
with (MODELS_DIR / "vqwen_ou25.pkl").open("rb") as handle:
|
||||
model_ou25 = pickle.load(handle)
|
||||
with (MODELS_DIR / "vqwen_btts.pkl").open("rb") as handle:
|
||||
model_btts = pickle.load(handle)
|
||||
|
||||
X_valid = valid_df[FEATURE_COLUMNS]
|
||||
y_ms = valid_df["t_ms"].to_numpy(dtype=np.int64)
|
||||
y_ou25 = valid_df["t_ou"].to_numpy(dtype=np.int64)
|
||||
y_btts = valid_df["t_btts"].to_numpy(dtype=np.int64)
|
||||
|
||||
ms_prob = np.asarray(model_ms.predict(X_valid), dtype=np.float64)
|
||||
ou25_prob = np.asarray(model_ou25.predict(X_valid), dtype=np.float64).reshape(-1)
|
||||
btts_prob = np.asarray(model_btts.predict(X_valid), dtype=np.float64).reshape(-1)
|
||||
|
||||
ms_pred = np.argmax(ms_prob, axis=1)
|
||||
ms_conf = np.max(ms_prob, axis=1)
|
||||
ms_correct = (ms_pred == y_ms).astype(np.int64)
|
||||
|
||||
ou25_pred = (ou25_prob >= 0.5).astype(np.int64)
|
||||
ou25_conf = np.where(ou25_prob >= 0.5, ou25_prob, 1.0 - ou25_prob)
|
||||
ou25_correct = (ou25_pred == y_ou25).astype(np.int64)
|
||||
|
||||
btts_pred = (btts_prob >= 0.5).astype(np.int64)
|
||||
btts_conf = np.where(btts_prob >= 0.5, btts_prob, 1.0 - btts_prob)
|
||||
btts_correct = (btts_pred == y_btts).astype(np.int64)
|
||||
|
||||
ms_acc = _accuracy(y_ms, ms_pred)
|
||||
ou25_acc, ou25_brier = _binary_metrics(ou25_prob, y_ou25)
|
||||
btts_acc, btts_brier = _binary_metrics(btts_prob, y_btts)
|
||||
ms_brier = _multiclass_brier(ms_prob, y_ms)
|
||||
|
||||
print("\nGenel metrikler")
|
||||
print(f"MS accuracy : {ms_acc*100:.2f}% | multiclass_brier={ms_brier:.4f}")
|
||||
print(f"OU25 accuracy : {ou25_acc*100:.2f}% | brier={ou25_brier:.4f}")
|
||||
print(f"BTTS accuracy : {btts_acc*100:.2f}% | brier={btts_brier:.4f}")
|
||||
|
||||
print("\nConfidence band")
|
||||
for line in _summarize_bands("MS", ms_conf, ms_correct):
|
||||
print(line)
|
||||
for line in _summarize_bands("OU25", ou25_conf, ou25_correct):
|
||||
print(line)
|
||||
for line in _summarize_bands("BTTS", btts_conf, btts_correct):
|
||||
print(line)
|
||||
|
||||
summary = {
|
||||
"validation_samples": int(len(valid_df)),
|
||||
"metrics": {
|
||||
"ms_accuracy": round(ms_acc, 4),
|
||||
"ms_brier": round(ms_brier, 4),
|
||||
"ou25_accuracy": round(ou25_acc, 4),
|
||||
"ou25_brier": round(ou25_brier, 4),
|
||||
"btts_accuracy": round(btts_acc, 4),
|
||||
"btts_brier": round(btts_brier, 4),
|
||||
},
|
||||
}
|
||||
(MODELS_DIR / "vqwen_backtest_v3_summary.json").write_text(
|
||||
json.dumps(summary, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print("\nKaydedildi: vqwen_backtest_v3_summary.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_v3_backtest()
|
||||
@@ -59,7 +59,7 @@ def fetch_matches(conn, sport: str):
|
||||
|
||||
|
||||
def flush_features_batch(conn, rows, dry_run: bool, sport: str = 'football'):
|
||||
"""Bulk upsert a batch of (match_id, home_elo, away_elo) into sport-partitioned ai_features table."""
|
||||
"""Bulk upsert ELO features into sport-partitioned ai_features table."""
|
||||
if not rows or dry_run:
|
||||
return
|
||||
|
||||
@@ -70,19 +70,27 @@ def flush_features_batch(conn, rows, dry_run: bool, sport: str = 'football'):
|
||||
f"""
|
||||
INSERT INTO {table_name}
|
||||
(match_id, home_elo, away_elo,
|
||||
home_home_elo, away_away_elo,
|
||||
home_form_elo, away_form_elo,
|
||||
elo_diff,
|
||||
home_form_score, away_form_score,
|
||||
missing_players_impact, calculator_ver, updated_at)
|
||||
VALUES %s
|
||||
ON CONFLICT (match_id) DO UPDATE SET
|
||||
home_elo = EXCLUDED.home_elo,
|
||||
away_elo = EXCLUDED.away_elo,
|
||||
home_home_elo = EXCLUDED.home_home_elo,
|
||||
away_away_elo = EXCLUDED.away_away_elo,
|
||||
home_form_elo = EXCLUDED.home_form_elo,
|
||||
away_form_elo = EXCLUDED.away_form_elo,
|
||||
elo_diff = EXCLUDED.elo_diff,
|
||||
home_form_score = EXCLUDED.home_form_score,
|
||||
away_form_score = EXCLUDED.away_form_score,
|
||||
calculator_ver = EXCLUDED.calculator_ver,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
""",
|
||||
rows,
|
||||
template="(%s, %s, %s, %s, %s, 0.0, %s, NOW())",
|
||||
template="(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 0.0, %s, NOW())",
|
||||
page_size=500,
|
||||
)
|
||||
conn.commit()
|
||||
@@ -136,16 +144,24 @@ def backfill(sport: str, batch_size: int, dry_run: bool):
|
||||
if not home_id or not away_id:
|
||||
continue
|
||||
|
||||
# Snapshot PRE-match ELO
|
||||
# Snapshot PRE-match ELO (all dimensions)
|
||||
home_rating = elo.get_or_create_rating(home_id, h_name or "")
|
||||
away_rating = elo.get_or_create_rating(away_id, a_name or "")
|
||||
|
||||
h_overall = round(home_rating.overall_elo, 2)
|
||||
a_overall = round(away_rating.overall_elo, 2)
|
||||
|
||||
feature_buf.append((
|
||||
match_id,
|
||||
round(home_rating.overall_elo, 2),
|
||||
round(away_rating.overall_elo, 2),
|
||||
round(form_to_score(home_rating.recent_form), 2),
|
||||
round(form_to_score(away_rating.recent_form), 2),
|
||||
h_overall, # home_elo
|
||||
a_overall, # away_elo
|
||||
round(home_rating.home_elo, 2), # home_home_elo
|
||||
round(away_rating.away_elo, 2), # away_away_elo
|
||||
round(home_rating.form_elo, 2), # home_form_elo
|
||||
round(away_rating.form_elo, 2), # away_form_elo
|
||||
round(h_overall - a_overall, 2), # elo_diff
|
||||
round(form_to_score(home_rating.recent_form), 2), # home_form_score
|
||||
round(form_to_score(away_rating.recent_form), 2), # away_form_score
|
||||
CALCULATOR_VER,
|
||||
))
|
||||
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI Features Full Enrichment Script
|
||||
====================================
|
||||
Fills empty/default columns in football_ai_features that were not populated
|
||||
by the original elo_backfill_v1 script.
|
||||
|
||||
Enriches: H2H, referee, team_stats, league_averages, form_streaks,
|
||||
rolling_goals, implied_odds, and clean_sheet/scoring rates.
|
||||
|
||||
Usage:
|
||||
python scripts/enrich_ai_features.py # enrich all
|
||||
python scripts/enrich_ai_features.py --batch-size 500 # smaller batches
|
||||
python scripts/enrich_ai_features.py --dry-run # preview only
|
||||
python scripts/enrich_ai_features.py --force # re-enrich all rows
|
||||
python scripts/enrich_ai_features.py --limit 1000 # process N rows max
|
||||
|
||||
Designed to be idempotent: uses ON CONFLICT upserts, skips already-enriched rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import argparse
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# Add ai-engine root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor, execute_values
|
||||
|
||||
from data.db import get_clean_dsn
|
||||
from services.feature_enrichment import FeatureEnrichmentService
|
||||
|
||||
# ────────────────────────── constants ──────────────────────────
|
||||
|
||||
CALCULATOR_VER = 'enrichment_v2.0'
|
||||
DEFAULT_BATCH_SIZE = 200
|
||||
|
||||
|
||||
# ────────────────────────── helpers ────────────────────────────
|
||||
|
||||
def fetch_unenriched_matches(
|
||||
conn: psycopg2.extensions.connection,
|
||||
force: bool = False,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch matches from football_ai_features that still have default values
|
||||
in the enrichment columns (h2h_total=0 AND referee_avg_cards=0).
|
||||
|
||||
If force=True, fetches ALL rows regardless of current state.
|
||||
"""
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
where_clause = "WHERE 1=1" if force else (
|
||||
"WHERE (faf.h2h_total = 0 AND faf.referee_avg_cards = 0)"
|
||||
)
|
||||
limit_clause = f"LIMIT {limit}" if limit else ""
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT
|
||||
faf.match_id,
|
||||
m.home_team_id,
|
||||
m.away_team_id,
|
||||
m.mst_utc,
|
||||
m.league_id,
|
||||
m.score_home,
|
||||
m.score_away
|
||||
FROM football_ai_features faf
|
||||
JOIN matches m ON m.id = faf.match_id
|
||||
WHERE m.status = 'FT'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.sport = 'football'
|
||||
AND ({where_clause.replace('WHERE ', '')})
|
||||
ORDER BY m.mst_utc ASC
|
||||
{limit_clause}
|
||||
""")
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def fetch_referee_for_match(
|
||||
cur: RealDictCursor,
|
||||
match_id: str,
|
||||
) -> Optional[str]:
|
||||
"""Get the head referee name for a match from match_officials."""
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT mo.name
|
||||
FROM match_officials mo
|
||||
WHERE mo.match_id = %s
|
||||
AND mo.role_id = 1
|
||||
LIMIT 1
|
||||
""", (match_id,))
|
||||
row = cur.fetchone()
|
||||
return row['name'] if row else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def fetch_implied_odds(
|
||||
cur: RealDictCursor,
|
||||
match_id: str,
|
||||
) -> Dict[str, float]:
|
||||
"""Get implied probabilities from odd_categories + odd_selections."""
|
||||
defaults = {
|
||||
'implied_home': 0.33,
|
||||
'implied_draw': 0.33,
|
||||
'implied_away': 0.33,
|
||||
'implied_over25': 0.50,
|
||||
'implied_btts_yes': 0.50,
|
||||
'odds_overround': 0.0,
|
||||
}
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT oc.name AS cat_name, os.name AS sel_name, os.odd_value
|
||||
FROM odd_selections os
|
||||
JOIN odd_categories oc ON os.odd_category_db_id = oc.db_id
|
||||
WHERE oc.match_id = %s
|
||||
""", (match_id,))
|
||||
rows = cur.fetchall()
|
||||
except Exception:
|
||||
return defaults
|
||||
|
||||
odds: Dict[str, float] = {}
|
||||
for row in rows:
|
||||
try:
|
||||
cat = (row.get('cat_name') or '').lower().strip()
|
||||
sel = (row.get('sel_name') or '').strip()
|
||||
val = float(row.get('odd_value', 0))
|
||||
if val <= 0:
|
||||
continue
|
||||
|
||||
if cat == 'maç sonucu':
|
||||
if sel == '1':
|
||||
odds['ms_h'] = val
|
||||
elif sel in ('0', 'X'):
|
||||
odds['ms_d'] = val
|
||||
elif sel == '2':
|
||||
odds['ms_a'] = val
|
||||
elif cat == '2,5 alt/üst':
|
||||
if 'üst' in sel.lower():
|
||||
odds['ou25_o'] = val
|
||||
elif 'alt' in sel.lower():
|
||||
odds['ou25_u'] = val
|
||||
elif cat == 'karşılıklı gol':
|
||||
if 'var' in sel.lower():
|
||||
odds['btts_y'] = val
|
||||
elif 'yok' in sel.lower():
|
||||
odds['btts_n'] = val
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
# Compute implied probabilities
|
||||
ms_h = odds.get('ms_h', 0)
|
||||
ms_d = odds.get('ms_d', 0)
|
||||
ms_a = odds.get('ms_a', 0)
|
||||
|
||||
if ms_h > 1.0 and ms_d > 1.0 and ms_a > 1.0:
|
||||
raw_sum = 1 / ms_h + 1 / ms_d + 1 / ms_a
|
||||
overround = raw_sum - 1.0
|
||||
defaults['implied_home'] = round((1 / ms_h) / raw_sum, 4)
|
||||
defaults['implied_draw'] = round((1 / ms_d) / raw_sum, 4)
|
||||
defaults['implied_away'] = round((1 / ms_a) / raw_sum, 4)
|
||||
defaults['odds_overround'] = round(overround, 4)
|
||||
|
||||
ou25_o = odds.get('ou25_o', 0)
|
||||
ou25_u = odds.get('ou25_u', 0)
|
||||
if ou25_o > 1.0 and ou25_u > 1.0:
|
||||
raw_sum = 1 / ou25_o + 1 / ou25_u
|
||||
defaults['implied_over25'] = round((1 / ou25_o) / raw_sum, 4)
|
||||
|
||||
btts_y = odds.get('btts_y', 0)
|
||||
btts_n = odds.get('btts_n', 0)
|
||||
if btts_y > 1.0 and btts_n > 1.0:
|
||||
raw_sum = 1 / btts_y + 1 / btts_n
|
||||
defaults['implied_btts_yes'] = round((1 / btts_y) / raw_sum, 4)
|
||||
|
||||
return defaults
|
||||
|
||||
|
||||
def enrich_single_match(
|
||||
enrichment: FeatureEnrichmentService,
|
||||
cur: RealDictCursor,
|
||||
match: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Compute all enrichment features for a single match and return
|
||||
a dict ready for DB upsert.
|
||||
"""
|
||||
match_id = match['match_id']
|
||||
home_id = str(match['home_team_id'])
|
||||
away_id = str(match['away_team_id'])
|
||||
mst_utc = int(match['mst_utc']) if match['mst_utc'] else 0
|
||||
league_id = str(match['league_id']) if match['league_id'] else None
|
||||
|
||||
# 1. Team stats
|
||||
home_stats = enrichment.compute_team_stats(cur, home_id, mst_utc)
|
||||
away_stats = enrichment.compute_team_stats(cur, away_id, mst_utc)
|
||||
|
||||
# 2. H2H
|
||||
h2h = enrichment.compute_h2h(cur, home_id, away_id, mst_utc)
|
||||
|
||||
# 3. Form & streaks
|
||||
home_form = enrichment.compute_form_streaks(cur, home_id, mst_utc)
|
||||
away_form = enrichment.compute_form_streaks(cur, away_id, mst_utc)
|
||||
|
||||
# 4. Referee
|
||||
referee_name = fetch_referee_for_match(cur, match_id)
|
||||
referee = enrichment.compute_referee_stats(cur, referee_name, mst_utc)
|
||||
|
||||
# 5. League averages
|
||||
league = enrichment.compute_league_averages(cur, league_id, mst_utc)
|
||||
|
||||
# 6. Rolling stats (for goals avg)
|
||||
home_rolling = enrichment.compute_rolling_stats(cur, home_id, mst_utc)
|
||||
away_rolling = enrichment.compute_rolling_stats(cur, away_id, mst_utc)
|
||||
|
||||
# 7. Implied odds
|
||||
implied = fetch_implied_odds(cur, match_id)
|
||||
|
||||
return {
|
||||
'match_id': match_id,
|
||||
# Team stats
|
||||
'home_avg_possession': round(home_stats['avg_possession'], 2),
|
||||
'away_avg_possession': round(away_stats['avg_possession'], 2),
|
||||
'home_avg_shots_on_target': round(home_stats['avg_shots_on_target'], 2),
|
||||
'away_avg_shots_on_target': round(away_stats['avg_shots_on_target'], 2),
|
||||
'home_shot_conversion': round(home_stats['shot_conversion'], 4),
|
||||
'away_shot_conversion': round(away_stats['shot_conversion'], 4),
|
||||
'home_avg_corners': round(home_stats['avg_corners'], 2),
|
||||
'away_avg_corners': round(away_stats['avg_corners'], 2),
|
||||
# H2H
|
||||
'h2h_total': h2h['total_matches'],
|
||||
'h2h_home_win_rate': round(h2h['home_win_rate'], 4),
|
||||
'h2h_avg_goals': round(h2h['avg_goals'], 2),
|
||||
'h2h_over25_rate': round(h2h['over25_rate'], 4),
|
||||
'h2h_btts_rate': round(h2h['btts_rate'], 4),
|
||||
# Form
|
||||
'home_clean_sheet_rate': round(home_form['clean_sheet_rate'], 4),
|
||||
'away_clean_sheet_rate': round(away_form['clean_sheet_rate'], 4),
|
||||
'home_scoring_rate': round(home_form['scoring_rate'], 4),
|
||||
'away_scoring_rate': round(away_form['scoring_rate'], 4),
|
||||
'home_win_streak': home_form['winning_streak'],
|
||||
'away_win_streak': away_form['winning_streak'],
|
||||
# Rolling goals
|
||||
'home_goals_avg_5': round(home_rolling['rolling5_goals'], 2),
|
||||
'away_goals_avg_5': round(away_rolling['rolling5_goals'], 2),
|
||||
'home_conceded_avg_5': round(home_rolling['rolling5_conceded'], 2),
|
||||
'away_conceded_avg_5': round(away_rolling['rolling5_conceded'], 2),
|
||||
# Referee
|
||||
'referee_avg_cards': round(referee['cards_total'], 2),
|
||||
'referee_home_bias': round(referee['home_bias'], 4),
|
||||
'referee_avg_goals': round(referee['avg_goals'], 2),
|
||||
# League
|
||||
'league_avg_goals': round(league['avg_goals'], 2),
|
||||
'league_home_win_pct': round(league['home_win_rate'], 4),
|
||||
'league_over25_pct': round(league['ou25_rate'], 4),
|
||||
# Implied odds
|
||||
'implied_home': implied['implied_home'],
|
||||
'implied_draw': implied['implied_draw'],
|
||||
'implied_away': implied['implied_away'],
|
||||
'implied_over25': implied['implied_over25'],
|
||||
'implied_btts_yes': implied['implied_btts_yes'],
|
||||
'odds_overround': implied['odds_overround'],
|
||||
# Missing players impact — default (no lineup data for historical)
|
||||
'missing_players_impact': 0.0,
|
||||
# Version
|
||||
'calculator_ver': CALCULATOR_VER,
|
||||
}
|
||||
|
||||
|
||||
def flush_enrichment_batch(
|
||||
conn: psycopg2.extensions.connection,
|
||||
rows: List[Dict[str, Any]],
|
||||
dry_run: bool,
|
||||
) -> int:
|
||||
"""Bulk upsert enriched features into football_ai_features."""
|
||||
if not rows or dry_run:
|
||||
return 0
|
||||
|
||||
columns = [
|
||||
'match_id',
|
||||
'home_avg_possession', 'away_avg_possession',
|
||||
'home_avg_shots_on_target', 'away_avg_shots_on_target',
|
||||
'home_shot_conversion', 'away_shot_conversion',
|
||||
'home_avg_corners', 'away_avg_corners',
|
||||
'h2h_total', 'h2h_home_win_rate', 'h2h_avg_goals',
|
||||
'h2h_over25_rate', 'h2h_btts_rate',
|
||||
'home_clean_sheet_rate', 'away_clean_sheet_rate',
|
||||
'home_scoring_rate', 'away_scoring_rate',
|
||||
'home_win_streak', 'away_win_streak',
|
||||
'home_goals_avg_5', 'away_goals_avg_5',
|
||||
'home_conceded_avg_5', 'away_conceded_avg_5',
|
||||
'referee_avg_cards', 'referee_home_bias', 'referee_avg_goals',
|
||||
'league_avg_goals', 'league_home_win_pct', 'league_over25_pct',
|
||||
'implied_home', 'implied_draw', 'implied_away',
|
||||
'implied_over25', 'implied_btts_yes', 'odds_overround',
|
||||
'missing_players_impact', 'calculator_ver',
|
||||
]
|
||||
|
||||
# Build update SET clause (skip match_id)
|
||||
update_cols = [c for c in columns if c != 'match_id']
|
||||
set_clause = ', '.join(f'{c} = EXCLUDED.{c}' for c in update_cols)
|
||||
|
||||
placeholders = ', '.join(['%s'] * len(columns))
|
||||
values = [
|
||||
tuple(row[c] for c in columns)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
with conn.cursor() as cur:
|
||||
execute_values(
|
||||
cur,
|
||||
f"""
|
||||
INSERT INTO football_ai_features ({', '.join(columns)})
|
||||
VALUES %s
|
||||
ON CONFLICT (match_id) DO UPDATE SET
|
||||
{set_clause},
|
||||
updated_at = NOW()
|
||||
""",
|
||||
values,
|
||||
template=f"({placeholders})",
|
||||
page_size=200,
|
||||
)
|
||||
conn.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
# ────────────────────────── main ───────────────────────────────
|
||||
|
||||
def run_enrichment(
|
||||
batch_size: int,
|
||||
dry_run: bool,
|
||||
force: bool,
|
||||
limit: Optional[int],
|
||||
) -> None:
|
||||
"""Core enrichment loop."""
|
||||
dsn = get_clean_dsn()
|
||||
conn = psycopg2.connect(dsn)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"🧠 AI Features Full Enrichment — {CALCULATOR_VER}")
|
||||
print(f" batch_size={batch_size} dry_run={dry_run} force={force}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
# 1. Fetch unenriched matches
|
||||
t0 = time.time()
|
||||
matches = fetch_unenriched_matches(conn, force=force, limit=limit)
|
||||
print(f"\n📊 {len(matches):,} matches to enrich ({time.time() - t0:.1f}s)")
|
||||
|
||||
if not matches:
|
||||
print("✅ Nothing to enrich — all rows already populated.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# 2. Initialize enrichment service
|
||||
enrichment = FeatureEnrichmentService()
|
||||
|
||||
# 3. Process in batches
|
||||
total = len(matches)
|
||||
processed = 0
|
||||
written = 0
|
||||
errors = 0
|
||||
batch_buf: List[Dict[str, Any]] = []
|
||||
t_start = time.time()
|
||||
|
||||
# Use a dedicated cursor with RealDictCursor for all enrichment queries
|
||||
enrich_cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
for idx, match in enumerate(matches):
|
||||
try:
|
||||
enriched = enrich_single_match(enrichment, enrich_cur, match)
|
||||
batch_buf.append(enriched)
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
if errors <= 10:
|
||||
print(f" ⚠️ Error enriching {match.get('match_id', '?')}: {e}")
|
||||
|
||||
processed += 1
|
||||
|
||||
# Flush batch
|
||||
if len(batch_buf) >= batch_size:
|
||||
flushed = flush_enrichment_batch(conn, batch_buf, dry_run)
|
||||
written += flushed
|
||||
batch_buf.clear()
|
||||
|
||||
# Progress reporting
|
||||
if processed % 500 == 0:
|
||||
elapsed = time.time() - t_start
|
||||
rate = processed / elapsed if elapsed > 0 else 0
|
||||
remaining = (total - processed) / rate if rate > 0 else 0
|
||||
pct = processed / total * 100
|
||||
print(
|
||||
f" [{processed:>8,} / {total:,}] "
|
||||
f"({pct:.1f}%) | {rate:.0f} matches/s | "
|
||||
f"ETA: {remaining / 60:.1f} min | "
|
||||
f"errors: {errors}"
|
||||
)
|
||||
|
||||
# Flush remaining
|
||||
if batch_buf:
|
||||
flushed = flush_enrichment_batch(conn, batch_buf, dry_run)
|
||||
written += flushed
|
||||
|
||||
enrich_cur.close()
|
||||
|
||||
elapsed = time.time() - t_start
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"✅ Enrichment complete:")
|
||||
print(f" Processed: {processed:,} matches in {elapsed:.1f}s")
|
||||
print(f" Written: {written:,} rows")
|
||||
print(f" Errors: {errors:,}")
|
||||
print(f" Rate: {processed / elapsed:.0f} matches/s")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Enrich football_ai_features with H2H, referee, stats, and odds data"
|
||||
)
|
||||
parser.add_argument(
|
||||
'--batch-size',
|
||||
type=int,
|
||||
default=DEFAULT_BATCH_SIZE,
|
||||
help=f'DB insert batch size (default: {DEFAULT_BATCH_SIZE})',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Compute features but do not write to DB',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='Re-enrich ALL rows, not just empty ones',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--limit',
|
||||
type=int,
|
||||
default=None,
|
||||
help='Max number of matches to process',
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
run_enrichment(
|
||||
batch_size=args.batch_size,
|
||||
dry_run=args.dry_run,
|
||||
force=args.force,
|
||||
limit=args.limit,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -14,6 +14,7 @@ import json
|
||||
import csv
|
||||
import math
|
||||
import time
|
||||
import bisect
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -33,7 +34,7 @@ from features.upset_engine import get_upset_engine
|
||||
from features.referee_engine import get_referee_engine
|
||||
from features.momentum_engine import get_momentum_engine
|
||||
|
||||
TOP_LEAGUES_PATH = os.path.join(AI_ENGINE_DIR, "..", "top_leagues.json")
|
||||
TOP_LEAGUES_PATH = os.path.join(AI_ENGINE_DIR, "..", "qualified_leagues.json")
|
||||
OUTPUT_CSV = os.path.join(AI_ENGINE_DIR, "data", "training_data.csv")
|
||||
|
||||
# Ensure output dir exists
|
||||
@@ -119,6 +120,14 @@ FEATURE_COLS = [
|
||||
"home_key_players", "away_key_players",
|
||||
"home_missing_impact", "away_missing_impact",
|
||||
"home_goals_form", "away_goals_form",
|
||||
|
||||
# Player-Level Features (12)
|
||||
"home_lineup_goals_per90", "away_lineup_goals_per90",
|
||||
"home_lineup_assists_per90", "away_lineup_assists_per90",
|
||||
"home_squad_continuity", "away_squad_continuity",
|
||||
"home_top_scorer_form", "away_top_scorer_form",
|
||||
"home_avg_player_exp", "away_avg_player_exp",
|
||||
"home_goals_diversity", "away_goals_diversity",
|
||||
|
||||
# Labels
|
||||
"score_home", "score_away", "total_goals",
|
||||
@@ -336,7 +345,7 @@ class BatchDataLoader:
|
||||
self.team_stats[tid].append((mst, poss, sot, tshots, corn, team_goals))
|
||||
|
||||
def _load_squad_data(self):
|
||||
"""Bulk load squad participation + player events for squad features."""
|
||||
"""Bulk load squad participation + player events + player career for squad features."""
|
||||
ph = ",".join(["%s"] * len(self.top_league_ids))
|
||||
|
||||
# 1) Participation: starting XI count + position distribution per (match, team)
|
||||
@@ -424,12 +433,99 @@ class BatchDataLoader:
|
||||
for mid, tid, pid in self.cur.fetchall():
|
||||
starting_players[(mid, tid)].append(pid)
|
||||
|
||||
# 5) Build combined cache
|
||||
# 5) Build match_id → mst_utc mapping for temporal filtering
|
||||
match_mst = {}
|
||||
for m in self.matches:
|
||||
match_mst[m[0]] = m[7] # m[0]=id, m[7]=mst_utc
|
||||
|
||||
# ─── NEW: Player Career Stats (prefix-sum for O(1) temporal lookup) ───
|
||||
# 6a) Goals per player per match date
|
||||
self.cur.execute(f"""
|
||||
SELECT mpe.player_id, m.mst_utc,
|
||||
SUM(CASE WHEN mpe.event_type = 'goal'
|
||||
AND COALESCE(mpe.event_subtype, '') NOT ILIKE '%%penaltı kaçırma%%'
|
||||
THEN 1 ELSE 0 END) AS goals
|
||||
FROM match_player_events mpe
|
||||
JOIN matches m ON mpe.match_id = m.id
|
||||
WHERE m.status = 'FT' AND m.sport = 'football' AND m.league_id IN ({ph})
|
||||
GROUP BY mpe.player_id, m.mst_utc
|
||||
""", self.top_league_ids)
|
||||
|
||||
player_goals_raw = defaultdict(dict)
|
||||
for pid, mst, goals in self.cur.fetchall():
|
||||
player_goals_raw[pid][mst] = (player_goals_raw[pid].get(mst, 0)) + (goals or 0)
|
||||
|
||||
# 6b) Assists per player per match date
|
||||
self.cur.execute(f"""
|
||||
SELECT mpe.assist_player_id, m.mst_utc, COUNT(*) AS assists
|
||||
FROM match_player_events mpe
|
||||
JOIN matches m ON mpe.match_id = m.id
|
||||
WHERE m.status = 'FT' AND m.sport = 'football' AND m.league_id IN ({ph})
|
||||
AND mpe.event_type = 'goal' AND mpe.assist_player_id IS NOT NULL
|
||||
GROUP BY mpe.assist_player_id, m.mst_utc
|
||||
""", self.top_league_ids)
|
||||
|
||||
player_assists_raw = defaultdict(dict)
|
||||
for pid, mst, assists in self.cur.fetchall():
|
||||
player_assists_raw[pid][mst] = (player_assists_raw[pid].get(mst, 0)) + (assists or 0)
|
||||
|
||||
# 6c) Player participation dates (starts only)
|
||||
self.cur.execute(f"""
|
||||
SELECT mpp.player_id, m.mst_utc
|
||||
FROM match_player_participation mpp
|
||||
JOIN matches m ON mpp.match_id = m.id
|
||||
WHERE mpp.is_starting = true
|
||||
AND m.status = 'FT' AND m.sport = 'football' AND m.league_id IN ({ph})
|
||||
ORDER BY mpp.player_id, m.mst_utc
|
||||
""", self.top_league_ids)
|
||||
|
||||
player_starts_raw = defaultdict(list)
|
||||
for pid, mst in self.cur.fetchall():
|
||||
player_starts_raw[pid].append(mst)
|
||||
|
||||
# 6d) Build prefix sums per player (goals_prefix[i] = total goals up to start i)
|
||||
player_career = {}
|
||||
all_pids = set(player_starts_raw.keys()) | set(player_goals_raw.keys()) | set(player_assists_raw.keys())
|
||||
for pid in all_pids:
|
||||
starts = sorted(set(player_starts_raw.get(pid, [])))
|
||||
if not starts:
|
||||
continue
|
||||
g_map = player_goals_raw.get(pid, {})
|
||||
a_map = player_assists_raw.get(pid, {})
|
||||
cum_g, cum_a = 0, 0
|
||||
goals_pf, assists_pf = [], []
|
||||
for mst in starts:
|
||||
cum_g += g_map.get(mst, 0)
|
||||
cum_a += a_map.get(mst, 0)
|
||||
goals_pf.append(cum_g)
|
||||
assists_pf.append(cum_a)
|
||||
player_career[pid] = {'msts': starts, 'gp': goals_pf, 'ap': assists_pf}
|
||||
|
||||
# Free raw dicts
|
||||
del player_goals_raw, player_assists_raw, player_starts_raw
|
||||
print(f" 📊 Player careers built: {len(player_career)} players", flush=True)
|
||||
|
||||
# ─── NEW: Team Lineup History (for squad continuity) ───
|
||||
# 7) Per-team sorted lineups: [(mst, frozenset(player_ids))]
|
||||
team_lineup_map = defaultdict(list)
|
||||
for (mid, tid), pids in starting_players.items():
|
||||
mst = match_mst.get(mid, 0)
|
||||
if mst > 0 and pids:
|
||||
team_lineup_map[tid].append((mst, frozenset(pids)))
|
||||
|
||||
team_lineup_history = {}
|
||||
team_lineup_msts = {}
|
||||
for tid, ll in team_lineup_map.items():
|
||||
ll.sort(key=lambda x: x[0])
|
||||
team_lineup_history[tid] = ll
|
||||
team_lineup_msts[tid] = [x[0] for x in ll]
|
||||
del team_lineup_map
|
||||
|
||||
# ─── 8) Build combined cache — NO DATA LEAKAGE ───
|
||||
all_keys = set(participation.keys()) | set(events.keys())
|
||||
for key in all_keys:
|
||||
mid, tid = key
|
||||
part = participation.get(key, {'starting_count': 0, 'total_squad': 0, 'fwd_count': 0})
|
||||
evt = events.get(key, {'goals': 0, 'assists': 0, 'unique_scorers': 0})
|
||||
|
||||
# Count key players in starting XI
|
||||
starters = starting_players.get(key, [])
|
||||
@@ -437,22 +533,78 @@ class BatchDataLoader:
|
||||
kp_total = len(key_players_by_team.get(tid, set()))
|
||||
kp_missing = max(0, kp_total - kp_in_starting)
|
||||
|
||||
# Squad quality: composite score
|
||||
# Squad quality: composite score — ONLY pre-match info
|
||||
squad_quality = (
|
||||
part['starting_count'] * 0.3 +
|
||||
evt['goals'] * 2.0 +
|
||||
evt['assists'] * 1.0 +
|
||||
kp_in_starting * 3.0 +
|
||||
part['fwd_count'] * 1.5
|
||||
)
|
||||
# Missing impact: how many key players are missing
|
||||
missing_impact = min(kp_missing / max(kp_total, 1), 1.0)
|
||||
|
||||
# goals_form: avg goals from last 5 matches BEFORE this match
|
||||
current_mst = match_mst.get(mid, 0)
|
||||
team_history = self.team_matches.get(tid, [])
|
||||
recent_goals = [
|
||||
tm[2] for tm in team_history if tm[0] < current_mst
|
||||
][-5:]
|
||||
goals_form = sum(recent_goals) / len(recent_goals) if recent_goals else 1.3
|
||||
|
||||
# ─── NEW: Player-level aggregation for starting XI ───
|
||||
lineup_g90, lineup_a90, total_exp = 0.0, 0.0, 0
|
||||
best_scorer_total, best_scorer_id = 0, None
|
||||
scorers_in_lineup = 0
|
||||
|
||||
for pid in starters:
|
||||
pc = player_career.get(pid)
|
||||
if not pc:
|
||||
continue
|
||||
idx = bisect.bisect_left(pc['msts'], current_mst)
|
||||
if idx == 0:
|
||||
continue # no prior matches for this player
|
||||
prior_starts = idx
|
||||
prior_goals = pc['gp'][idx - 1]
|
||||
prior_assists = pc['ap'][idx - 1]
|
||||
lineup_g90 += prior_goals / prior_starts
|
||||
lineup_a90 += prior_assists / prior_starts
|
||||
total_exp += prior_starts
|
||||
if prior_goals > 0:
|
||||
scorers_in_lineup += 1
|
||||
if prior_goals > best_scorer_total:
|
||||
best_scorer_total = prior_goals
|
||||
best_scorer_id = pid
|
||||
|
||||
n_st = len(starters) or 1
|
||||
|
||||
# Top scorer recent form (goals in last 5 starts)
|
||||
top_scorer_form = 0
|
||||
if best_scorer_id:
|
||||
pc = player_career.get(best_scorer_id)
|
||||
if pc:
|
||||
idx = bisect.bisect_left(pc['msts'], current_mst)
|
||||
if idx > 0:
|
||||
s5 = max(0, idx - 5)
|
||||
top_scorer_form = pc['gp'][idx - 1] - (pc['gp'][s5 - 1] if s5 > 0 else 0)
|
||||
|
||||
# Squad continuity (overlap with previous match lineup)
|
||||
squad_continuity = 0.5
|
||||
msts_list = team_lineup_msts.get(tid)
|
||||
if msts_list:
|
||||
li = bisect.bisect_left(msts_list, current_mst)
|
||||
if li > 0:
|
||||
prev_lineup = team_lineup_history[tid][li - 1][1]
|
||||
squad_continuity = len(frozenset(starters) & prev_lineup) / n_st
|
||||
|
||||
self.squad_cache[key] = {
|
||||
'squad_quality': squad_quality,
|
||||
'key_players': kp_in_starting,
|
||||
'missing_impact': missing_impact,
|
||||
'goals_form': evt['goals'],
|
||||
'goals_form': round(goals_form, 2),
|
||||
'lineup_goals_per90': round(lineup_g90, 3),
|
||||
'lineup_assists_per90': round(lineup_a90, 3),
|
||||
'squad_continuity': round(squad_continuity, 3),
|
||||
'top_scorer_form': top_scorer_form,
|
||||
'avg_player_exp': round(total_exp / n_st, 1),
|
||||
'goals_diversity': round(scorers_in_lineup / n_st, 3),
|
||||
}
|
||||
|
||||
def _load_cards_data(self):
|
||||
@@ -496,16 +648,24 @@ class FeatureExtractor:
|
||||
self.referee_engine = get_referee_engine()
|
||||
self.momentum_engine = get_momentum_engine()
|
||||
|
||||
# ── Data Quality Thresholds ──
|
||||
# Matches below these thresholds produce default-only features that
|
||||
# teach the model noise rather than signal.
|
||||
DQ_MIN_FORM_MATCHES = 3 # team must have ≥3 prior matches
|
||||
DQ_MIN_FEATURE_COVERAGE = 0.30 # ≥30% of key features must be non-default
|
||||
|
||||
def extract_all(self) -> list:
|
||||
"""Extract features for all matches, yield row dicts."""
|
||||
"""Extract features for all matches with data quality validation."""
|
||||
matches = self.loader.matches
|
||||
total = len(matches)
|
||||
rows = []
|
||||
skipped = 0
|
||||
dq_rejected = 0
|
||||
dq_reasons: dict = defaultdict(int)
|
||||
t_start = time.time()
|
||||
|
||||
|
||||
print(f"\n🔄 Extracting features for {total} matches...", flush=True)
|
||||
|
||||
|
||||
# Process chronologically — ELO grows as we go
|
||||
for i, m in enumerate(matches):
|
||||
(
|
||||
@@ -522,38 +682,43 @@ class FeatureExtractor:
|
||||
away_name,
|
||||
league_name,
|
||||
) = m
|
||||
|
||||
|
||||
if i % 100 == 0 and i > 0:
|
||||
elapsed = time.time() - t_start
|
||||
rate = i / elapsed # matches per second
|
||||
remaining = (total - i) / rate if rate > 0 else 0
|
||||
pct = i / total * 100
|
||||
print(f" [{i}/{total}] ({pct:.0f}%) | {rate:.1f} maç/s | ETA: {remaining/60:.1f} dk | skipped: {skipped}", flush=True)
|
||||
|
||||
print(
|
||||
f" [{i}/{total}] ({pct:.0f}%) | {rate:.1f} maç/s | "
|
||||
f"ETA: {remaining/60:.1f} dk | skipped: {skipped} | "
|
||||
f"dq_rejected: {dq_rejected}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
row = self._extract_one(
|
||||
mid,
|
||||
hid,
|
||||
aid,
|
||||
sh,
|
||||
sa,
|
||||
hth,
|
||||
hta,
|
||||
mst,
|
||||
lid,
|
||||
home_name,
|
||||
away_name,
|
||||
league_name,
|
||||
mid, hid, aid, sh, sa, hth, hta, mst, lid,
|
||||
home_name, away_name, league_name,
|
||||
)
|
||||
|
||||
|
||||
if row:
|
||||
rows.append(row)
|
||||
# ── Data Quality Gate ──
|
||||
dq_pass, reason = self._validate_row_quality(row, hid, aid, mst)
|
||||
if dq_pass:
|
||||
rows.append(row)
|
||||
else:
|
||||
dq_rejected += 1
|
||||
dq_reasons[reason] += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
|
||||
# Update ELO after processing (so ELO is calculated BEFORE the match)
|
||||
self._update_elo(hid, aid, sh, sa)
|
||||
|
||||
print(f" ✅ Extracted {len(rows)} rows, skipped {skipped}", flush=True)
|
||||
|
||||
print(f" ✅ Extracted {len(rows)} rows, skipped {skipped}, DQ rejected {dq_rejected}", flush=True)
|
||||
if dq_reasons:
|
||||
print(f" 📊 DQ Rejection reasons:")
|
||||
for reason, count in sorted(dq_reasons.items(), key=lambda x: -x[1]):
|
||||
print(f" {reason}: {count}")
|
||||
return rows
|
||||
|
||||
def _extract_one(
|
||||
@@ -828,6 +993,20 @@ class FeatureExtractor:
|
||||
"away_missing_impact": away_missing_impact,
|
||||
"home_goals_form": home_goals_form,
|
||||
"away_goals_form": away_goals_form,
|
||||
|
||||
# Player-Level Features
|
||||
"home_lineup_goals_per90": home_sq.get('lineup_goals_per90', 0.0),
|
||||
"away_lineup_goals_per90": away_sq.get('lineup_goals_per90', 0.0),
|
||||
"home_lineup_assists_per90": home_sq.get('lineup_assists_per90', 0.0),
|
||||
"away_lineup_assists_per90": away_sq.get('lineup_assists_per90', 0.0),
|
||||
"home_squad_continuity": home_sq.get('squad_continuity', 0.5),
|
||||
"away_squad_continuity": away_sq.get('squad_continuity', 0.5),
|
||||
"home_top_scorer_form": home_sq.get('top_scorer_form', 0),
|
||||
"away_top_scorer_form": away_sq.get('top_scorer_form', 0),
|
||||
"home_avg_player_exp": home_sq.get('avg_player_exp', 0.0),
|
||||
"away_avg_player_exp": away_sq.get('avg_player_exp', 0.0),
|
||||
"home_goals_diversity": home_sq.get('goals_diversity', 0.0),
|
||||
"away_goals_diversity": away_sq.get('goals_diversity', 0.0),
|
||||
|
||||
# Labels
|
||||
"score_home": sh,
|
||||
@@ -853,7 +1032,58 @@ class FeatureExtractor:
|
||||
}
|
||||
|
||||
return row
|
||||
|
||||
|
||||
def _validate_row_quality(
|
||||
self,
|
||||
row: dict,
|
||||
home_id: str,
|
||||
away_id: str,
|
||||
before_date: int,
|
||||
) -> tuple:
|
||||
"""
|
||||
Data quality gate for training rows.
|
||||
|
||||
Ensures the feature vector has enough real signal to be useful for
|
||||
training. Rejects rows where critical features are all at their
|
||||
default/fallback values — these teach the model noise, not patterns.
|
||||
|
||||
Returns (pass: bool, reason: str | None).
|
||||
"""
|
||||
# 1. Minimum form history: both teams must have enough prior matches
|
||||
home_history = self.loader.team_matches.get(home_id, [])
|
||||
away_history = self.loader.team_matches.get(away_id, [])
|
||||
home_prior = sum(1 for m in home_history if m[0] < before_date)
|
||||
away_prior = sum(1 for m in away_history if m[0] < before_date)
|
||||
|
||||
if home_prior < self.DQ_MIN_FORM_MATCHES:
|
||||
return False, 'home_insufficient_history'
|
||||
if away_prior < self.DQ_MIN_FORM_MATCHES:
|
||||
return False, 'away_insufficient_history'
|
||||
|
||||
# 2. Feature coverage check: count how many key features are non-default
|
||||
key_features = [
|
||||
('home_goals_avg', 1.3),
|
||||
('away_goals_avg', 1.3),
|
||||
('home_clean_sheet_rate', 0.25),
|
||||
('away_clean_sheet_rate', 0.25),
|
||||
('home_avg_possession', 0.50),
|
||||
('away_avg_possession', 0.50),
|
||||
('home_avg_shots_on_target', 3.5),
|
||||
('away_avg_shots_on_target', 3.5),
|
||||
('h2h_total_matches', 0),
|
||||
('odds_ms_h', 0.0),
|
||||
]
|
||||
non_default = sum(
|
||||
1 for feat_name, default_val in key_features
|
||||
if abs(float(row.get(feat_name, default_val)) - default_val) > 0.01
|
||||
)
|
||||
coverage = non_default / len(key_features)
|
||||
|
||||
if coverage < self.DQ_MIN_FEATURE_COVERAGE:
|
||||
return False, f'low_feature_coverage_{coverage:.0%}'
|
||||
|
||||
return True, None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# ELO (simplified inline version — doesn't need DB, grows incrementally)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -1,183 +1,271 @@
|
||||
"""
|
||||
V25-Compatible Score Prediction Model Trainer
|
||||
===============================================
|
||||
Trains 4 independent XGBoost regression models for:
|
||||
- FT Home Goals
|
||||
- FT Away Goals
|
||||
- HT Home Goals
|
||||
- HT Away Goals
|
||||
|
||||
Uses the same 102-feature set as v25_ensemble for full compatibility.
|
||||
Temporal train/test split (80/20) to avoid future leakage.
|
||||
|
||||
Usage:
|
||||
python3 scripts/train_score_model.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import pickle
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import xgboost as xgb
|
||||
import pickle
|
||||
import os
|
||||
from sklearn.model_selection import train_test_split
|
||||
from sklearn.metrics import mean_absolute_error, r2_score
|
||||
from datetime import datetime
|
||||
from sklearn.metrics import mean_absolute_error, r2_score, mean_squared_error
|
||||
|
||||
# Paths
|
||||
DATA_PATH = os.path.join(os.path.dirname(__file__), "../data/training_data.csv")
|
||||
MODEL_PATH = os.path.join(os.path.dirname(__file__), "../models/xgb_score.pkl")
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Import unified 56-feature array from markets trainer
|
||||
from train_xgboost_markets import FEATURES
|
||||
# Config
|
||||
AI_ENGINE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DATA_PATH = os.path.join(AI_ENGINE_DIR, "data", "training_data.csv")
|
||||
MODEL_PATH = os.path.join(AI_ENGINE_DIR, "models", "xgb_score.pkl")
|
||||
|
||||
# Import the EXACT same feature set as v25 market models
|
||||
from train_v25_clean import FEATURES
|
||||
|
||||
TARGETS = ["score_home", "score_away", "ht_score_home", "ht_score_away"]
|
||||
|
||||
def train():
|
||||
print("🚀 Training Score Prediction Model (XGBoost) - Full Time & Half Time")
|
||||
print("=" * 60)
|
||||
# Model hyperparameters (tuned for goal count regression)
|
||||
XGB_PARAMS = {
|
||||
"objective": "reg:squarederror",
|
||||
"n_estimators": 1200,
|
||||
"learning_rate": 0.02,
|
||||
"max_depth": 6,
|
||||
"subsample": 0.8,
|
||||
"colsample_bytree": 0.7,
|
||||
"min_child_weight": 5,
|
||||
"reg_alpha": 0.1,
|
||||
"reg_lambda": 1.0,
|
||||
"n_jobs": -1,
|
||||
"random_state": 42,
|
||||
}
|
||||
|
||||
|
||||
def load_data() -> pd.DataFrame:
|
||||
"""Load and validate training data."""
|
||||
if not os.path.exists(DATA_PATH):
|
||||
print(f"❌ Data file not found: {DATA_PATH}")
|
||||
return
|
||||
print(" Run extract_training_data.py first")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"📦 Loading data from {DATA_PATH}...")
|
||||
df = pd.read_csv(DATA_PATH)
|
||||
|
||||
# Preprocessing
|
||||
# Drop rows where target is missing (should verify)
|
||||
|
||||
# Fill feature NaNs with 0 (same as v25 training)
|
||||
for col in FEATURES:
|
||||
if col in df.columns:
|
||||
df[col] = df[col].fillna(0)
|
||||
|
||||
# Backward-compatible: add odds presence flags if missing
|
||||
odds_base_columns = [
|
||||
"odds_ms_h", "odds_ms_d", "odds_ms_a",
|
||||
"odds_ht_ms_h", "odds_ht_ms_d", "odds_ht_ms_a",
|
||||
"odds_ou05_o", "odds_ou05_u",
|
||||
"odds_ou15_o", "odds_ou15_u",
|
||||
"odds_ou25_o", "odds_ou25_u",
|
||||
"odds_ou35_o", "odds_ou35_u",
|
||||
"odds_ht_ou05_o", "odds_ht_ou05_u",
|
||||
"odds_ht_ou15_o", "odds_ht_ou15_u",
|
||||
"odds_btts_y", "odds_btts_n",
|
||||
]
|
||||
for base_col in odds_base_columns:
|
||||
pres_col = f"{base_col}_present"
|
||||
if pres_col not in df.columns and base_col in df.columns:
|
||||
df[pres_col] = (df[base_col] > 1.0).astype(int)
|
||||
|
||||
# Drop rows where any target is missing
|
||||
df = df.dropna(subset=TARGETS)
|
||||
|
||||
# Fill feature NaNs with median/mean or 0
|
||||
print(f" Original rows: {len(df)}")
|
||||
|
||||
# Filter valid odds (at least ms_h > 1.0)
|
||||
|
||||
# Filter: at least MS odds must be present
|
||||
df = df[df["odds_ms_h"] > 1.0].copy()
|
||||
print(f" Rows with valid odds: {len(df)}")
|
||||
|
||||
X = df[FEATURES]
|
||||
y_home = df["score_home"]
|
||||
y_away = df["score_away"]
|
||||
y_ht_home = df["ht_score_home"]
|
||||
y_ht_away = df["ht_score_away"]
|
||||
|
||||
# Train/Test Split
|
||||
X_train, X_test, y_h_train, y_h_test, y_a_train, y_a_test, y_ht_h_train, y_ht_h_test, y_ht_a_train, y_ht_a_test = train_test_split(
|
||||
X, y_home, y_away, y_ht_home, y_ht_away, test_size=0.2, random_state=42
|
||||
)
|
||||
|
||||
print(f" Training set: {len(X_train)} matches")
|
||||
print(f" Test set: {len(X_test)} matches")
|
||||
|
||||
# --- HOME GOALS MODEL ---
|
||||
print("\n🏠 Training Home Goals Model...")
|
||||
xgb_home = xgb.XGBRegressor(
|
||||
objective='reg:squarederror',
|
||||
n_estimators=1000,
|
||||
learning_rate=0.01,
|
||||
max_depth=5,
|
||||
subsample=0.7,
|
||||
colsample_bytree=0.7,
|
||||
n_jobs=-1,
|
||||
random_state=42,
|
||||
early_stopping_rounds=50 # Configure here for newer XGBoost or remove if not supported in constructor (depends on version)
|
||||
)
|
||||
# Actually, to be safe across versions, let's remove early stopping for now or use validation set properly
|
||||
# Using 'eval_set' without early_stopping_rounds just prints metrics
|
||||
xgb_home = xgb.XGBRegressor(
|
||||
objective='reg:squarederror',
|
||||
n_estimators=1000,
|
||||
learning_rate=0.01,
|
||||
max_depth=5,
|
||||
subsample=0.7,
|
||||
colsample_bytree=0.7,
|
||||
n_jobs=-1,
|
||||
random_state=42
|
||||
)
|
||||
xgb_home.fit(X_train, y_h_train, eval_set=[(X_test, y_h_test)], verbose=False)
|
||||
|
||||
home_preds = xgb_home.predict(X_test)
|
||||
mae_home = mean_absolute_error(y_h_test, home_preds)
|
||||
r2_home = r2_score(y_h_test, home_preds)
|
||||
print(f" ✅ FT Home MAE: {mae_home:.4f} goals")
|
||||
print(f" ✅ FT Home R2: {r2_home:.4f}")
|
||||
# Ensure all features exist
|
||||
missing = [f for f in FEATURES if f not in df.columns]
|
||||
if missing:
|
||||
print(f"⚠️ Missing {len(missing)} features, filling with 0: {missing[:5]}...")
|
||||
for f in missing:
|
||||
df[f] = 0
|
||||
|
||||
# --- AWAY GOALS MODEL ---
|
||||
print("\n✈️ Training FT Away Goals Model...")
|
||||
xgb_away = xgb.XGBRegressor(
|
||||
objective='reg:squarederror',
|
||||
n_estimators=1000,
|
||||
learning_rate=0.01,
|
||||
max_depth=5,
|
||||
subsample=0.7,
|
||||
colsample_bytree=0.7,
|
||||
n_jobs=-1,
|
||||
random_state=42
|
||||
)
|
||||
xgb_away.fit(X_train, y_a_train, eval_set=[(X_test, y_a_test)], verbose=False)
|
||||
|
||||
away_preds = xgb_away.predict(X_test)
|
||||
mae_away = mean_absolute_error(y_a_test, away_preds)
|
||||
r2_away = r2_score(y_a_test, away_preds)
|
||||
print(f" ✅ FT Away MAE: {mae_away:.4f} goals")
|
||||
print(f" ✅ FT Away R2: {r2_away:.4f}")
|
||||
|
||||
# --- HT HOME GOALS MODEL ---
|
||||
print("\n🏠 Training HT Home Goals Model...")
|
||||
xgb_ht_home = xgb.XGBRegressor(
|
||||
objective='reg:squarederror',
|
||||
n_estimators=1000,
|
||||
learning_rate=0.01,
|
||||
max_depth=5,
|
||||
subsample=0.7,
|
||||
colsample_bytree=0.7,
|
||||
n_jobs=-1,
|
||||
random_state=42
|
||||
)
|
||||
xgb_ht_home.fit(X_train, y_ht_h_train, eval_set=[(X_test, y_ht_h_test)], verbose=False)
|
||||
|
||||
ht_home_preds = xgb_ht_home.predict(X_test)
|
||||
mae_ht_home = mean_absolute_error(y_ht_h_test, ht_home_preds)
|
||||
print(f" ✅ HT Home MAE: {mae_ht_home:.4f} goals")
|
||||
return df
|
||||
|
||||
# --- HT AWAY GOALS MODEL ---
|
||||
print("\n✈️ Training HT Away Goals Model...")
|
||||
xgb_ht_away = xgb.XGBRegressor(
|
||||
objective='reg:squarederror',
|
||||
n_estimators=1000,
|
||||
learning_rate=0.01,
|
||||
max_depth=5,
|
||||
subsample=0.7,
|
||||
colsample_bytree=0.7,
|
||||
n_jobs=-1,
|
||||
random_state=42
|
||||
|
||||
def temporal_split(df: pd.DataFrame, train_ratio: float = 0.80):
|
||||
"""
|
||||
Temporal train/test split by match date.
|
||||
Ensures no future information leaks into training.
|
||||
"""
|
||||
if "match_date" in df.columns:
|
||||
df = df.sort_values("match_date").reset_index(drop=True)
|
||||
elif "round" in df.columns:
|
||||
df = df.sort_values("round").reset_index(drop=True)
|
||||
|
||||
split_idx = int(len(df) * train_ratio)
|
||||
return df.iloc[:split_idx].copy(), df.iloc[split_idx:].copy()
|
||||
|
||||
|
||||
def train_single_model(X_train, y_train, X_test, y_test, name: str):
|
||||
"""Train a single XGBoost regression model with early stopping."""
|
||||
print(f"\n🏗️ Training {name} model...")
|
||||
|
||||
model = xgb.XGBRegressor(**XGB_PARAMS)
|
||||
model.fit(
|
||||
X_train, y_train,
|
||||
eval_set=[(X_test, y_test)],
|
||||
verbose=False,
|
||||
)
|
||||
xgb_ht_away.fit(X_train, y_ht_a_train, eval_set=[(X_test, y_ht_a_test)], verbose=False)
|
||||
|
||||
ht_away_preds = xgb_ht_away.predict(X_test)
|
||||
mae_ht_away = mean_absolute_error(y_ht_a_test, ht_away_preds)
|
||||
print(f" ✅ HT Away MAE: {mae_ht_away:.4f} goals")
|
||||
|
||||
# --- EVALUATE EXACT SCORE ACCURACY (ROUNDED) ---
|
||||
print("\n🎯 Exact FT Score Accuracy (Test Set):")
|
||||
correct = 0
|
||||
close = 0 # Within 1 goal diff for both
|
||||
|
||||
for h_true, a_true, h_pred, a_pred in zip(y_h_test, y_a_test, home_preds, away_preds):
|
||||
h_p = round(h_pred)
|
||||
a_p = round(a_pred)
|
||||
if h_p == h_true and a_p == a_true:
|
||||
correct += 1
|
||||
if abs(h_p - h_true) <= 1 and abs(a_p - a_true) <= 1:
|
||||
|
||||
preds = model.predict(X_test)
|
||||
|
||||
mae = mean_absolute_error(y_test, preds)
|
||||
rmse = np.sqrt(mean_squared_error(y_test, preds))
|
||||
r2 = r2_score(y_test, preds)
|
||||
|
||||
print(f" MAE: {mae:.4f} goals")
|
||||
print(f" RMSE: {rmse:.4f}")
|
||||
print(f" R²: {r2:.4f}")
|
||||
|
||||
return model, {"mae": mae, "rmse": rmse, "r2": r2}
|
||||
|
||||
|
||||
def evaluate_combined(models: dict, X_test, y_test_dict: dict):
|
||||
"""Evaluate combined score accuracy (FT and HT)."""
|
||||
print("\n🎯 Combined Score Evaluation (Test Set):")
|
||||
|
||||
# FT Score
|
||||
ft_h_preds = models["ft_home"].predict(X_test)
|
||||
ft_a_preds = models["ft_away"].predict(X_test)
|
||||
|
||||
y_ft_h = y_test_dict["score_home"].values
|
||||
y_ft_a = y_test_dict["score_away"].values
|
||||
|
||||
exact = 0
|
||||
close = 0
|
||||
result_correct = 0
|
||||
total = len(X_test)
|
||||
|
||||
for h_true, a_true, h_pred, a_pred in zip(y_ft_h, y_ft_a, ft_h_preds, ft_a_preds):
|
||||
hp = max(0, round(h_pred))
|
||||
ap = max(0, round(a_pred))
|
||||
|
||||
# Exact score
|
||||
if hp == h_true and ap == a_true:
|
||||
exact += 1
|
||||
|
||||
# Close (±1 each)
|
||||
if abs(hp - h_true) <= 1 and abs(ap - a_true) <= 1:
|
||||
close += 1
|
||||
|
||||
acc = correct / len(X_test) * 100
|
||||
close_acc = close / len(X_test) * 100
|
||||
print(f" Exact Match: {acc:.2f}%")
|
||||
print(f" Close Match (+/- 1 goal): {close_acc:.2f}%")
|
||||
|
||||
# Result direction (1X2)
|
||||
true_result = 1 if h_true > a_true else (0 if h_true == a_true else -1)
|
||||
pred_result = 1 if hp > ap else (0 if hp == ap else -1)
|
||||
if true_result == pred_result:
|
||||
result_correct += 1
|
||||
|
||||
print(f" FT Exact Score: {exact / total * 100:.2f}% ({exact}/{total})")
|
||||
print(f" FT Close (±1): {close / total * 100:.2f}% ({close}/{total})")
|
||||
print(f" FT Result (1X2): {result_correct / total * 100:.2f}% ({result_correct}/{total})")
|
||||
|
||||
# HT Score
|
||||
ht_h_preds = models["ht_home"].predict(X_test)
|
||||
ht_a_preds = models["ht_away"].predict(X_test)
|
||||
|
||||
y_ht_h = y_test_dict["ht_score_home"].values
|
||||
y_ht_a = y_test_dict["ht_score_away"].values
|
||||
|
||||
ht_exact = 0
|
||||
ht_total = len(X_test)
|
||||
|
||||
for h_true, a_true, h_pred, a_pred in zip(y_ht_h, y_ht_a, ht_h_preds, ht_a_preds):
|
||||
hp = max(0, round(h_pred))
|
||||
ap = max(0, round(a_pred))
|
||||
if hp == h_true and ap == a_true:
|
||||
ht_exact += 1
|
||||
|
||||
print(f" HT Exact Score: {ht_exact / ht_total * 100:.2f}% ({ht_exact}/{ht_total})")
|
||||
|
||||
return {
|
||||
"ft_exact_pct": exact / total * 100,
|
||||
"ft_close_pct": close / total * 100,
|
||||
"ft_result_pct": result_correct / total * 100,
|
||||
"ht_exact_pct": ht_exact / ht_total * 100,
|
||||
}
|
||||
|
||||
|
||||
def train():
|
||||
"""Main training pipeline."""
|
||||
print("🚀 Score Prediction Model Trainer (V25-Compatible)")
|
||||
print(f" Feature count: {len(FEATURES)}")
|
||||
print("=" * 60)
|
||||
|
||||
# Load data
|
||||
df = load_data()
|
||||
print(f" Total valid rows: {len(df)}")
|
||||
|
||||
# Temporal split
|
||||
train_df, test_df = temporal_split(df)
|
||||
print(f" Training set: {len(train_df)} matches")
|
||||
print(f" Test set: {len(test_df)} matches (temporally after training)")
|
||||
|
||||
X_train = train_df[FEATURES]
|
||||
X_test = test_df[FEATURES]
|
||||
|
||||
# Train 4 models
|
||||
models = {}
|
||||
metrics = {}
|
||||
|
||||
for target_name, model_key in [
|
||||
("score_home", "ft_home"),
|
||||
("score_away", "ft_away"),
|
||||
("ht_score_home", "ht_home"),
|
||||
("ht_score_away", "ht_away"),
|
||||
]:
|
||||
model, metric = train_single_model(
|
||||
X_train, train_df[target_name],
|
||||
X_test, test_df[target_name],
|
||||
model_key,
|
||||
)
|
||||
models[model_key] = model
|
||||
metrics[model_key] = metric
|
||||
|
||||
# Combined evaluation
|
||||
y_test_dict = {t: test_df[t] for t in TARGETS}
|
||||
combined = evaluate_combined(models, X_test, y_test_dict)
|
||||
|
||||
# Save
|
||||
print(f"\n💾 Saving models to {MODEL_PATH}...")
|
||||
print(f"\n💾 Saving to {MODEL_PATH}...")
|
||||
model_data = {
|
||||
"home_model": xgb_home,
|
||||
"away_model": xgb_away,
|
||||
"ht_home_model": xgb_ht_home,
|
||||
"ht_away_model": xgb_ht_away,
|
||||
"home_model": models["ft_home"],
|
||||
"away_model": models["ft_away"],
|
||||
"ht_home_model": models["ht_home"],
|
||||
"ht_away_model": models["ht_away"],
|
||||
"features": FEATURES,
|
||||
"meta": {
|
||||
"mae_home": mae_home,
|
||||
"mae_away": mae_away,
|
||||
"mae_ht_home": mae_ht_home,
|
||||
"mae_ht_away": mae_ht_away,
|
||||
"acc": acc
|
||||
}
|
||||
**{f"{k}_{mk}": mv for k, m in metrics.items() for mk, mv in m.items()},
|
||||
**combined,
|
||||
"trained_at": datetime.now().isoformat(),
|
||||
"feature_count": len(FEATURES),
|
||||
"train_size": len(train_df),
|
||||
"test_size": len(test_df),
|
||||
},
|
||||
}
|
||||
|
||||
with open(MODEL_PATH, "wb") as f:
|
||||
pickle.dump(model_data, f)
|
||||
|
||||
print("✅ Done.")
|
||||
|
||||
print("\n✅ Score model training complete!")
|
||||
print(f" Saved: {MODEL_PATH}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
train()
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
"""
|
||||
V25 Pro Model Trainer — Optuna + Isotonic Calibration
|
||||
=====================================================
|
||||
Combines V25's 83 features + 12 markets + temporal split
|
||||
with Optuna hyperparameter tuning and Isotonic Regression calibration.
|
||||
|
||||
Usage:
|
||||
python scripts/train_v25_pro.py
|
||||
python scripts/train_v25_pro.py --markets MS,OU25,BTTS # specific markets
|
||||
python scripts/train_v25_pro.py --trials 30 # fewer trials
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import pickle
|
||||
import argparse
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import xgboost as xgb
|
||||
import lightgbm as lgb
|
||||
import optuna
|
||||
from optuna.samplers import TPESampler
|
||||
from datetime import datetime
|
||||
from sklearn.metrics import accuracy_score, log_loss, classification_report
|
||||
from sklearn.isotonic import IsotonicRegression
|
||||
from sklearn.base import BaseEstimator, ClassifierMixin
|
||||
|
||||
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
AI_ENGINE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DATA_PATH = os.path.join(AI_ENGINE_DIR, "data", "training_data.csv")
|
||||
MODELS_DIR = os.path.join(AI_ENGINE_DIR, "models", "v25")
|
||||
REPORTS_DIR = os.path.join(AI_ENGINE_DIR, "reports", "training_v25")
|
||||
|
||||
os.makedirs(MODELS_DIR, exist_ok=True)
|
||||
os.makedirs(REPORTS_DIR, exist_ok=True)
|
||||
|
||||
# ─── Feature Columns (95 features, NO target leakage) ───────────────
|
||||
FEATURES = [
|
||||
# ELO (8)
|
||||
"home_overall_elo", "away_overall_elo", "elo_diff",
|
||||
"home_home_elo", "away_away_elo",
|
||||
"home_form_elo", "away_form_elo", "form_elo_diff",
|
||||
# Form (12)
|
||||
"home_goals_avg", "home_conceded_avg",
|
||||
"away_goals_avg", "away_conceded_avg",
|
||||
"home_clean_sheet_rate", "away_clean_sheet_rate",
|
||||
"home_scoring_rate", "away_scoring_rate",
|
||||
"home_winning_streak", "away_winning_streak",
|
||||
"home_unbeaten_streak", "away_unbeaten_streak",
|
||||
# H2H (6)
|
||||
"h2h_total_matches", "h2h_home_win_rate", "h2h_draw_rate",
|
||||
"h2h_avg_goals", "h2h_btts_rate", "h2h_over25_rate",
|
||||
# Team Stats (8)
|
||||
"home_avg_possession", "away_avg_possession",
|
||||
"home_avg_shots_on_target", "away_avg_shots_on_target",
|
||||
"home_shot_conversion", "away_shot_conversion",
|
||||
"home_avg_corners", "away_avg_corners",
|
||||
# Odds (24 + 20 presence flags)
|
||||
"odds_ms_h", "odds_ms_d", "odds_ms_a",
|
||||
"implied_home", "implied_draw", "implied_away",
|
||||
"odds_ht_ms_h", "odds_ht_ms_d", "odds_ht_ms_a",
|
||||
"odds_ou05_o", "odds_ou05_u",
|
||||
"odds_ou15_o", "odds_ou15_u",
|
||||
"odds_ou25_o", "odds_ou25_u",
|
||||
"odds_ou35_o", "odds_ou35_u",
|
||||
"odds_ht_ou05_o", "odds_ht_ou05_u",
|
||||
"odds_ht_ou15_o", "odds_ht_ou15_u",
|
||||
"odds_btts_y", "odds_btts_n",
|
||||
"odds_ms_h_present", "odds_ms_d_present", "odds_ms_a_present",
|
||||
"odds_ht_ms_h_present", "odds_ht_ms_d_present", "odds_ht_ms_a_present",
|
||||
"odds_ou05_o_present", "odds_ou05_u_present",
|
||||
"odds_ou15_o_present", "odds_ou15_u_present",
|
||||
"odds_ou25_o_present", "odds_ou25_u_present",
|
||||
"odds_ou35_o_present", "odds_ou35_u_present",
|
||||
"odds_ht_ou05_o_present", "odds_ht_ou05_u_present",
|
||||
"odds_ht_ou15_o_present", "odds_ht_ou15_u_present",
|
||||
"odds_btts_y_present", "odds_btts_n_present",
|
||||
# League (4)
|
||||
"home_xga", "away_xga",
|
||||
"league_avg_goals", "league_zero_goal_rate",
|
||||
# Upset Engine (4)
|
||||
"upset_atmosphere", "upset_motivation", "upset_fatigue", "upset_potential",
|
||||
# Referee Engine (5)
|
||||
"referee_home_bias", "referee_avg_goals", "referee_cards_total",
|
||||
"referee_avg_yellow", "referee_experience",
|
||||
# Momentum (3)
|
||||
"home_momentum_score", "away_momentum_score", "momentum_diff",
|
||||
# Squad (9)
|
||||
"home_squad_quality", "away_squad_quality", "squad_diff",
|
||||
"home_key_players", "away_key_players",
|
||||
"home_missing_impact", "away_missing_impact",
|
||||
"home_goals_form", "away_goals_form",
|
||||
# Player-Level Features (12)
|
||||
"home_lineup_goals_per90", "away_lineup_goals_per90",
|
||||
"home_lineup_assists_per90", "away_lineup_assists_per90",
|
||||
"home_squad_continuity", "away_squad_continuity",
|
||||
"home_top_scorer_form", "away_top_scorer_form",
|
||||
"home_avg_player_exp", "away_avg_player_exp",
|
||||
"home_goals_diversity", "away_goals_diversity",
|
||||
]
|
||||
|
||||
MARKET_CONFIGS = [
|
||||
{"target": "label_ms", "name": "MS", "num_class": 3},
|
||||
{"target": "label_ou15", "name": "OU15", "num_class": 2},
|
||||
{"target": "label_ou25", "name": "OU25", "num_class": 2},
|
||||
{"target": "label_ou35", "name": "OU35", "num_class": 2},
|
||||
{"target": "label_btts", "name": "BTTS", "num_class": 2},
|
||||
{"target": "label_ht_result", "name": "HT_RESULT", "num_class": 3},
|
||||
{"target": "label_ht_ou05", "name": "HT_OU05", "num_class": 2},
|
||||
{"target": "label_ht_ou15", "name": "HT_OU15", "num_class": 2},
|
||||
{"target": "label_ht_ft", "name": "HTFT", "num_class": 9},
|
||||
{"target": "label_odd_even", "name": "ODD_EVEN", "num_class": 2},
|
||||
{"target": "label_cards_ou45", "name": "CARDS_OU45", "num_class": 2},
|
||||
{"target": "label_handicap_ms", "name": "HANDICAP_MS", "num_class": 3},
|
||||
]
|
||||
|
||||
|
||||
def load_data():
|
||||
"""Load and prepare training data."""
|
||||
if not os.path.exists(DATA_PATH):
|
||||
print(f"[ERROR] Data not found: {DATA_PATH}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[INFO] Loading {DATA_PATH}...")
|
||||
df = pd.read_csv(DATA_PATH)
|
||||
|
||||
for col in FEATURES:
|
||||
if col in df.columns:
|
||||
df[col] = df[col].fillna(0)
|
||||
|
||||
# Derive odds presence flags for older CSVs
|
||||
odds_flag_sources = {
|
||||
"odds_ms_h_present": "odds_ms_h", "odds_ms_d_present": "odds_ms_d",
|
||||
"odds_ms_a_present": "odds_ms_a", "odds_ht_ms_h_present": "odds_ht_ms_h",
|
||||
"odds_ht_ms_d_present": "odds_ht_ms_d", "odds_ht_ms_a_present": "odds_ht_ms_a",
|
||||
"odds_ou05_o_present": "odds_ou05_o", "odds_ou05_u_present": "odds_ou05_u",
|
||||
"odds_ou15_o_present": "odds_ou15_o", "odds_ou15_u_present": "odds_ou15_u",
|
||||
"odds_ou25_o_present": "odds_ou25_o", "odds_ou25_u_present": "odds_ou25_u",
|
||||
"odds_ou35_o_present": "odds_ou35_o", "odds_ou35_u_present": "odds_ou35_u",
|
||||
"odds_ht_ou05_o_present": "odds_ht_ou05_o", "odds_ht_ou05_u_present": "odds_ht_ou05_u",
|
||||
"odds_ht_ou15_o_present": "odds_ht_ou15_o", "odds_ht_ou15_u_present": "odds_ht_ou15_u",
|
||||
"odds_btts_y_present": "odds_btts_y", "odds_btts_n_present": "odds_btts_n",
|
||||
}
|
||||
for flag_col, odds_col in odds_flag_sources.items():
|
||||
if flag_col not in df.columns:
|
||||
df[flag_col] = (
|
||||
pd.to_numeric(df.get(odds_col, 0), errors="coerce").fillna(0) > 1.01
|
||||
).astype(float)
|
||||
|
||||
print(f"[INFO] Shape: {df.shape}, Features: {len(FEATURES)}")
|
||||
return df
|
||||
|
||||
|
||||
def temporal_split_4way(valid_df: pd.DataFrame):
|
||||
"""Chronological 60/15/10/15 split: train/val/cal/test."""
|
||||
ordered = valid_df.sort_values("mst_utc").reset_index(drop=True)
|
||||
n = len(ordered)
|
||||
i1 = int(n * 0.60)
|
||||
i2 = int(n * 0.75)
|
||||
i3 = int(n * 0.85)
|
||||
|
||||
train = ordered.iloc[:i1].copy()
|
||||
val = ordered.iloc[i1:i2].copy()
|
||||
cal = ordered.iloc[i2:i3].copy()
|
||||
test = ordered.iloc[i3:].copy()
|
||||
|
||||
return train, val, cal, test
|
||||
|
||||
|
||||
# ─── XGBoost Wrapper for sklearn CalibratedClassifierCV ─────────────
|
||||
class XGBWrapper(BaseEstimator, ClassifierMixin):
|
||||
"""Thin sklearn-compatible wrapper around xgb.train for Isotonic calibration."""
|
||||
|
||||
def __init__(self, params, num_boost_round=500):
|
||||
self.params = params
|
||||
self.num_boost_round = num_boost_round
|
||||
self.model_ = None
|
||||
self.classes_ = None
|
||||
|
||||
def fit(self, X, y, **kwargs):
|
||||
self.classes_ = np.unique(y)
|
||||
dtrain = xgb.DMatrix(X, label=y)
|
||||
self.model_ = xgb.train(self.params, dtrain, num_boost_round=self.num_boost_round)
|
||||
return self
|
||||
|
||||
def predict_proba(self, X):
|
||||
dm = xgb.DMatrix(X)
|
||||
probs = self.model_.predict(dm)
|
||||
if len(probs.shape) == 1:
|
||||
probs = np.column_stack([1 - probs, probs])
|
||||
return probs
|
||||
|
||||
def predict(self, X):
|
||||
return np.argmax(self.predict_proba(X), axis=1)
|
||||
|
||||
|
||||
# ─── Optuna Objectives ──────────────────────────────────────────────
|
||||
def xgb_objective(trial, X_train, y_train, X_val, y_val, num_class):
|
||||
params = {
|
||||
"objective": "multi:softprob" if num_class > 2 else "binary:logistic",
|
||||
"eval_metric": "mlogloss" if num_class > 2 else "logloss",
|
||||
"max_depth": trial.suggest_int("max_depth", 3, 8),
|
||||
"eta": trial.suggest_float("eta", 0.01, 0.15, log=True),
|
||||
"subsample": trial.suggest_float("subsample", 0.6, 1.0),
|
||||
"colsample_bytree": trial.suggest_float("colsample_bytree", 0.5, 1.0),
|
||||
"min_child_weight": trial.suggest_int("min_child_weight", 1, 10),
|
||||
"gamma": trial.suggest_float("gamma", 1e-8, 1.0, log=True),
|
||||
"reg_lambda": trial.suggest_float("reg_lambda", 1e-8, 10.0, log=True),
|
||||
"reg_alpha": trial.suggest_float("reg_alpha", 1e-8, 1.0, log=True),
|
||||
"n_jobs": 4,
|
||||
"random_state": 42,
|
||||
}
|
||||
if num_class > 2:
|
||||
params["num_class"] = num_class
|
||||
|
||||
dtrain = xgb.DMatrix(X_train, label=y_train)
|
||||
dval = xgb.DMatrix(X_val, label=y_val)
|
||||
|
||||
model = xgb.train(
|
||||
params, dtrain, num_boost_round=1000,
|
||||
evals=[(dval, "val")], early_stopping_rounds=50, verbose_eval=False,
|
||||
)
|
||||
|
||||
preds = model.predict(dval)
|
||||
if len(preds.shape) == 1:
|
||||
preds = np.column_stack([1 - preds, preds])
|
||||
|
||||
return log_loss(y_val, preds)
|
||||
|
||||
|
||||
def lgb_objective(trial, X_train, y_train, X_val, y_val, num_class):
|
||||
params = {
|
||||
"objective": "multiclass" if num_class > 2 else "binary",
|
||||
"metric": "multi_logloss" if num_class > 2 else "binary_logloss",
|
||||
"max_depth": trial.suggest_int("max_depth", 3, 8),
|
||||
"learning_rate": trial.suggest_float("learning_rate", 0.01, 0.15, log=True),
|
||||
"feature_fraction": trial.suggest_float("feature_fraction", 0.5, 1.0),
|
||||
"bagging_fraction": trial.suggest_float("bagging_fraction", 0.6, 1.0),
|
||||
"bagging_freq": trial.suggest_int("bagging_freq", 1, 7),
|
||||
"min_child_samples": trial.suggest_int("min_child_samples", 5, 50),
|
||||
"lambda_l1": trial.suggest_float("lambda_l1", 1e-8, 1.0, log=True),
|
||||
"lambda_l2": trial.suggest_float("lambda_l2", 1e-8, 10.0, log=True),
|
||||
"n_jobs": 4, "random_state": 42, "verbose": -1,
|
||||
}
|
||||
if num_class > 2:
|
||||
params["num_class"] = num_class
|
||||
|
||||
train_data = lgb.Dataset(X_train, label=y_train)
|
||||
val_data = lgb.Dataset(X_val, label=y_val, reference=train_data)
|
||||
|
||||
model = lgb.train(
|
||||
params, train_data, num_boost_round=1000,
|
||||
valid_sets=[val_data], valid_names=["val"],
|
||||
callbacks=[lgb.early_stopping(50), lgb.log_evaluation(0)],
|
||||
)
|
||||
|
||||
preds = model.predict(X_val, num_iteration=model.best_iteration)
|
||||
if len(preds.shape) == 1:
|
||||
preds = np.column_stack([1 - preds, preds])
|
||||
|
||||
return log_loss(y_val, preds)
|
||||
|
||||
|
||||
# ─── Main Training Pipeline ─────────────────────────────────────────
|
||||
def train_market(df, target_col, market_name, num_class, n_trials):
|
||||
"""Full pipeline for one market: Optuna → Train → Calibrate → Evaluate."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"[MARKET] {market_name} (classes={num_class})")
|
||||
print(f"{'='*60}")
|
||||
|
||||
valid_df = df[df[target_col].notna()].copy()
|
||||
valid_df = valid_df[valid_df[target_col].astype(str) != ""].copy()
|
||||
print(f"[INFO] Valid samples: {len(valid_df)}")
|
||||
|
||||
if len(valid_df) < 500:
|
||||
print(f"[SKIP] Not enough data for {market_name}")
|
||||
return None
|
||||
|
||||
available_features = [f for f in FEATURES if f in valid_df.columns]
|
||||
print(f"[INFO] Features: {len(available_features)}/{len(FEATURES)}")
|
||||
|
||||
train_df, val_df, cal_df, test_df = temporal_split_4way(valid_df)
|
||||
X_train = train_df[available_features].values
|
||||
X_val = val_df[available_features].values
|
||||
X_cal = cal_df[available_features].values
|
||||
X_test = test_df[available_features].values
|
||||
y_train = train_df[target_col].astype(int).values
|
||||
y_val = val_df[target_col].astype(int).values
|
||||
y_cal = cal_df[target_col].astype(int).values
|
||||
y_test = test_df[target_col].astype(int).values
|
||||
|
||||
print(f"[INFO] Split: train={len(X_train)} val={len(X_val)} cal={len(X_cal)} test={len(X_test)}")
|
||||
|
||||
# ── Phase 1: Optuna XGBoost ──────────────────────────────────
|
||||
print(f"\n[OPTUNA] XGBoost tuning ({n_trials} trials)...")
|
||||
xgb_study = optuna.create_study(direction="minimize", sampler=TPESampler(seed=42))
|
||||
xgb_study.optimize(
|
||||
lambda trial: xgb_objective(trial, X_train, y_train, X_val, y_val, num_class),
|
||||
n_trials=n_trials,
|
||||
)
|
||||
xgb_best = xgb_study.best_params
|
||||
print(f"[OK] XGB best logloss: {xgb_study.best_value:.4f}")
|
||||
|
||||
# ── Phase 2: Optuna LightGBM ─────────────────────────────────
|
||||
print(f"[OPTUNA] LightGBM tuning ({n_trials} trials)...")
|
||||
lgb_study = optuna.create_study(direction="minimize", sampler=TPESampler(seed=42))
|
||||
lgb_study.optimize(
|
||||
lambda trial: lgb_objective(trial, X_train, y_train, X_val, y_val, num_class),
|
||||
n_trials=n_trials,
|
||||
)
|
||||
lgb_best = lgb_study.best_params
|
||||
print(f"[OK] LGB best logloss: {lgb_study.best_value:.4f}")
|
||||
|
||||
# ── Phase 3: Train final models with best params ─────────────
|
||||
# XGBoost final
|
||||
xgb_params = {
|
||||
"objective": "multi:softprob" if num_class > 2 else "binary:logistic",
|
||||
"eval_metric": "mlogloss" if num_class > 2 else "logloss",
|
||||
"n_jobs": 4, "random_state": 42,
|
||||
**{k: v for k, v in xgb_best.items()},
|
||||
}
|
||||
if num_class > 2:
|
||||
xgb_params["num_class"] = num_class
|
||||
|
||||
dtrain = xgb.DMatrix(X_train, label=y_train)
|
||||
dval = xgb.DMatrix(X_val, label=y_val)
|
||||
xgb_model = xgb.train(
|
||||
xgb_params, dtrain, num_boost_round=1500,
|
||||
evals=[(dtrain, "train"), (dval, "val")],
|
||||
early_stopping_rounds=80, verbose_eval=200,
|
||||
)
|
||||
print(f"[OK] XGB final: iter={xgb_model.best_iteration}, score={xgb_model.best_score:.4f}")
|
||||
|
||||
# LightGBM final
|
||||
lgb_params = {
|
||||
"objective": "multiclass" if num_class > 2 else "binary",
|
||||
"metric": "multi_logloss" if num_class > 2 else "binary_logloss",
|
||||
"n_jobs": 4, "random_state": 42, "verbose": -1,
|
||||
**{k: v for k, v in lgb_best.items()},
|
||||
}
|
||||
if num_class > 2:
|
||||
lgb_params["num_class"] = num_class
|
||||
|
||||
lgb_train_data = lgb.Dataset(X_train, label=y_train)
|
||||
lgb_val_data = lgb.Dataset(X_val, label=y_val, reference=lgb_train_data)
|
||||
lgb_model = lgb.train(
|
||||
lgb_params, lgb_train_data, num_boost_round=1500,
|
||||
valid_sets=[lgb_train_data, lgb_val_data],
|
||||
valid_names=["train", "val"],
|
||||
callbacks=[lgb.early_stopping(80), lgb.log_evaluation(200)],
|
||||
)
|
||||
print(f"[OK] LGB final: iter={lgb_model.best_iteration}")
|
||||
|
||||
# ── Phase 4: Isotonic Calibration on cal set ─────────────────
|
||||
print("[CAL] Fitting Isotonic Regression (per-class)...")
|
||||
|
||||
# XGB calibration — manual IsotonicRegression per class
|
||||
dcal = xgb.DMatrix(X_cal)
|
||||
xgb_cal_raw = xgb_model.predict(dcal)
|
||||
if len(xgb_cal_raw.shape) == 1:
|
||||
xgb_cal_raw = np.column_stack([1 - xgb_cal_raw, xgb_cal_raw])
|
||||
|
||||
xgb_iso_calibrators = []
|
||||
for cls_idx in range(num_class):
|
||||
ir = IsotonicRegression(out_of_bounds="clip")
|
||||
y_binary = (y_cal == cls_idx).astype(float)
|
||||
ir.fit(xgb_cal_raw[:, cls_idx], y_binary)
|
||||
xgb_iso_calibrators.append(ir)
|
||||
print(f"[OK] XGB Isotonic calibrators fitted: {num_class} classes")
|
||||
|
||||
# LGB calibration — manual IsotonicRegression per class
|
||||
lgb_cal_raw = lgb_model.predict(X_cal, num_iteration=lgb_model.best_iteration)
|
||||
if len(lgb_cal_raw.shape) == 1:
|
||||
lgb_cal_raw = np.column_stack([1 - lgb_cal_raw, lgb_cal_raw])
|
||||
|
||||
lgb_iso_calibrators = []
|
||||
for cls_idx in range(num_class):
|
||||
ir = IsotonicRegression(out_of_bounds="clip")
|
||||
y_binary = (y_cal == cls_idx).astype(float)
|
||||
ir.fit(lgb_cal_raw[:, cls_idx], y_binary)
|
||||
lgb_iso_calibrators.append(ir)
|
||||
print(f"[OK] LGB Isotonic calibrators fitted: {num_class} classes")
|
||||
|
||||
# ── Phase 5: Evaluate on test set ────────────────────────────
|
||||
print("\n[EVAL] Test set evaluation...")
|
||||
dtest = xgb.DMatrix(X_test)
|
||||
|
||||
# Raw XGB
|
||||
xgb_raw_probs = xgb_model.predict(dtest)
|
||||
if len(xgb_raw_probs.shape) == 1:
|
||||
xgb_raw_probs = np.column_stack([1 - xgb_raw_probs, xgb_raw_probs])
|
||||
|
||||
# Calibrated XGB — apply isotonic per class + renormalize
|
||||
xgb_cal_probs = np.column_stack([
|
||||
xgb_iso_calibrators[i].predict(xgb_raw_probs[:, i]) for i in range(num_class)
|
||||
])
|
||||
xgb_cal_probs = xgb_cal_probs / xgb_cal_probs.sum(axis=1, keepdims=True)
|
||||
|
||||
# Raw LGB
|
||||
lgb_raw_probs = lgb_model.predict(X_test, num_iteration=lgb_model.best_iteration)
|
||||
if len(lgb_raw_probs.shape) == 1:
|
||||
lgb_raw_probs = np.column_stack([1 - lgb_raw_probs, lgb_raw_probs])
|
||||
|
||||
# Calibrated LGB — apply isotonic per class + renormalize
|
||||
lgb_cal_probs = np.column_stack([
|
||||
lgb_iso_calibrators[i].predict(lgb_raw_probs[:, i]) for i in range(num_class)
|
||||
])
|
||||
lgb_cal_probs = lgb_cal_probs / lgb_cal_probs.sum(axis=1, keepdims=True)
|
||||
|
||||
# Ensembles
|
||||
raw_ensemble = (xgb_raw_probs + lgb_raw_probs) / 2
|
||||
cal_ensemble = (xgb_cal_probs + lgb_cal_probs) / 2
|
||||
|
||||
def _eval(probs, label):
|
||||
preds = np.argmax(probs, axis=1)
|
||||
acc = accuracy_score(y_test, preds)
|
||||
ll = log_loss(y_test, probs)
|
||||
print(f" {label}: Acc={acc:.4f} LogLoss={ll:.4f}")
|
||||
return {"accuracy": round(float(acc), 4), "logloss": round(float(ll), 4)}
|
||||
|
||||
m_xgb_raw = _eval(xgb_raw_probs, "XGB Raw")
|
||||
m_xgb_cal = _eval(xgb_cal_probs, "XGB Calibrated")
|
||||
m_lgb_raw = _eval(lgb_raw_probs, "LGB Raw")
|
||||
m_lgb_cal = _eval(lgb_cal_probs, "LGB Calibrated")
|
||||
m_ensemble = _eval(raw_ensemble, "Ensemble Raw")
|
||||
m_cal_ensemble = _eval(cal_ensemble, "Ensemble Calibrated")
|
||||
|
||||
# Classification report for ensemble
|
||||
ens_preds = np.argmax(raw_ensemble, axis=1)
|
||||
print(f"\n[REPORT] Ensemble Classification Report:")
|
||||
print(classification_report(y_test, ens_preds))
|
||||
|
||||
# ── Phase 6: Save models ─────────────────────────────────────
|
||||
# Raw models (orchestrator compatible)
|
||||
xgb_path = os.path.join(MODELS_DIR, f"xgb_v25_{market_name.lower()}.json")
|
||||
xgb_model.save_model(xgb_path)
|
||||
print(f"[SAVE] {xgb_path}")
|
||||
|
||||
lgb_path = os.path.join(MODELS_DIR, f"lgb_v25_{market_name.lower()}.txt")
|
||||
lgb_model.save_model(lgb_path)
|
||||
print(f"[SAVE] {lgb_path}")
|
||||
|
||||
# Isotonic calibrators (XGB + LGB)
|
||||
xgb_cal_path = os.path.join(MODELS_DIR, f"iso_xgb_v25_{market_name.lower()}.pkl")
|
||||
with open(xgb_cal_path, "wb") as f:
|
||||
pickle.dump(xgb_iso_calibrators, f)
|
||||
print(f"[SAVE] {xgb_cal_path}")
|
||||
|
||||
lgb_cal_path = os.path.join(MODELS_DIR, f"iso_lgb_v25_{market_name.lower()}.pkl")
|
||||
with open(lgb_cal_path, "wb") as f:
|
||||
pickle.dump(lgb_iso_calibrators, f)
|
||||
print(f"[SAVE] {lgb_cal_path}")
|
||||
|
||||
return {
|
||||
"market": market_name,
|
||||
"samples": int(len(valid_df)),
|
||||
"train": int(len(X_train)),
|
||||
"val": int(len(X_val)),
|
||||
"cal": int(len(X_cal)),
|
||||
"test": int(len(X_test)),
|
||||
"features_used": len(available_features),
|
||||
"xgb_best_params": xgb_best,
|
||||
"lgb_best_params": lgb_best,
|
||||
"xgb_best_iteration": int(xgb_model.best_iteration),
|
||||
"lgb_best_iteration": int(lgb_model.best_iteration),
|
||||
"xgb_optuna_best_logloss": round(float(xgb_study.best_value), 4),
|
||||
"lgb_optuna_best_logloss": round(float(lgb_study.best_value), 4),
|
||||
"test_xgb_raw": m_xgb_raw,
|
||||
"test_xgb_calibrated": m_xgb_cal,
|
||||
"test_lgb_raw": m_lgb_raw,
|
||||
"test_lgb_calibrated": m_lgb_cal,
|
||||
"test_ensemble_raw": m_ensemble,
|
||||
"test_ensemble_calibrated": m_cal_ensemble,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="V25 Pro Trainer")
|
||||
parser.add_argument("--markets", type=str, default=None,
|
||||
help="Comma-separated market names (e.g., MS,OU25,BTTS)")
|
||||
parser.add_argument("--trials", type=int, default=50,
|
||||
help="Optuna trials per model per market")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print("V25 PRO — Optuna + Isotonic Calibration")
|
||||
print("=" * 60)
|
||||
print(f"[INFO] Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"[INFO] Trials per model: {args.trials}")
|
||||
print(f"[INFO] Total features: {len(FEATURES)}")
|
||||
|
||||
df = load_data()
|
||||
|
||||
configs = MARKET_CONFIGS
|
||||
if args.markets:
|
||||
selected = [m.strip().upper() for m in args.markets.split(",")]
|
||||
configs = [c for c in configs if c["name"] in selected]
|
||||
print(f"[INFO] Selected markets: {[c['name'] for c in configs]}")
|
||||
|
||||
all_metrics = {
|
||||
"trained_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"trainer": "v25_pro",
|
||||
"optuna_trials": args.trials,
|
||||
"total_features": len(FEATURES),
|
||||
"markets": {},
|
||||
}
|
||||
|
||||
for config in configs:
|
||||
target = config["target"]
|
||||
if target not in df.columns:
|
||||
print(f"[SKIP] {config['name']}: missing target {target}")
|
||||
continue
|
||||
|
||||
metrics = train_market(
|
||||
df, target, config["name"], config["num_class"], args.trials,
|
||||
)
|
||||
if metrics:
|
||||
all_metrics["markets"][config["name"]] = metrics
|
||||
|
||||
# Save feature list
|
||||
feature_path = os.path.join(MODELS_DIR, "feature_cols.json")
|
||||
with open(feature_path, "w") as f:
|
||||
json.dump(FEATURES, f, indent=2)
|
||||
|
||||
# Save full report
|
||||
report_path = os.path.join(REPORTS_DIR, "v25_pro_metrics.json")
|
||||
with open(report_path, "w") as f:
|
||||
json.dump(all_metrics, f, indent=2, default=str)
|
||||
print(f"\n[SAVE] Report: {report_path}")
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print("[SUMMARY]")
|
||||
print("=" * 60)
|
||||
for name, m in all_metrics["markets"].items():
|
||||
ens = m.get("test_ensemble_calibrated", m.get("test_ensemble_raw", {}))
|
||||
acc = ens.get('accuracy', '?')
|
||||
ll = ens.get('logloss', '?')
|
||||
acc_s = f"{acc:.4f}" if isinstance(acc, float) else str(acc)
|
||||
ll_s = f"{ll:.4f}" if isinstance(ll, float) else str(ll)
|
||||
print(f" {name:12s} | Acc={acc_s:>6s} | LL={ll_s:>6s} | "
|
||||
f"XGB_iter={m.get('xgb_best_iteration','?')} LGB_iter={m.get('lgb_best_iteration','?')}")
|
||||
|
||||
print(f"\n[INFO] Completed: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print("[OK] V25 PRO Training Complete!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -20,7 +20,7 @@ from sklearn.isotonic import IsotonicRegression
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
AI_DIR = Path(__file__).resolve().parent.parent
|
||||
DATA_CSV = AI_DIR / "data" / "training_data_v27.csv"
|
||||
DATA_CSV = AI_DIR / "data" / "training_data.csv"
|
||||
MODELS_DIR = AI_DIR / "models" / "v27"
|
||||
MODELS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -373,15 +373,52 @@ def main():
|
||||
print("\n" + "─"*65)
|
||||
print(" STAGE A.2: Fundamentals-Only O/U 2.5 Model")
|
||||
print("─"*65)
|
||||
y_tr_ou = tr["label_ou25"].values
|
||||
y_va_ou = va["label_ou25"].values
|
||||
y_tr_ou = tr['label_ou25'].values
|
||||
y_va_ou = va['label_ou25'].values
|
||||
mask_tr = ~np.isnan(y_tr_ou)
|
||||
mask_va = ~np.isnan(y_va_ou)
|
||||
if mask_tr.sum() > 1000:
|
||||
ou_models = train_fundamentals_model(
|
||||
X_tr[mask_tr], y_tr_ou[mask_tr].astype(int),
|
||||
X_va[mask_va], y_va_ou[mask_va].astype(int),
|
||||
clean_feats, "ou25")
|
||||
clean_feats, 'ou25')
|
||||
|
||||
# ── STAGE A.3: BTTS Model ──
|
||||
btts_models = None
|
||||
if 'label_btts' in tr.columns:
|
||||
print('\n' + '─' * 65)
|
||||
print(' STAGE A.3: Fundamentals-Only BTTS Model')
|
||||
print('─' * 65)
|
||||
y_tr_btts = tr['label_btts'].values
|
||||
y_va_btts = va['label_btts'].values
|
||||
mask_tr_btts = ~np.isnan(y_tr_btts)
|
||||
mask_va_btts = ~np.isnan(y_va_btts)
|
||||
if mask_tr_btts.sum() > 1000:
|
||||
btts_models = train_fundamentals_model(
|
||||
X_tr[mask_tr_btts], y_tr_btts[mask_tr_btts].astype(int),
|
||||
X_va[mask_va_btts], y_va_btts[mask_va_btts].astype(int),
|
||||
clean_feats, 'btts')
|
||||
|
||||
# Quick val accuracy
|
||||
btts_probs = ensemble_predict(
|
||||
btts_models,
|
||||
X_va[mask_va_btts],
|
||||
clean_feats,
|
||||
n_class=2,
|
||||
)
|
||||
btts_acc = accuracy_score(
|
||||
y_va_btts[mask_va_btts].astype(int),
|
||||
btts_probs.argmax(1),
|
||||
)
|
||||
btts_ll = log_loss(
|
||||
y_va_btts[mask_va_btts].astype(int),
|
||||
btts_probs,
|
||||
)
|
||||
print(f'\n BTTS Ensemble Val: acc={btts_acc:.4f}, logloss={btts_ll:.4f}')
|
||||
# Compare with naive baseline (always predict majority class)
|
||||
btts_majority = y_va_btts[mask_va_btts].astype(int).mean()
|
||||
print(f' BTTS baseline: {max(btts_majority, 1-btts_majority):.4f} (majority class)')
|
||||
print(f' Model vs baseline: {btts_acc - max(btts_majority, 1-btts_majority):+.4f}')
|
||||
|
||||
# ── STAGE C: Backtest ──
|
||||
print("\n" + "─"*65)
|
||||
@@ -422,13 +459,58 @@ def main():
|
||||
|
||||
# OU25 backtest
|
||||
if ou_models:
|
||||
print("\n --- O/U 2.5 Backtest ---")
|
||||
print('\n --- O/U 2.5 Backtest ---')
|
||||
for edge in [0.05, 0.07, 0.10]:
|
||||
r = backtest_value(ou_models, te, clean_feats, "ou25",
|
||||
r = backtest_value(ou_models, te, clean_feats, 'ou25',
|
||||
min_edge=edge, min_odds=1.50, max_odds=3.0,
|
||||
use_kelly=True)
|
||||
if r.get("total", 0) > 0:
|
||||
print_backtest(r, f"OU25 edge>{edge}")
|
||||
if r.get('total', 0) > 0:
|
||||
print_backtest(r, f'OU25 edge>{edge}')
|
||||
|
||||
# BTTS backtest
|
||||
if btts_models and 'label_btts' in te.columns:
|
||||
print('\n --- BTTS Backtest ---')
|
||||
# Build BTTS odds for backtest
|
||||
if 'odds_btts_y' in te.columns and 'odds_btts_n' in te.columns:
|
||||
te_btts = te.copy()
|
||||
te_btts['odds_btts_y'] = pd.to_numeric(
|
||||
te_btts['odds_btts_y'], errors='coerce',
|
||||
).fillna(1.85)
|
||||
te_btts['odds_btts_n'] = pd.to_numeric(
|
||||
te_btts['odds_btts_n'], errors='coerce',
|
||||
).fillna(1.85)
|
||||
|
||||
for edge in [0.05, 0.07, 0.10]:
|
||||
X_test = te_btts[clean_feats].values
|
||||
probs = ensemble_predict(btts_models, X_test, clean_feats, 2)
|
||||
y_btts = te_btts['label_btts'].values.astype(int)
|
||||
odds_arr = te_btts[['odds_btts_n', 'odds_btts_y']].values
|
||||
m_arr = 1 / odds_arr
|
||||
impl = m_arr / m_arr.sum(axis=1, keepdims=True)
|
||||
|
||||
total_bets = 0
|
||||
wins = 0
|
||||
pnl = 0.0
|
||||
for i in range(len(y_btts)):
|
||||
for cls in range(2):
|
||||
e = probs[i, cls] - impl[i, cls]
|
||||
o = odds_arr[i, cls]
|
||||
if e < edge or o < 1.50 or o > 3.0:
|
||||
continue
|
||||
total_bets += 1
|
||||
won = (y_btts[i] == cls)
|
||||
if won:
|
||||
wins += 1
|
||||
pnl += 10 * (o - 1)
|
||||
else:
|
||||
pnl -= 10
|
||||
if total_bets > 0:
|
||||
roi = pnl / (total_bets * 10) * 100
|
||||
hit = wins / total_bets * 100
|
||||
print(
|
||||
f' Edge>{edge:.2f}: {total_bets} bets, '
|
||||
f'hit={hit:.1f}%, ROI={roi:+.1f}%'
|
||||
)
|
||||
|
||||
# ── Feature importance ──
|
||||
if "lgb" in ms_models:
|
||||
@@ -452,25 +534,40 @@ def main():
|
||||
|
||||
if ou_models:
|
||||
for name, m in ou_models.items():
|
||||
p = MODELS_DIR / f"v27_ou25_{name}.pkl"
|
||||
with open(p, "wb") as f:
|
||||
p = MODELS_DIR / f'v27_ou25_{name}.pkl'
|
||||
with open(p, 'wb') as f:
|
||||
pickle.dump(m, f)
|
||||
print(f" ✓ {p.name}")
|
||||
print(f' ✓ {p.name}')
|
||||
|
||||
if btts_models:
|
||||
for name, m in btts_models.items():
|
||||
p = MODELS_DIR / f'v27_btts_{name}.pkl'
|
||||
with open(p, 'wb') as f:
|
||||
pickle.dump(m, f)
|
||||
print(f' ✓ {p.name}')
|
||||
|
||||
meta = {
|
||||
"version": "v27-pro", "trained_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"approach": "odds-free fundamentals + value edge detection",
|
||||
"feature_count": len(clean_feats),
|
||||
"total_samples": len(df),
|
||||
"val_acc": round(val_acc, 4), "val_ll": round(val_ll, 4),
|
||||
"best_config": {k: v for k, v in best_cfg.items() if k != "result"} if best_cfg else {},
|
||||
"markets": ["ms"] + (["ou25"] if ou_models else []),
|
||||
'version': 'v27-pro',
|
||||
'trained_at': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'approach': 'odds-free fundamentals + value edge detection',
|
||||
'feature_count': len(clean_feats),
|
||||
'total_samples': len(df),
|
||||
'val_acc': round(val_acc, 4),
|
||||
'val_ll': round(val_ll, 4),
|
||||
'best_config': {
|
||||
k: v for k, v in best_cfg.items() if k != 'result'
|
||||
} if best_cfg else {},
|
||||
'markets': (
|
||||
['ms']
|
||||
+ (['ou25'] if ou_models else [])
|
||||
+ (['btts'] if btts_models else [])
|
||||
),
|
||||
}
|
||||
with open(MODELS_DIR / "v27_metadata.json", "w") as f:
|
||||
with open(MODELS_DIR / 'v27_metadata.json', 'w') as f:
|
||||
json.dump(meta, f, indent=2, default=str)
|
||||
with open(MODELS_DIR / "v27_feature_cols.json", "w") as f:
|
||||
with open(MODELS_DIR / 'v27_feature_cols.json', 'w') as f:
|
||||
json.dump(clean_feats, f, indent=2)
|
||||
print(f" ✓ metadata + feature_cols")
|
||||
print(f' ✓ metadata + feature_cols')
|
||||
|
||||
print(f"\n Total time: {(time.time()-t0)/60:.1f} min")
|
||||
print(" DONE!")
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
Update Implied Odds in football_ai_features
|
||||
=============================================
|
||||
Populates implied_home, implied_draw, implied_away, implied_over25, implied_btts
|
||||
from real odds data in odd_categories + odd_selections tables.
|
||||
|
||||
Also backfills form-based features (home_goals_avg_5, away_goals_avg_5, etc.)
|
||||
from recent match history.
|
||||
|
||||
Usage:
|
||||
python3 scripts/update_implied_odds.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import psycopg2
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def get_conn():
|
||||
db_url = os.getenv("DATABASE_URL", "").split("?schema=")[0]
|
||||
return psycopg2.connect(db_url)
|
||||
|
||||
|
||||
def update_implied_odds(conn):
|
||||
"""Update implied probabilities from real odds data."""
|
||||
cur = conn.cursor()
|
||||
|
||||
print("📊 Phase 1: Updating implied odds from real market data...")
|
||||
t0 = time.time()
|
||||
|
||||
# Step 1: Build odds lookup from odd_categories + odd_selections
|
||||
print(" Loading odds data...")
|
||||
cur.execute("""
|
||||
SELECT oc.match_id, oc.name AS cat_name, os.name AS sel_name, os.odd_value
|
||||
FROM odd_selections os
|
||||
JOIN odd_categories oc ON os.odd_category_db_id = oc.db_id
|
||||
WHERE os.odd_value IS NOT NULL
|
||||
AND CAST(os.odd_value AS FLOAT) > 1.0
|
||||
""")
|
||||
|
||||
odds_by_match = {}
|
||||
row_count = 0
|
||||
for match_id, cat_name, sel_name, odd_val in cur.fetchall():
|
||||
try:
|
||||
v = float(odd_val)
|
||||
if v <= 1.0:
|
||||
continue
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
if match_id not in odds_by_match:
|
||||
odds_by_match[match_id] = {}
|
||||
|
||||
cat_lower = (cat_name or "").lower().strip()
|
||||
sel_lower = (sel_name or "").lower().strip()
|
||||
|
||||
# Match Result (1X2)
|
||||
if cat_lower == 'maç sonucu':
|
||||
if sel_name == '1':
|
||||
odds_by_match[match_id]['ms_h'] = v
|
||||
elif sel_name in ('0', 'X'):
|
||||
odds_by_match[match_id]['ms_d'] = v
|
||||
elif sel_name == '2':
|
||||
odds_by_match[match_id]['ms_a'] = v
|
||||
|
||||
# Over/Under 2.5
|
||||
elif cat_lower == '2,5 alt/üst':
|
||||
if 'üst' in sel_lower:
|
||||
odds_by_match[match_id]['ou25_o'] = v
|
||||
elif 'alt' in sel_lower:
|
||||
odds_by_match[match_id]['ou25_u'] = v
|
||||
|
||||
# BTTS
|
||||
elif cat_lower == 'karşılıklı gol':
|
||||
if 'var' in sel_lower:
|
||||
odds_by_match[match_id]['btts_y'] = v
|
||||
elif 'yok' in sel_lower:
|
||||
odds_by_match[match_id]['btts_n'] = v
|
||||
|
||||
row_count += 1
|
||||
|
||||
print(f" Loaded odds for {len(odds_by_match)} matches ({row_count} selections) in {time.time()-t0:.1f}s")
|
||||
|
||||
# Step 2: Calculate implied probabilities and update
|
||||
print(" Calculating implied probabilities...")
|
||||
|
||||
# Get all match_ids in football_ai_features
|
||||
cur.execute("SELECT match_id FROM football_ai_features")
|
||||
feature_match_ids = {row[0] for row in cur.fetchall()}
|
||||
|
||||
updated = 0
|
||||
batch_size = 500
|
||||
updates = []
|
||||
|
||||
for match_id in feature_match_ids:
|
||||
odds = odds_by_match.get(match_id, {})
|
||||
if not odds:
|
||||
continue
|
||||
|
||||
# Implied MS probabilities (vig-free normalization)
|
||||
ms_h = odds.get('ms_h', 0)
|
||||
ms_d = odds.get('ms_d', 0)
|
||||
ms_a = odds.get('ms_a', 0)
|
||||
|
||||
implied_home = 0.33
|
||||
implied_draw = 0.33
|
||||
implied_away = 0.33
|
||||
|
||||
if ms_h > 1.0 and ms_d > 1.0 and ms_a > 1.0:
|
||||
raw_sum = (1 / ms_h) + (1 / ms_d) + (1 / ms_a)
|
||||
if raw_sum > 0:
|
||||
implied_home = round((1 / ms_h) / raw_sum, 4)
|
||||
implied_draw = round((1 / ms_d) / raw_sum, 4)
|
||||
implied_away = round((1 / ms_a) / raw_sum, 4)
|
||||
|
||||
# Implied OU25
|
||||
ou25_o = odds.get('ou25_o', 0)
|
||||
ou25_u = odds.get('ou25_u', 0)
|
||||
implied_over25 = 0.50
|
||||
|
||||
if ou25_o > 1.0 and ou25_u > 1.0:
|
||||
raw_sum = (1 / ou25_o) + (1 / ou25_u)
|
||||
if raw_sum > 0:
|
||||
implied_over25 = round((1 / ou25_o) / raw_sum, 4)
|
||||
|
||||
# Implied BTTS
|
||||
btts_y = odds.get('btts_y', 0)
|
||||
btts_n = odds.get('btts_n', 0)
|
||||
implied_btts = 0.50
|
||||
|
||||
if btts_y > 1.0 and btts_n > 1.0:
|
||||
raw_sum = (1 / btts_y) + (1 / btts_n)
|
||||
if raw_sum > 0:
|
||||
implied_btts = round((1 / btts_y) / raw_sum, 4)
|
||||
|
||||
# Only update if we have real data (not all defaults)
|
||||
has_real_data = (ms_h > 1.0 or ou25_o > 1.0 or btts_y > 1.0)
|
||||
if not has_real_data:
|
||||
continue
|
||||
|
||||
updates.append((
|
||||
implied_home, implied_draw, implied_away,
|
||||
implied_over25, implied_btts, match_id
|
||||
))
|
||||
|
||||
if len(updates) >= batch_size:
|
||||
cur.executemany("""
|
||||
UPDATE football_ai_features
|
||||
SET implied_home = %s,
|
||||
implied_draw = %s,
|
||||
implied_away = %s,
|
||||
implied_over25 = %s,
|
||||
implied_btts_yes = %s
|
||||
WHERE match_id = %s
|
||||
""", updates)
|
||||
updated += len(updates)
|
||||
updates = []
|
||||
|
||||
# Final batch
|
||||
if updates:
|
||||
cur.executemany("""
|
||||
UPDATE football_ai_features
|
||||
SET implied_home = %s,
|
||||
implied_draw = %s,
|
||||
implied_away = %s,
|
||||
implied_over25 = %s,
|
||||
implied_btts_yes = %s
|
||||
WHERE match_id = %s
|
||||
""", updates)
|
||||
updated += len(updates)
|
||||
|
||||
conn.commit()
|
||||
print(f" ✅ Updated implied odds for {updated} matches in {time.time()-t0:.1f}s")
|
||||
return updated
|
||||
|
||||
|
||||
def update_form_features(conn):
|
||||
"""Backfill form-based features (goals avg, clean sheet rate) from match history."""
|
||||
cur = conn.cursor()
|
||||
|
||||
print("\n📊 Phase 2: Updating form-based features...")
|
||||
t0 = time.time()
|
||||
|
||||
# Load all finished football matches ordered by time
|
||||
print(" Loading match history...")
|
||||
cur.execute("""
|
||||
SELECT id, home_team_id, away_team_id, score_home, score_away, mst_utc
|
||||
FROM matches
|
||||
WHERE status = 'FT'
|
||||
AND score_home IS NOT NULL
|
||||
AND sport = 'football'
|
||||
ORDER BY mst_utc ASC
|
||||
""")
|
||||
|
||||
matches = cur.fetchall()
|
||||
print(f" Loaded {len(matches)} finished matches")
|
||||
|
||||
# Build team history incrementally
|
||||
from collections import defaultdict
|
||||
team_history = defaultdict(list) # team_id -> [(goals_scored, goals_conceded)]
|
||||
|
||||
# Get all feature match IDs
|
||||
cur.execute("SELECT match_id FROM football_ai_features")
|
||||
feature_match_ids = {row[0] for row in cur.fetchall()}
|
||||
|
||||
updated = 0
|
||||
batch_size = 500
|
||||
updates = []
|
||||
|
||||
for match_id, home_id, away_id, score_home, score_away, mst_utc in matches:
|
||||
# Calculate features BEFORE updating history (pre-match features)
|
||||
if match_id in feature_match_ids:
|
||||
h_hist = team_history[home_id][-5:] # last 5
|
||||
a_hist = team_history[away_id][-5:]
|
||||
|
||||
# Home team form
|
||||
if h_hist:
|
||||
h_goals_avg = sum(g for g, _ in h_hist) / len(h_hist)
|
||||
h_conceded_avg = sum(c for _, c in h_hist) / len(h_hist)
|
||||
h_cs_rate = sum(1 for _, c in h_hist if c == 0) / len(h_hist)
|
||||
h_scoring_rate = sum(1 for g, _ in h_hist if g > 0) / len(h_hist)
|
||||
else:
|
||||
h_goals_avg, h_conceded_avg = 1.3, 1.2
|
||||
h_cs_rate, h_scoring_rate = 0.25, 0.75
|
||||
|
||||
# Away team form
|
||||
if a_hist:
|
||||
a_goals_avg = sum(g for g, _ in a_hist) / len(a_hist)
|
||||
a_conceded_avg = sum(c for _, c in a_hist) / len(a_hist)
|
||||
a_cs_rate = sum(1 for _, c in a_hist if c == 0) / len(a_hist)
|
||||
a_scoring_rate = sum(1 for g, _ in a_hist if g > 0) / len(a_hist)
|
||||
else:
|
||||
a_goals_avg, a_conceded_avg = 1.3, 1.2
|
||||
a_cs_rate, a_scoring_rate = 0.25, 0.75
|
||||
|
||||
updates.append((
|
||||
round(h_goals_avg, 3), round(h_conceded_avg, 3),
|
||||
round(h_cs_rate, 3), round(h_scoring_rate, 3),
|
||||
round(a_goals_avg, 3), round(a_conceded_avg, 3),
|
||||
round(a_cs_rate, 3), round(a_scoring_rate, 3),
|
||||
match_id
|
||||
))
|
||||
|
||||
if len(updates) >= batch_size:
|
||||
cur.executemany("""
|
||||
UPDATE football_ai_features
|
||||
SET home_goals_avg_5 = %s,
|
||||
home_conceded_avg_5 = %s,
|
||||
home_clean_sheet_rate = %s,
|
||||
home_scoring_rate = %s,
|
||||
away_goals_avg_5 = %s,
|
||||
away_conceded_avg_5 = %s,
|
||||
away_clean_sheet_rate = %s,
|
||||
away_scoring_rate = %s
|
||||
WHERE match_id = %s
|
||||
""", updates)
|
||||
updated += len(updates)
|
||||
updates = []
|
||||
|
||||
# Update history AFTER feature extraction (maintains pre-match invariant)
|
||||
team_history[home_id].append((score_home, score_away))
|
||||
team_history[away_id].append((score_away, score_home))
|
||||
|
||||
# Final batch
|
||||
if updates:
|
||||
cur.executemany("""
|
||||
UPDATE football_ai_features
|
||||
SET home_goals_avg_5 = %s,
|
||||
home_conceded_avg_5 = %s,
|
||||
home_clean_sheet_rate = %s,
|
||||
home_scoring_rate = %s,
|
||||
away_goals_avg_5 = %s,
|
||||
away_conceded_avg_5 = %s,
|
||||
away_clean_sheet_rate = %s,
|
||||
away_scoring_rate = %s
|
||||
WHERE match_id = %s
|
||||
""", updates)
|
||||
updated += len(updates)
|
||||
|
||||
conn.commit()
|
||||
print(f" ✅ Updated form features for {updated} matches in {time.time()-t0:.1f}s")
|
||||
return updated
|
||||
|
||||
|
||||
def main():
|
||||
print("🚀 Football AI Features — Implied Odds & Form Backfill")
|
||||
print("=" * 60)
|
||||
|
||||
conn = get_conn()
|
||||
|
||||
try:
|
||||
odds_updated = update_implied_odds(conn)
|
||||
form_updated = update_form_features(conn)
|
||||
|
||||
print(f"\n✅ DONE!")
|
||||
print(f" Implied odds updated: {odds_updated} matches")
|
||||
print(f" Form features updated: {form_updated} matches")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,505 @@
|
||||
"""
|
||||
Deterministic betting judge for prediction packages.
|
||||
|
||||
The model layer estimates event probabilities. BettingBrain decides whether
|
||||
those probabilities are trustworthy enough to risk money.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
class BettingBrain:
|
||||
MIN_ODDS = 1.30
|
||||
MIN_BET_SCORE = 72.0
|
||||
MIN_WATCH_SCORE = 62.0
|
||||
MIN_BAND_SAMPLE = 8
|
||||
HARD_DIVERGENCE = 0.22
|
||||
SOFT_DIVERGENCE = 0.14
|
||||
EXTREME_MODEL_PROB = 0.85
|
||||
EXTREME_GAP = 0.30
|
||||
|
||||
MARKET_PRIORS = {
|
||||
"DC": 4.0,
|
||||
"OU15": 3.0,
|
||||
"OU25": 2.0,
|
||||
"BTTS": 0.0,
|
||||
"MS": -2.0,
|
||||
"OU35": -2.0,
|
||||
"HT": -6.0,
|
||||
"HTFT": -12.0,
|
||||
"CARDS": -5.0,
|
||||
"OE": -8.0,
|
||||
}
|
||||
|
||||
def judge(self, package: Dict[str, Any]) -> Dict[str, Any]:
|
||||
v27_engine = package.get("v27_engine")
|
||||
if not isinstance(v27_engine, dict):
|
||||
return package
|
||||
|
||||
guarded = dict(package)
|
||||
rows = self._collect_rows(guarded)
|
||||
if not rows:
|
||||
return guarded
|
||||
|
||||
judged_rows: Dict[str, Dict[str, Any]] = {}
|
||||
decisions: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
key = self._row_key(row)
|
||||
judged = self._judge_row(dict(row), guarded)
|
||||
judged_rows[key] = judged
|
||||
decisions.append(judged["betting_brain"])
|
||||
|
||||
approved = [
|
||||
row for row in judged_rows.values()
|
||||
if row.get("betting_brain", {}).get("action") == "BET"
|
||||
]
|
||||
watchlist = [
|
||||
row for row in judged_rows.values()
|
||||
if row.get("betting_brain", {}).get("action") == "WATCH"
|
||||
]
|
||||
approved.sort(key=self._candidate_sort_key, reverse=True)
|
||||
watchlist.sort(key=self._candidate_sort_key, reverse=True)
|
||||
|
||||
original_main = guarded.get("main_pick") or {}
|
||||
main_pick = None
|
||||
decision = "NO_BET"
|
||||
decision_reason = "No candidate passed the betting brain evidence gates."
|
||||
|
||||
if approved:
|
||||
main_pick = dict(approved[0])
|
||||
main_pick["is_guaranteed"] = bool(main_pick.get("betting_brain", {}).get("score", 0.0) >= 82.0)
|
||||
main_pick["pick_reason"] = "betting_brain_approved"
|
||||
decision = "BET"
|
||||
decision_reason = main_pick.get("betting_brain", {}).get("summary", "Evidence is aligned.")
|
||||
elif watchlist:
|
||||
main_pick = dict(watchlist[0])
|
||||
self._force_no_bet(main_pick, "betting_brain_watchlist")
|
||||
decision = "WATCHLIST"
|
||||
decision_reason = main_pick.get("betting_brain", {}).get("summary", "Interesting but not clean enough.")
|
||||
elif original_main:
|
||||
main_pick = dict(judged_rows.get(self._row_key(original_main), original_main))
|
||||
self._force_no_bet(main_pick, "betting_brain_no_safe_pick")
|
||||
|
||||
main_key = self._row_key(main_pick) if main_pick else ""
|
||||
supporting = [
|
||||
dict(row)
|
||||
for row in judged_rows.values()
|
||||
if self._row_key(row) != main_key
|
||||
]
|
||||
supporting.sort(key=self._candidate_sort_key, reverse=True)
|
||||
|
||||
bet_summary = [
|
||||
self._summary_item(row)
|
||||
for row in sorted(judged_rows.values(), key=self._candidate_sort_key, reverse=True)
|
||||
]
|
||||
|
||||
guarded["main_pick"] = main_pick
|
||||
guarded["value_pick"] = self._pick_value_candidate(judged_rows, main_key)
|
||||
guarded["supporting_picks"] = supporting[:6]
|
||||
guarded["bet_summary"] = bet_summary
|
||||
|
||||
playable = decision == "BET" and bool(main_pick and main_pick.get("playable"))
|
||||
advice = dict(guarded.get("bet_advice") or {})
|
||||
advice["playable"] = playable
|
||||
advice["suggested_stake_units"] = float(main_pick.get("stake_units", 0.0)) if playable else 0.0
|
||||
advice["reason"] = "betting_brain_approved" if playable else "betting_brain_no_bet"
|
||||
advice["decision"] = decision
|
||||
advice["confidence_band"] = self._decision_band(main_pick)
|
||||
guarded["bet_advice"] = advice
|
||||
|
||||
rejected = [d for d in decisions if d.get("action") == "REJECT"]
|
||||
guarded["betting_brain"] = {
|
||||
"version": "judge-v1",
|
||||
"decision": decision,
|
||||
"reason": decision_reason,
|
||||
"main_pick_key": main_key or None,
|
||||
"approved_count": len(approved),
|
||||
"watchlist_count": len(watchlist),
|
||||
"rejected_count": len(rejected),
|
||||
"top_candidates": self._top_decisions(decisions),
|
||||
"rules": {
|
||||
"min_bet_score": self.MIN_BET_SCORE,
|
||||
"min_watch_score": self.MIN_WATCH_SCORE,
|
||||
"min_band_sample": self.MIN_BAND_SAMPLE,
|
||||
"hard_divergence": self.HARD_DIVERGENCE,
|
||||
"soft_divergence": self.SOFT_DIVERGENCE,
|
||||
"extreme_model_probability": self.EXTREME_MODEL_PROB,
|
||||
"extreme_model_market_gap": self.EXTREME_GAP,
|
||||
},
|
||||
}
|
||||
guarded["upper_brain"] = guarded["betting_brain"]
|
||||
guarded.setdefault("analysis_details", {})
|
||||
guarded["analysis_details"]["betting_brain_applied"] = True
|
||||
guarded["analysis_details"]["betting_brain_decision"] = decision
|
||||
return guarded
|
||||
|
||||
def _judge_row(self, row: Dict[str, Any], package: Dict[str, Any]) -> Dict[str, Any]:
|
||||
market = str(row.get("market") or "")
|
||||
pick = str(row.get("pick") or "")
|
||||
model_prob = self._market_probability(row, package)
|
||||
odds = self._safe_float(row.get("odds"), 0.0) or 0.0
|
||||
implied = (1.0 / odds) if odds > 1.0 else 0.0
|
||||
model_gap = (model_prob - implied) if model_prob is not None and implied > 0 else None
|
||||
calibrated_conf = self._safe_float(row.get("calibrated_confidence", row.get("confidence")), 0.0) or 0.0
|
||||
play_score = self._safe_float(row.get("play_score"), 0.0) or 0.0
|
||||
ev_edge = self._safe_float(row.get("ev_edge", row.get("edge")), 0.0) or 0.0
|
||||
v27_prob = self._v27_probability(market, pick, package.get("v27_engine") or {})
|
||||
divergence = abs(model_prob - v27_prob) if model_prob is not None and v27_prob is not None else None
|
||||
triple_key = self._triple_key(market, pick)
|
||||
triple = self._triple_value(package, triple_key)
|
||||
band_sample = int(self._safe_float((triple or {}).get("band_sample"), 0.0) or 0.0)
|
||||
triple_is_value = bool((triple or {}).get("is_value"))
|
||||
consensus = str((package.get("v27_engine") or {}).get("consensus") or "").upper()
|
||||
|
||||
positives: List[str] = []
|
||||
issues: List[str] = []
|
||||
vetoes: List[str] = []
|
||||
score = 0.0
|
||||
|
||||
if row.get("playable"):
|
||||
score += 18.0
|
||||
positives.append("base_model_playable")
|
||||
else:
|
||||
score -= 18.0
|
||||
issues.append("base_model_not_playable")
|
||||
|
||||
is_value_sniper = bool(row.get("is_value_sniper"))
|
||||
if is_value_sniper:
|
||||
score += 35.0
|
||||
positives.append("value_sniper_override")
|
||||
|
||||
score += max(0.0, min(20.0, calibrated_conf * 0.22))
|
||||
score += max(-8.0, min(16.0, ev_edge * 45.0))
|
||||
score += max(0.0, min(14.0, play_score * 0.12))
|
||||
score += self.MARKET_PRIORS.get(market, -3.0)
|
||||
|
||||
data_quality = package.get("data_quality") or {}
|
||||
quality_score = self._safe_float(data_quality.get("score"), 0.6) or 0.6
|
||||
score += max(-8.0, min(6.0, (quality_score - 0.55) * 16.0))
|
||||
risk = str((package.get("risk") or {}).get("level") or "MEDIUM").upper()
|
||||
score += {"LOW": 5.0, "MEDIUM": 0.0, "HIGH": -12.0, "EXTREME": -22.0}.get(risk, -4.0)
|
||||
|
||||
if odds < self.MIN_ODDS:
|
||||
vetoes.append("odds_below_minimum")
|
||||
if calibrated_conf < 38.0 and not is_value_sniper:
|
||||
vetoes.append("calibrated_confidence_too_low")
|
||||
if play_score < 50.0 and not is_value_sniper:
|
||||
vetoes.append("play_score_too_low")
|
||||
|
||||
if divergence is not None:
|
||||
if divergence >= self.HARD_DIVERGENCE and not is_value_sniper:
|
||||
score -= 42.0
|
||||
vetoes.append("v25_v27_hard_disagreement")
|
||||
elif divergence >= self.SOFT_DIVERGENCE:
|
||||
score -= 18.0
|
||||
issues.append("v25_v27_soft_disagreement")
|
||||
else:
|
||||
score += 11.0
|
||||
positives.append("v25_v27_aligned")
|
||||
|
||||
if isinstance(triple, dict):
|
||||
if triple_is_value:
|
||||
score += 18.0
|
||||
positives.append("triple_value_confirmed")
|
||||
elif market in {"DC", "MS", "OU25", "BTTS"}:
|
||||
score -= 18.0
|
||||
issues.append("triple_value_not_confirmed")
|
||||
|
||||
if band_sample >= 25:
|
||||
score += 8.0
|
||||
positives.append("strong_historical_sample")
|
||||
elif band_sample >= self.MIN_BAND_SAMPLE:
|
||||
score += 3.0
|
||||
positives.append("usable_historical_sample")
|
||||
else:
|
||||
score -= 16.0
|
||||
issues.append("historical_sample_too_low")
|
||||
if market == "DC" and not is_value_sniper:
|
||||
vetoes.append("dc_without_historical_sample")
|
||||
elif market in {"MS", "DC", "OU25"}:
|
||||
score -= 10.0
|
||||
issues.append("missing_triple_value_evidence")
|
||||
|
||||
if consensus == "DISAGREE" and market in {"MS", "DC"}:
|
||||
score -= 12.0
|
||||
issues.append("engine_consensus_disagree")
|
||||
|
||||
if (
|
||||
model_prob is not None
|
||||
and model_gap is not None
|
||||
and model_prob >= self.EXTREME_MODEL_PROB
|
||||
and model_gap >= self.EXTREME_GAP
|
||||
and not triple_is_value
|
||||
and not is_value_sniper
|
||||
):
|
||||
score -= 24.0
|
||||
vetoes.append("extreme_probability_without_evidence")
|
||||
|
||||
if market in {"HT", "HTFT", "OE"} and score < 86.0 and not is_value_sniper:
|
||||
vetoes.append("volatile_market_requires_exceptional_evidence")
|
||||
|
||||
score = max(0.0, min(100.0, score))
|
||||
action = "BET"
|
||||
if vetoes:
|
||||
action = "REJECT"
|
||||
elif score < self.MIN_WATCH_SCORE and not is_value_sniper:
|
||||
action = "REJECT"
|
||||
elif score < self.MIN_BET_SCORE and not is_value_sniper:
|
||||
action = "WATCH"
|
||||
|
||||
row["betting_brain"] = {
|
||||
"action": action,
|
||||
"score": round(score, 1),
|
||||
"summary": self._summary(action, market, pick, positives, issues, vetoes),
|
||||
"positives": positives[:5],
|
||||
"issues": issues[:6],
|
||||
"vetoes": vetoes[:6],
|
||||
"model_prob": round(model_prob, 4) if model_prob is not None else None,
|
||||
"implied_prob": round(implied, 4),
|
||||
"model_market_gap": round(model_gap, 4) if model_gap is not None else None,
|
||||
"v27_prob": round(v27_prob, 4) if v27_prob is not None else None,
|
||||
"divergence": round(divergence, 4) if divergence is not None else None,
|
||||
"triple_key": triple_key,
|
||||
"triple_value": triple,
|
||||
}
|
||||
|
||||
if action != "BET":
|
||||
self._force_no_bet(row, f"betting_brain_{action.lower()}")
|
||||
else:
|
||||
row["is_guaranteed"] = bool(score >= 82.0)
|
||||
row["pick_reason"] = "betting_brain_approved"
|
||||
row["stake_units"] = self._brain_stake(row, score)
|
||||
row["bet_grade"] = "A" if score >= 82.0 else "B"
|
||||
row["playable"] = True
|
||||
|
||||
self._append_reason(row, f"betting_brain_{action.lower()}_{round(score)}")
|
||||
return row
|
||||
|
||||
def _collect_rows(self, package: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
rows: Dict[str, Dict[str, Any]] = {}
|
||||
for source in ("main_pick", "value_pick"):
|
||||
item = package.get(source)
|
||||
if isinstance(item, dict) and item.get("market"):
|
||||
# print(f"DEBUG: {source} is_value_sniper: {item.get('is_value_sniper')}")
|
||||
rows[self._row_key(item)] = dict(item)
|
||||
|
||||
for source in ("supporting_picks", "bet_summary"):
|
||||
for item in package.get(source) or []:
|
||||
if isinstance(item, dict) and item.get("market"):
|
||||
key = self._row_key(item)
|
||||
rows[key] = self._merge_row(rows.get(key), item)
|
||||
|
||||
return list(rows.values())
|
||||
|
||||
@staticmethod
|
||||
def _merge_row(existing: Optional[Dict[str, Any]], incoming: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if existing is None:
|
||||
return dict(incoming)
|
||||
merged = dict(incoming)
|
||||
merged.update({k: v for k, v in existing.items() if v is not None})
|
||||
for key in ("decision_reasons", "reasons"):
|
||||
reasons = list(existing.get(key) or []) + list(incoming.get(key) or [])
|
||||
if reasons:
|
||||
merged[key] = list(dict.fromkeys(reasons))
|
||||
return merged
|
||||
|
||||
def _pick_value_candidate(self, rows: Dict[str, Dict[str, Any]], main_key: str) -> Optional[Dict[str, Any]]:
|
||||
candidates = [
|
||||
row for key, row in rows.items()
|
||||
if key != main_key
|
||||
and row.get("betting_brain", {}).get("action") in {"BET", "WATCH"}
|
||||
and (self._safe_float(row.get("odds"), 0.0) or 0.0) >= 1.60
|
||||
]
|
||||
candidates.sort(key=self._candidate_sort_key, reverse=True)
|
||||
return dict(candidates[0]) if candidates else None
|
||||
|
||||
def _summary_item(self, row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
reasons = list(row.get("decision_reasons") or row.get("reasons") or [])
|
||||
return {
|
||||
"market": row.get("market"),
|
||||
"pick": row.get("pick"),
|
||||
"raw_confidence": row.get("raw_confidence", row.get("confidence")),
|
||||
"calibrated_confidence": row.get("calibrated_confidence", row.get("confidence")),
|
||||
"bet_grade": row.get("bet_grade", "PASS"),
|
||||
"playable": bool(row.get("playable")),
|
||||
"stake_units": float(row.get("stake_units", 0.0) or 0.0),
|
||||
"play_score": row.get("play_score", 0.0),
|
||||
"ev_edge": row.get("ev_edge", row.get("edge", 0.0)),
|
||||
"implied_prob": row.get("implied_prob", 0.0),
|
||||
"odds_reliability": row.get("odds_reliability", 0.35),
|
||||
"odds": row.get("odds", 0.0),
|
||||
"reasons": reasons[:6],
|
||||
"betting_brain": row.get("betting_brain"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _candidate_sort_key(row: Dict[str, Any]) -> Tuple[float, float, float]:
|
||||
brain = row.get("betting_brain") or {}
|
||||
action_boost = {"BET": 2.0, "WATCH": 1.0, "REJECT": 0.0}.get(str(brain.get("action")), 0.0)
|
||||
return (
|
||||
action_boost,
|
||||
float(brain.get("score", 0.0) or 0.0),
|
||||
float(row.get("play_score", 0.0) or 0.0),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _row_key(row: Optional[Dict[str, Any]]) -> str:
|
||||
if not isinstance(row, dict):
|
||||
return ""
|
||||
return f"{row.get('market')}:{row.get('pick')}"
|
||||
|
||||
def _force_no_bet(self, row: Dict[str, Any], reason: str) -> None:
|
||||
row["playable"] = False
|
||||
row["stake_units"] = 0.0
|
||||
row["bet_grade"] = "PASS"
|
||||
row["is_guaranteed"] = False
|
||||
row["pick_reason"] = reason
|
||||
if row.get("signal_tier") == "CORE":
|
||||
row["signal_tier"] = "PASS"
|
||||
self._append_reason(row, reason)
|
||||
|
||||
@staticmethod
|
||||
def _append_reason(row: Dict[str, Any], reason: str) -> None:
|
||||
key = "decision_reasons" if "decision_reasons" in row else "reasons"
|
||||
reasons = list(row.get(key) or [])
|
||||
if reason not in reasons:
|
||||
reasons.append(reason)
|
||||
row[key] = reasons[:6]
|
||||
|
||||
def _brain_stake(self, row: Dict[str, Any], score: float) -> float:
|
||||
existing = self._safe_float(row.get("stake_units"), 0.0) or 0.0
|
||||
odds = self._safe_float(row.get("odds"), 0.0) or 0.0
|
||||
if odds <= 1.0:
|
||||
return 0.0
|
||||
cap = 2.0 if score >= 82.0 else 1.2
|
||||
if score < 78.0:
|
||||
cap = 0.8
|
||||
return round(max(0.25, min(existing if existing > 0 else cap, cap)), 1)
|
||||
|
||||
@staticmethod
|
||||
def _decision_band(main_pick: Optional[Dict[str, Any]]) -> str:
|
||||
if not main_pick:
|
||||
return "LOW"
|
||||
score = float((main_pick.get("betting_brain") or {}).get("score", 0.0) or 0.0)
|
||||
if score >= 82.0:
|
||||
return "HIGH"
|
||||
if score >= 72.0:
|
||||
return "MEDIUM"
|
||||
return "LOW"
|
||||
|
||||
@staticmethod
|
||||
def _top_decisions(decisions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
ordered = sorted(decisions, key=lambda d: float(d.get("score", 0.0) or 0.0), reverse=True)
|
||||
return [
|
||||
{
|
||||
"action": item.get("action"),
|
||||
"score": item.get("score"),
|
||||
"summary": item.get("summary"),
|
||||
"vetoes": item.get("vetoes", []),
|
||||
"issues": item.get("issues", []),
|
||||
}
|
||||
for item in ordered[:5]
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _summary(action: str, market: str, pick: str, positives: List[str], issues: List[str], vetoes: List[str]) -> str:
|
||||
if action == "BET":
|
||||
return f"{market} {pick} approved: evidence is aligned enough for a controlled stake."
|
||||
if action == "WATCH":
|
||||
return f"{market} {pick} is interesting but not clean enough for stake."
|
||||
if vetoes:
|
||||
return f"{market} {pick} rejected: {', '.join(vetoes[:3])}."
|
||||
if issues:
|
||||
return f"{market} {pick} rejected: {', '.join(issues[:3])}."
|
||||
return f"{market} {pick} rejected by evidence score."
|
||||
|
||||
def _market_probability(self, row: Dict[str, Any], package: Dict[str, Any]) -> Optional[float]:
|
||||
direct = self._safe_float(row.get("probability"))
|
||||
if direct is not None:
|
||||
return direct
|
||||
board = package.get("market_board") or {}
|
||||
payload = board.get(str(row.get("market") or "")) if isinstance(board, dict) else None
|
||||
probs = payload.get("probs") if isinstance(payload, dict) else None
|
||||
if not isinstance(probs, dict):
|
||||
return None
|
||||
key = self._prob_key(str(row.get("market") or ""), str(row.get("pick") or ""))
|
||||
return self._safe_float(probs.get(key)) if key else None
|
||||
|
||||
def _v27_probability(self, market: str, pick: str, v27_engine: Dict[str, Any]) -> Optional[float]:
|
||||
predictions = v27_engine.get("predictions") or {}
|
||||
ms = predictions.get("ms") or {}
|
||||
ou25 = predictions.get("ou25") or {}
|
||||
if market == "MS":
|
||||
return self._safe_float(ms.get({"1": "home", "X": "draw", "2": "away"}.get(pick, "")))
|
||||
if market == "DC":
|
||||
home = self._safe_float(ms.get("home"), 0.0) or 0.0
|
||||
draw = self._safe_float(ms.get("draw"), 0.0) or 0.0
|
||||
away = self._safe_float(ms.get("away"), 0.0) or 0.0
|
||||
return {"1X": home + draw, "X2": draw + away, "12": home + away}.get(pick)
|
||||
if market == "OU25":
|
||||
key = self._prob_key(market, pick)
|
||||
return self._safe_float(ou25.get(key)) if key else None
|
||||
return None
|
||||
|
||||
def _triple_value(self, package: Dict[str, Any], key: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
if not key:
|
||||
return None
|
||||
value = ((package.get("v27_engine") or {}).get("triple_value") or {}).get(key)
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
def _triple_key(self, market: str, pick: str) -> Optional[str]:
|
||||
prob_key = self._prob_key(market, pick)
|
||||
if market == "MS":
|
||||
return {"1": "home", "2": "away"}.get(pick)
|
||||
if market == "DC" and pick.upper() in {"1X", "X2", "12"}:
|
||||
return f"dc_{pick.lower()}"
|
||||
if market in {"OU15", "OU25", "OU35"} and prob_key == "over":
|
||||
return f"{market.lower()}_over"
|
||||
if market == "BTTS" and prob_key == "yes":
|
||||
return "btts_yes"
|
||||
if market == "HT":
|
||||
return {"1": "ht_home", "2": "ht_away"}.get(pick)
|
||||
if market in {"HT_OU05", "HT_OU15"} and prob_key == "over":
|
||||
return f"{market.lower()}_over"
|
||||
if market == "OE" and prob_key == "odd":
|
||||
return "oe_odd"
|
||||
if market == "CARDS" and prob_key == "over":
|
||||
return "cards_over"
|
||||
if market == "HTFT" and "/" in pick:
|
||||
return f"htft_{pick.replace('/', '').lower()}"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _prob_key(market: str, pick: str) -> Optional[str]:
|
||||
norm = str(pick or "").strip().casefold()
|
||||
if market in {"MS", "HT", "HCAP"}:
|
||||
return pick if pick in {"1", "X", "2"} else None
|
||||
if market == "DC":
|
||||
return pick.upper() if pick.upper() in {"1X", "X2", "12"} else None
|
||||
if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}:
|
||||
if "over" in norm or "ust" in norm or "üst" in norm:
|
||||
return "over"
|
||||
if "under" in norm or "alt" in norm:
|
||||
return "under"
|
||||
if market == "BTTS":
|
||||
if "yes" in norm or "var" in norm:
|
||||
return "yes"
|
||||
if "no" in norm or "yok" in norm:
|
||||
return "no"
|
||||
if market == "OE":
|
||||
if "odd" in norm or "tek" in norm:
|
||||
return "odd"
|
||||
if "even" in norm or "cift" in norm or "çift" in norm:
|
||||
return "even"
|
||||
if market == "HTFT" and "/" in pick:
|
||||
return pick
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _safe_float(value: Any, default: Optional[float] = None) -> Optional[float]:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
@@ -14,11 +14,40 @@ is missing or queries fail.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
|
||||
# ─── Turkish Name Normalization ──────────────────────────────────
|
||||
|
||||
_TR_CHAR_MAP = str.maketrans(
|
||||
'çÇğĞıİöÖşŞüÜâÂîÎûÛ',
|
||||
'cCgGiIoOsSuUaAiIuU',
|
||||
)
|
||||
|
||||
|
||||
def _normalize_name(name: str) -> str:
|
||||
"""
|
||||
Normalize a Turkish referee name for fuzzy matching.
|
||||
|
||||
Strips accents, lowercases, removes extra whitespace, and maps
|
||||
Turkish-specific characters to their ASCII equivalents.
|
||||
"""
|
||||
if not name:
|
||||
return ''
|
||||
# 1. Turkish-specific character mapping
|
||||
normalized = name.translate(_TR_CHAR_MAP)
|
||||
# 2. Unicode NFKD decomposition → strip combining marks
|
||||
normalized = unicodedata.normalize('NFKD', normalized)
|
||||
normalized = ''.join(
|
||||
c for c in normalized if not unicodedata.combining(c)
|
||||
)
|
||||
# 3. Lowercase + collapse whitespace
|
||||
return ' '.join(normalized.lower().split())
|
||||
|
||||
|
||||
class FeatureEnrichmentService:
|
||||
"""Stateless service — all state comes from DB via cursor."""
|
||||
|
||||
@@ -380,34 +409,20 @@ class FeatureEnrichmentService:
|
||||
"""
|
||||
Referee tendencies: home win bias, avg goals, card rates.
|
||||
Matches referee by name in match_officials (role_id=1 = Orta Hakem).
|
||||
|
||||
Uses Turkish-aware fuzzy matching as a fallback when exact name
|
||||
lookup returns zero results.
|
||||
"""
|
||||
if not referee_name:
|
||||
return dict(self._DEFAULT_REFEREE)
|
||||
try:
|
||||
# Get match IDs officiated by this referee
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
m.home_team_id,
|
||||
m.score_home,
|
||||
m.score_away,
|
||||
m.id AS match_id
|
||||
FROM match_officials mo
|
||||
JOIN matches m ON m.id = mo.match_id
|
||||
WHERE mo.name = %s
|
||||
AND mo.role_id = 1
|
||||
AND m.status = 'FT'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.score_away IS NOT NULL
|
||||
AND m.mst_utc < %s
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(referee_name, before_date_ms, limit),
|
||||
|
||||
rows = self._query_referee_matches(cur, referee_name, before_date_ms, limit)
|
||||
|
||||
# Fuzzy fallback: if exact match fails, try normalized name search
|
||||
if not rows:
|
||||
rows = self._fuzzy_referee_lookup(
|
||||
cur, referee_name, before_date_ms, limit,
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
except Exception:
|
||||
return dict(self._DEFAULT_REFEREE)
|
||||
|
||||
if not rows:
|
||||
return dict(self._DEFAULT_REFEREE)
|
||||
@@ -459,6 +474,118 @@ class FeatureEnrichmentService:
|
||||
'experience': total,
|
||||
}
|
||||
|
||||
def _query_referee_matches(
|
||||
self,
|
||||
cur: RealDictCursor,
|
||||
referee_name: str,
|
||||
before_date_ms: int,
|
||||
limit: int,
|
||||
) -> list:
|
||||
"""Exact-match referee lookup in match_officials."""
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
m.home_team_id,
|
||||
m.score_home,
|
||||
m.score_away,
|
||||
m.id AS match_id
|
||||
FROM match_officials mo
|
||||
JOIN matches m ON m.id = mo.match_id
|
||||
WHERE mo.name = %s
|
||||
AND mo.role_id = 1
|
||||
AND m.status = 'FT'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.score_away IS NOT NULL
|
||||
AND m.mst_utc < %s
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(referee_name, before_date_ms, limit),
|
||||
)
|
||||
return cur.fetchall()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _fuzzy_referee_lookup(
|
||||
self,
|
||||
cur: RealDictCursor,
|
||||
referee_name: str,
|
||||
before_date_ms: int,
|
||||
limit: int,
|
||||
) -> list:
|
||||
"""
|
||||
Fuzzy referee lookup using Turkish name normalization.
|
||||
|
||||
Strategy: fetch recent distinct referee names from match_officials,
|
||||
normalize both the query name and each candidate, and pick the
|
||||
best match. This handles common mismatches like:
|
||||
- 'Hüseyin Göçek' vs 'Huseyin Gocek'
|
||||
- 'Ali Palabıyık' vs 'Ali Palabiyik'
|
||||
- Extra/missing middle initials
|
||||
"""
|
||||
normalized_query = _normalize_name(referee_name)
|
||||
if not normalized_query:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Fetch candidate referee names (distinct, recent, role=1)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT mo.name
|
||||
FROM match_officials mo
|
||||
JOIN matches m ON m.id = mo.match_id
|
||||
WHERE mo.role_id = 1
|
||||
AND m.status = 'FT'
|
||||
AND m.mst_utc < %s
|
||||
ORDER BY mo.name
|
||||
LIMIT 2000
|
||||
""",
|
||||
(before_date_ms,),
|
||||
)
|
||||
candidates = cur.fetchall()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
# Find best match by normalized name comparison
|
||||
best_match: Optional[str] = None
|
||||
best_score = 0.0
|
||||
|
||||
for cand_row in candidates:
|
||||
cand_name = cand_row.get('name', '')
|
||||
if not cand_name:
|
||||
continue
|
||||
normalized_cand = _normalize_name(cand_name)
|
||||
|
||||
# Exact normalized match
|
||||
if normalized_cand == normalized_query:
|
||||
best_match = cand_name
|
||||
best_score = 1.0
|
||||
break
|
||||
|
||||
# Substring containment (handles "First Last" vs "First M. Last")
|
||||
if (
|
||||
normalized_query in normalized_cand
|
||||
or normalized_cand in normalized_query
|
||||
):
|
||||
containment_score = min(
|
||||
len(normalized_query), len(normalized_cand)
|
||||
) / max(len(normalized_query), len(normalized_cand))
|
||||
if containment_score > best_score and containment_score > 0.6:
|
||||
best_match = cand_name
|
||||
best_score = containment_score
|
||||
|
||||
if not best_match:
|
||||
return []
|
||||
|
||||
# Re-query with the resolved name
|
||||
return self._query_referee_matches(
|
||||
cur, best_match, before_date_ms, limit,
|
||||
)
|
||||
|
||||
# ─── 5. League Averages ─────────────────────────────────────────
|
||||
|
||||
def compute_league_averages(
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
"""
|
||||
Match Commentary Generator
|
||||
===========================
|
||||
Generates human-readable Turkish commentary from the analysis package.
|
||||
Reads all engine signals (model, odds band, betting brain, triple value)
|
||||
and produces a clear, actionable summary for end users.
|
||||
|
||||
No LLM required — fully template-based.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def generate_match_commentary(package: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Main entry point. Takes a full analysis package and returns a commentary dict.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"action": "BET" | "WATCH" | "SKIP",
|
||||
"headline": "...",
|
||||
"summary": "...",
|
||||
"notes": ["...", "..."],
|
||||
"contradictions": ["...", "..."],
|
||||
"confidence_label": "YÜKSEK" | "ORTA" | "DÜŞÜK" | "ÇOK DÜŞÜK"
|
||||
}
|
||||
"""
|
||||
match_info = package.get("match_info") or {}
|
||||
home = match_info.get("home_team", "Ev Sahibi")
|
||||
away = match_info.get("away_team", "Deplasman")
|
||||
main_pick = package.get("main_pick") or {}
|
||||
betting_brain = package.get("betting_brain") or {}
|
||||
v27_engine = package.get("v27_engine") or {}
|
||||
market_board = package.get("market_board") or {}
|
||||
score_pred = package.get("score_prediction") or {}
|
||||
risk = package.get("risk") or {}
|
||||
data_quality = package.get("data_quality") or {}
|
||||
|
||||
# ── Determine action ──────────────────────────────────────────
|
||||
brain_decision = str(betting_brain.get("decision") or "NO_BET").upper()
|
||||
main_playable = bool(main_pick.get("playable"))
|
||||
main_vetoed = bool((main_pick.get("upper_brain") or {}).get("veto"))
|
||||
approved_count = int(betting_brain.get("approved_count", 0) or 0)
|
||||
|
||||
if main_playable and not main_vetoed and approved_count > 0:
|
||||
action = "BET"
|
||||
elif approved_count == 0 and brain_decision == "NO_BET":
|
||||
action = "SKIP"
|
||||
else:
|
||||
action = "WATCH"
|
||||
|
||||
# ── Headline ──────────────────────────────────────────────────
|
||||
headline = _build_headline(action, main_pick, home, away)
|
||||
|
||||
# ── Summary paragraph ─────────────────────────────────────────
|
||||
summary = _build_summary(
|
||||
action, main_pick, market_board, v27_engine,
|
||||
score_pred, risk, data_quality, home, away,
|
||||
)
|
||||
|
||||
# ── Quick notes ───────────────────────────────────────────────
|
||||
notes = _build_notes(market_board, v27_engine, score_pred, risk, home, away)
|
||||
|
||||
# ── Contradiction detection ───────────────────────────────────
|
||||
contradictions = _detect_contradictions(market_board, v27_engine, package)
|
||||
|
||||
# ── Overall confidence label ──────────────────────────────────
|
||||
confidence_label = _overall_confidence_label(main_pick, data_quality)
|
||||
|
||||
return {
|
||||
"action": action,
|
||||
"headline": headline,
|
||||
"summary": summary,
|
||||
"notes": notes[:6],
|
||||
"contradictions": contradictions[:4],
|
||||
"confidence_label": confidence_label,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Headline
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _build_headline(
|
||||
action: str,
|
||||
main_pick: Dict[str, Any],
|
||||
home: str,
|
||||
away: str,
|
||||
) -> str:
|
||||
if action == "BET":
|
||||
market = main_pick.get("market", "")
|
||||
pick = main_pick.get("pick", "")
|
||||
odds = main_pick.get("odds", 0.0)
|
||||
conf = main_pick.get("calibrated_confidence", main_pick.get("confidence", 0))
|
||||
market_tr = _market_to_turkish(market, pick)
|
||||
return f"🎯 {market_tr} önerisi — Oran: {odds}, Güven: %{conf:.0f}"
|
||||
|
||||
if action == "WATCH":
|
||||
return f"👀 {home} vs {away} — İzlemeye değer sinyaller var"
|
||||
|
||||
return f"⚠️ {home} vs {away} — Şu an net bir fırsat görülmüyor"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Summary
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _build_summary(
|
||||
action: str,
|
||||
main_pick: Dict[str, Any],
|
||||
market_board: Dict[str, Any],
|
||||
v27_engine: Dict[str, Any],
|
||||
score_pred: Dict[str, Any],
|
||||
risk: Dict[str, Any],
|
||||
data_quality: Dict[str, Any],
|
||||
home: str,
|
||||
away: str,
|
||||
) -> str:
|
||||
parts: List[str] = []
|
||||
|
||||
# Who is the favourite?
|
||||
ms_board = market_board.get("MS") or {}
|
||||
ms_pick = ms_board.get("pick", "")
|
||||
ms_conf = float(ms_board.get("confidence", 50) or 50)
|
||||
|
||||
if ms_pick == "1" and ms_conf > 45:
|
||||
parts.append(f"{home} hafif favori görünüyor")
|
||||
elif ms_pick == "1" and ms_conf > 55:
|
||||
parts.append(f"{home} net favori")
|
||||
elif ms_pick == "2" and ms_conf > 45:
|
||||
parts.append(f"{away} hafif favori görünüyor")
|
||||
elif ms_pick == "2" and ms_conf > 55:
|
||||
parts.append(f"{away} net favori")
|
||||
else:
|
||||
parts.append("İki takım da birbirine yakın güçte")
|
||||
|
||||
# xG expectation
|
||||
xg_home = float(score_pred.get("xg_home", 0) or 0)
|
||||
xg_away = float(score_pred.get("xg_away", 0) or 0)
|
||||
xg_total = xg_home + xg_away
|
||||
if xg_total > 3.0:
|
||||
parts.append(f"Gol beklentisi yüksek (toplam xG: {xg_total:.1f})")
|
||||
elif xg_total < 2.0:
|
||||
parts.append(f"Düşük gol beklentisi (toplam xG: {xg_total:.1f})")
|
||||
|
||||
# Consensus check
|
||||
consensus = str(v27_engine.get("consensus") or "").upper()
|
||||
if consensus == "AGREE":
|
||||
parts.append("Model motorları aynı fikirde")
|
||||
elif consensus == "DISAGREE":
|
||||
parts.append("Model motorları farklı sonuçlara ulaşıyor — belirsizlik var")
|
||||
|
||||
# Action-specific
|
||||
if action == "BET":
|
||||
market_tr = _market_to_turkish(
|
||||
main_pick.get("market", ""), main_pick.get("pick", "")
|
||||
)
|
||||
edge = float(main_pick.get("ev_edge", 0) or 0)
|
||||
parts.append(
|
||||
f"{market_tr} yönünde değer tespit edildi (EV edge: {edge:+.1%})"
|
||||
)
|
||||
elif action == "SKIP":
|
||||
parts.append(
|
||||
"Hiçbir markette piyasanın fiyatlamadığı bir avantaj görülmüyor"
|
||||
)
|
||||
|
||||
# Risk
|
||||
risk_level = str(risk.get("level") or "MEDIUM").upper()
|
||||
if risk_level == "HIGH":
|
||||
parts.append("⚠️ Risk seviyesi yüksek")
|
||||
elif risk_level == "EXTREME":
|
||||
parts.append("🔴 Çok yüksek risk — dikkatli olun")
|
||||
|
||||
# Data quality
|
||||
quality_label = str(data_quality.get("label") or "MEDIUM").upper()
|
||||
if quality_label == "LOW":
|
||||
parts.append("Veri kalitesi düşük — tahminler daha az güvenilir")
|
||||
|
||||
return ". ".join(parts) + "."
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Quick Notes
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _build_notes(
|
||||
market_board: Dict[str, Any],
|
||||
v27_engine: Dict[str, Any],
|
||||
score_pred: Dict[str, Any],
|
||||
risk: Dict[str, Any],
|
||||
home: str,
|
||||
away: str,
|
||||
) -> List[str]:
|
||||
notes: List[str] = []
|
||||
triple_value = v27_engine.get("triple_value") or {}
|
||||
odds_band = v27_engine.get("odds_band") or {}
|
||||
|
||||
# MS note
|
||||
ms = market_board.get("MS") or {}
|
||||
ms_conf = float(ms.get("confidence", 0) or 0)
|
||||
if ms_conf < 45:
|
||||
notes.append("Maç sonucu belirsiz, net favori yok")
|
||||
elif ms.get("pick") == "1":
|
||||
notes.append(f"{home} favori ama oran değerli mi kontrol et")
|
||||
elif ms.get("pick") == "2":
|
||||
notes.append(f"{away} favori ama oran değerli mi kontrol et")
|
||||
|
||||
# OU25 note
|
||||
ou25 = market_board.get("OU25") or {}
|
||||
ou25_probs = ou25.get("probs") or {}
|
||||
over_prob = float(ou25_probs.get("over", 0.5) or 0.5)
|
||||
if over_prob > 0.58:
|
||||
notes.append("2.5 Üst yönünde eğilim var")
|
||||
elif over_prob < 0.42:
|
||||
notes.append("2.5 Alt yönünde eğilim var")
|
||||
else:
|
||||
notes.append("2.5 Üst/Alt dengeli — kesin sinyal yok")
|
||||
|
||||
# BTTS note
|
||||
btts = market_board.get("BTTS") or {}
|
||||
btts_probs = btts.get("probs") or {}
|
||||
btts_yes = float(btts_probs.get("yes", 0.5) or 0.5)
|
||||
if btts_yes > 0.58:
|
||||
notes.append("Her iki takımın da gol atması bekleniyor")
|
||||
elif btts_yes < 0.42:
|
||||
notes.append("KG olasılığı düşük")
|
||||
|
||||
# HT note
|
||||
ht = market_board.get("HT") or {}
|
||||
ht_pick = ht.get("pick", "")
|
||||
ht_conf = float(ht.get("confidence", 0) or 0)
|
||||
if ht_conf > 40 and ht_pick:
|
||||
ht_label = {"1": f"İY {home}", "2": f"İY {away}", "X": "İY beraberlik"}.get(
|
||||
ht_pick, f"İY {ht_pick}"
|
||||
)
|
||||
notes.append(f"{ht_label} yönünde hafif sinyal (%{ht_conf:.0f})")
|
||||
|
||||
# Risk warnings
|
||||
warnings = risk.get("warnings") or []
|
||||
for w in warnings[:2]:
|
||||
notes.append(f"⚠️ {w}")
|
||||
|
||||
return notes
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Contradiction Detection
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _detect_contradictions(
|
||||
market_board: Dict[str, Any],
|
||||
v27_engine: Dict[str, Any],
|
||||
package: Dict[str, Any],
|
||||
) -> List[str]:
|
||||
"""
|
||||
Detect cases where model prediction and odds band/triple value
|
||||
point in opposite directions — the user's main complaint.
|
||||
"""
|
||||
contradictions: List[str] = []
|
||||
triple_value = v27_engine.get("triple_value") or {}
|
||||
predictions = v27_engine.get("predictions") or {}
|
||||
|
||||
# MS contradiction: model says home but triple_value says away has value
|
||||
ms_preds = predictions.get("ms") or {}
|
||||
ms_home = float(ms_preds.get("home", 0) or 0)
|
||||
ms_away = float(ms_preds.get("away", 0) or 0)
|
||||
home_triple = triple_value.get("home") or {}
|
||||
away_triple = triple_value.get("away") or {}
|
||||
|
||||
model_favours_home = ms_home > ms_away
|
||||
away_is_value = bool(away_triple.get("is_value"))
|
||||
home_is_value = bool(home_triple.get("is_value"))
|
||||
|
||||
if model_favours_home and away_is_value:
|
||||
contradictions.append(
|
||||
"Model ev sahibini favori görüyor ama oran bandı deplasmanda değer buluyor — "
|
||||
"bu çelişki nedeniyle MS tahminine dikkatli yaklaş"
|
||||
)
|
||||
elif not model_favours_home and home_is_value:
|
||||
contradictions.append(
|
||||
"Model deplasmanı favori görüyor ama oran bandı ev sahibinde değer buluyor — "
|
||||
"bu çelişki nedeniyle MS tahminine dikkatli yaklaş"
|
||||
)
|
||||
|
||||
# HT contradiction
|
||||
ht_board = market_board.get("HT") or {}
|
||||
ht_pick = ht_board.get("pick", "")
|
||||
ht_home_triple = triple_value.get("ht_home") or {}
|
||||
ht_away_triple = triple_value.get("ht_away") or {}
|
||||
|
||||
if ht_pick == "1" and bool(ht_away_triple.get("is_value")):
|
||||
contradictions.append(
|
||||
"Model İY ev sahibi diyor ama oran bandı İY deplasmanda değer buluyor — "
|
||||
"İY tahmini güvenilir değil"
|
||||
)
|
||||
elif ht_pick == "2" and bool(ht_home_triple.get("is_value")):
|
||||
contradictions.append(
|
||||
"Model İY deplasman diyor ama oran bandı İY ev sahibinde değer buluyor — "
|
||||
"İY tahmini güvenilir değil"
|
||||
)
|
||||
|
||||
# OU25 contradiction
|
||||
ou25_board = market_board.get("OU25") or {}
|
||||
ou25_pick = ou25_board.get("pick", "")
|
||||
ou25_over_triple = triple_value.get("ou25_over") or {}
|
||||
ou25_under_triple = triple_value.get("ou25_under") or {}
|
||||
|
||||
if ou25_pick == "Üst" and bool(ou25_under_triple.get("is_value")):
|
||||
contradictions.append(
|
||||
"Model 2.5 Üst diyor ama oran bandı 2.5 Alt'ta değer buluyor — çelişki var"
|
||||
)
|
||||
elif ou25_pick == "Alt" and bool(ou25_over_triple.get("is_value")):
|
||||
contradictions.append(
|
||||
"Model 2.5 Alt diyor ama oran bandı 2.5 Üst'te değer buluyor — çelişki var"
|
||||
)
|
||||
|
||||
return contradictions
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Helpers
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _overall_confidence_label(
|
||||
main_pick: Dict[str, Any],
|
||||
data_quality: Dict[str, Any],
|
||||
) -> str:
|
||||
"""Overall confidence label for the entire analysis."""
|
||||
quality_score = float(data_quality.get("score", 0.5) or 0.5)
|
||||
main_conf = float(
|
||||
main_pick.get("calibrated_confidence", main_pick.get("confidence", 0)) or 0
|
||||
)
|
||||
main_playable = bool(main_pick.get("playable"))
|
||||
|
||||
if main_playable and main_conf >= 60 and quality_score >= 0.8:
|
||||
return "YÜKSEK"
|
||||
if main_playable and main_conf >= 45:
|
||||
return "ORTA"
|
||||
if main_conf >= 30:
|
||||
return "DÜŞÜK"
|
||||
return "ÇOK DÜŞÜK"
|
||||
|
||||
|
||||
_MARKET_TR_MAP = {
|
||||
"MS": {"1": "Maç Sonucu Ev Sahibi", "2": "Maç Sonucu Deplasman", "X": "Beraberlik"},
|
||||
"DC": {"1X": "Çifte Şans 1X", "X2": "Çifte Şans X2", "12": "Çifte Şans 12"},
|
||||
"OU25": {"Üst": "2.5 Üst", "Alt": "2.5 Alt", "Over": "2.5 Üst", "Under": "2.5 Alt"},
|
||||
"OU15": {"Üst": "1.5 Üst", "Alt": "1.5 Alt", "Over": "1.5 Üst", "Under": "1.5 Alt"},
|
||||
"OU35": {"Üst": "3.5 Üst", "Alt": "3.5 Alt", "Over": "3.5 Üst", "Under": "3.5 Alt"},
|
||||
"BTTS": {"KG Var": "Karşılıklı Gol Var", "KG Yok": "Karşılıklı Gol Yok",
|
||||
"Yes": "Karşılıklı Gol Var", "No": "Karşılıklı Gol Yok"},
|
||||
"HT": {"1": "İlk Yarı Ev Sahibi", "2": "İlk Yarı Deplasman", "X": "İlk Yarı Beraberlik"},
|
||||
"HT_OU05": {"Üst": "İY 0.5 Üst", "Alt": "İY 0.5 Alt"},
|
||||
"HT_OU15": {"Üst": "İY 1.5 Üst", "Alt": "İY 1.5 Alt"},
|
||||
"OE": {"Tek": "Tek", "Çift": "Çift", "Odd": "Tek", "Even": "Çift"},
|
||||
"CARDS": {"Üst": "Kart Üst", "Alt": "Kart Alt"},
|
||||
}
|
||||
|
||||
|
||||
def _market_to_turkish(market: str, pick: str) -> str:
|
||||
market_map = _MARKET_TR_MAP.get(market, {})
|
||||
result = market_map.get(pick)
|
||||
if result:
|
||||
return result
|
||||
return f"{market} {pick}"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
import os, psycopg2
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv('/Users/piton/Documents/Suggest-Bet-BE/.env')
|
||||
conn = psycopg2.connect(os.getenv('DATABASE_URL').split('?')[0])
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT mpe.match_id, SUM(CASE WHEN event_type::text LIKE \'%yellow_card%\' THEN 1 WHEN event_type::text LIKE \'%red_card%\' THEN 2 ELSE 1 END) as cards FROM match_player_events mpe WHERE event_type::text LIKE \'%card%\' GROUP BY mpe.match_id LIMIT 5')
|
||||
print(cur.fetchall())
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Quick test: V20+Quant integration — EV Edge, Kelly staking, edge-based grading."""
|
||||
import json
|
||||
from services.single_match_orchestrator import SingleMatchOrchestrator
|
||||
|
||||
MATCH_IDS = [
|
||||
"er7n8hqndkhvdsg6an72r7h90", # Def. Justicia vs Atl Lanus
|
||||
"etpay8k4qr3gts3jjidfebaxg", # CA Tigre vs Gymnasia
|
||||
]
|
||||
|
||||
o = SingleMatchOrchestrator()
|
||||
|
||||
for mid in MATCH_IDS:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"MATCH: {mid}")
|
||||
print(f"{'='*60}")
|
||||
r = o.analyze_match(mid)
|
||||
if not r:
|
||||
print(" Match not found")
|
||||
continue
|
||||
|
||||
info = r.get("match_info", {})
|
||||
print(f" {info.get('match_name', '?')} | {info.get('league', '?')}")
|
||||
|
||||
mp = r.get("main_pick", {})
|
||||
print(f"\n MAIN PICK: {mp.get('market')} {mp.get('pick')}")
|
||||
print(f" probability: {mp.get('probability', 0):.4f}")
|
||||
print(f" odds: {mp.get('odds', 0):.2f}")
|
||||
print(f" ev_edge: {mp.get('ev_edge', mp.get('edge', 0)):+.4f}")
|
||||
print(f" implied_prob: {mp.get('implied_prob', 0):.4f}")
|
||||
print(f" bet_grade: {mp.get('bet_grade', 'N/A')}")
|
||||
print(f" stake_units: {mp.get('stake_units', 0)}")
|
||||
print(f" playable: {mp.get('playable', False)}")
|
||||
print(f" reasons: {mp.get('decision_reasons', [])}")
|
||||
|
||||
print(f"\n ALL MARKETS (with EV Edge + Kelly):")
|
||||
for b in r.get("bet_summary", []):
|
||||
ev = b.get("ev_edge", 0)
|
||||
imp = b.get("implied_prob", 0)
|
||||
flag = ">>>" if b.get("playable") else " "
|
||||
mkt = b["market"]
|
||||
pick = b["pick"]
|
||||
odds = b.get("odds", 0)
|
||||
grade = b["bet_grade"]
|
||||
stake = b["stake_units"]
|
||||
conf = b.get("calibrated_confidence", 0)
|
||||
print(
|
||||
f" {flag} {mkt:8s} {pick:12s} "
|
||||
f"ev_edge={ev:+.3f} "
|
||||
f"odds={odds:.2f} "
|
||||
f"stake={stake:.1f} "
|
||||
f"grade={grade:4s} "
|
||||
f"conf={conf:.1f}% "
|
||||
f"implied={imp:.3f}"
|
||||
)
|
||||
|
||||
print()
|
||||
@@ -1,75 +0,0 @@
|
||||
import sys
|
||||
import unittest
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
AI_ENGINE_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(AI_ENGINE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(AI_ENGINE_ROOT))
|
||||
|
||||
from core.engines.odds_predictor import OddsPredictorEngine
|
||||
from features.sidelined_analyzer import SidelinedAnalyzer
|
||||
|
||||
|
||||
class EngineNullSafetyTests(unittest.TestCase):
|
||||
def test_odds_predictor_accepts_decimal_inputs_without_crashing(self):
|
||||
engine = OddsPredictorEngine()
|
||||
|
||||
prediction = engine.predict(
|
||||
odds_data={
|
||||
"ms_h": Decimal("2.10"),
|
||||
"ms_d": Decimal("3.25"),
|
||||
"ms_a": Decimal("3.60"),
|
||||
"ou25_o": Decimal("1.90"),
|
||||
},
|
||||
)
|
||||
|
||||
self.assertGreater(prediction.market_home_prob, 0.0)
|
||||
self.assertGreater(prediction.market_draw_prob, 0.0)
|
||||
self.assertGreater(prediction.market_away_prob, 0.0)
|
||||
|
||||
def test_sidelined_analyzer_handles_non_numeric_fields(self):
|
||||
analyzer = SidelinedAnalyzer.__new__(SidelinedAnalyzer)
|
||||
analyzer.position_weights = {"K": 0.35, "D": 0.20, "O": 0.25, "F": 0.30}
|
||||
analyzer.max_rating = 10
|
||||
analyzer.adaptation_threshold = 10
|
||||
analyzer.adaptation_discount = 0.5
|
||||
analyzer.goalkeeper_penalty = 0.15
|
||||
analyzer.confidence_boost = 10
|
||||
analyzer.max_impact = 0.85
|
||||
analyzer.key_player_threshold = 3
|
||||
analyzer.recent_matches_lookback = 15
|
||||
analyzer._fetch_player_stats = MagicMock(return_value={})
|
||||
|
||||
result = analyzer.analyze(
|
||||
{
|
||||
"totalSidelined": 2,
|
||||
"players": [
|
||||
{
|
||||
"playerId": "p1",
|
||||
"playerName": "Player One",
|
||||
"positionShort": "O",
|
||||
"matchesMissed": "N/A",
|
||||
"average": "?",
|
||||
"type": "injury",
|
||||
},
|
||||
{
|
||||
"playerId": "p2",
|
||||
"playerName": "Player Two",
|
||||
"positionShort": "K",
|
||||
"matchesMissed": "12",
|
||||
"average": "6.7",
|
||||
"type": "suspension",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(result.total_sidelined, 2)
|
||||
self.assertGreaterEqual(result.impact_score, 0.0)
|
||||
self.assertTrue(len(result.player_details) >= 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,282 +0,0 @@
|
||||
"""
|
||||
Unit tests for FeatureEnrichmentService
|
||||
========================================
|
||||
Tests all 6 enrichment methods with mocked DB cursor:
|
||||
1. compute_team_stats
|
||||
2. compute_h2h
|
||||
3. compute_form_streaks
|
||||
4. compute_referee_stats
|
||||
5. compute_league_averages
|
||||
6. compute_momentum
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
AI_ENGINE_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(AI_ENGINE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(AI_ENGINE_ROOT))
|
||||
|
||||
from services.feature_enrichment import FeatureEnrichmentService, _safe_avg
|
||||
|
||||
|
||||
def _make_cursor(rows=None, side_effect=None):
|
||||
"""Create a mock RealDictCursor."""
|
||||
cur = MagicMock()
|
||||
if side_effect:
|
||||
cur.execute.side_effect = side_effect
|
||||
else:
|
||||
cur.fetchall.return_value = rows or []
|
||||
cur.fetchone.return_value = rows[0] if rows else None
|
||||
return cur
|
||||
|
||||
|
||||
class TestSafeAvg(unittest.TestCase):
|
||||
def test_returns_average(self):
|
||||
self.assertAlmostEqual(_safe_avg([2.0, 4.0, 6.0], 0.0), 4.0)
|
||||
|
||||
def test_returns_default_on_empty(self):
|
||||
self.assertEqual(_safe_avg([], 99.0), 99.0)
|
||||
|
||||
def test_single_value(self):
|
||||
self.assertAlmostEqual(_safe_avg([7.5], 0.0), 7.5)
|
||||
|
||||
|
||||
class TestComputeTeamStats(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.svc = FeatureEnrichmentService()
|
||||
self.ts = 1700000000000
|
||||
|
||||
def test_returns_defaults_when_no_team_id(self):
|
||||
result = self.svc.compute_team_stats(MagicMock(), '', self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_TEAM_STATS)
|
||||
|
||||
def test_returns_defaults_when_no_rows(self):
|
||||
cur = _make_cursor(rows=[])
|
||||
result = self.svc.compute_team_stats(cur, 'team1', self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_TEAM_STATS)
|
||||
|
||||
def test_returns_defaults_on_db_error(self):
|
||||
cur = _make_cursor(side_effect=Exception('DB down'))
|
||||
result = self.svc.compute_team_stats(cur, 'team1', self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_TEAM_STATS)
|
||||
|
||||
def test_calculates_averages_correctly(self):
|
||||
rows = [
|
||||
{'possession_percentage': 60.0, 'shots_on_target': 5, 'total_shots': 10, 'corners': 7},
|
||||
{'possession_percentage': 40.0, 'shots_on_target': 3, 'total_shots': 12, 'corners': 3},
|
||||
]
|
||||
cur = _make_cursor(rows)
|
||||
result = self.svc.compute_team_stats(cur, 'team1', self.ts)
|
||||
|
||||
self.assertAlmostEqual(result['avg_possession'], 50.0)
|
||||
self.assertAlmostEqual(result['avg_shots_on_target'], 4.0)
|
||||
self.assertAlmostEqual(result['shot_conversion'], (5 / 10 + 3 / 12) / 2, places=4)
|
||||
self.assertAlmostEqual(result['avg_corners'], 5.0)
|
||||
|
||||
def test_handles_none_subfields_gracefully(self):
|
||||
"""Rows with None values should be skipped, not crash."""
|
||||
rows = [
|
||||
{'possession_percentage': 55.0, 'shots_on_target': None, 'total_shots': None, 'corners': 4},
|
||||
{'possession_percentage': None, 'shots_on_target': 2, 'total_shots': 8, 'corners': None},
|
||||
]
|
||||
cur = _make_cursor(rows)
|
||||
result = self.svc.compute_team_stats(cur, 'team1', self.ts)
|
||||
|
||||
self.assertAlmostEqual(result['avg_possession'], 55.0)
|
||||
self.assertAlmostEqual(result['avg_shots_on_target'], 2.0)
|
||||
self.assertAlmostEqual(result['avg_corners'], 4.0)
|
||||
|
||||
|
||||
class TestComputeH2H(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.svc = FeatureEnrichmentService()
|
||||
self.ts = 1700000000000
|
||||
|
||||
def test_returns_defaults_when_no_ids(self):
|
||||
result = self.svc.compute_h2h(MagicMock(), '', 'away1', self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_H2H)
|
||||
|
||||
def test_returns_defaults_when_no_rows(self):
|
||||
cur = _make_cursor(rows=[])
|
||||
result = self.svc.compute_h2h(cur, 'home1', 'away1', self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_H2H)
|
||||
|
||||
def test_calculates_h2h_stats(self):
|
||||
rows = [
|
||||
{'home_team_id': 'home1', 'away_team_id': 'away1', 'score_home': 2, 'score_away': 1}, # home win, btts, over25
|
||||
{'home_team_id': 'home1', 'away_team_id': 'away1', 'score_home': 0, 'score_away': 0}, # draw, no btts, no over25
|
||||
{'home_team_id': 'away1', 'away_team_id': 'home1', 'score_home': 1, 'score_away': 3}, # reversed: home wins again, btts, over25
|
||||
{'home_team_id': 'away1', 'away_team_id': 'home1', 'score_home': 2, 'score_away': 0}, # reversed: away(=home1) lost
|
||||
]
|
||||
cur = _make_cursor(rows)
|
||||
result = self.svc.compute_h2h(cur, 'home1', 'away1', self.ts)
|
||||
|
||||
self.assertEqual(result['total_matches'], 4)
|
||||
self.assertAlmostEqual(result['home_win_rate'], 2 / 4)
|
||||
self.assertAlmostEqual(result['draw_rate'], 1 / 4)
|
||||
self.assertAlmostEqual(result['btts_rate'], 2 / 4)
|
||||
self.assertAlmostEqual(result['over25_rate'], 2 / 4)
|
||||
|
||||
def test_returns_defaults_on_db_error(self):
|
||||
cur = _make_cursor(side_effect=Exception('connection lost'))
|
||||
result = self.svc.compute_h2h(cur, 'home1', 'away1', self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_H2H)
|
||||
|
||||
|
||||
class TestComputeFormStreaks(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.svc = FeatureEnrichmentService()
|
||||
self.ts = 1700000000000
|
||||
|
||||
def test_returns_defaults_when_no_team_id(self):
|
||||
result = self.svc.compute_form_streaks(MagicMock(), '', self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_FORM)
|
||||
|
||||
def test_calculates_streaks_correctly(self):
|
||||
"""Most recent first: W, W, D, L → winning_streak=2, unbeaten_streak=3."""
|
||||
rows = [
|
||||
{'home_team_id': 'team1', 'away_team_id': 'x', 'score_home': 2, 'score_away': 0}, # W (clean sheet, scored)
|
||||
{'home_team_id': 'team1', 'away_team_id': 'x', 'score_home': 1, 'score_away': 0}, # W (clean sheet, scored)
|
||||
{'home_team_id': 'x', 'away_team_id': 'team1', 'score_home': 1, 'score_away': 1}, # D (scored, conceded)
|
||||
{'home_team_id': 'team1', 'away_team_id': 'x', 'score_home': 0, 'score_away': 2}, # L (not scored, conceded)
|
||||
]
|
||||
cur = _make_cursor(rows)
|
||||
result = self.svc.compute_form_streaks(cur, 'team1', self.ts)
|
||||
|
||||
self.assertEqual(result['winning_streak'], 2)
|
||||
self.assertEqual(result['unbeaten_streak'], 3)
|
||||
self.assertAlmostEqual(result['clean_sheet_rate'], 2 / 4)
|
||||
self.assertAlmostEqual(result['scoring_rate'], 3 / 4)
|
||||
|
||||
def test_all_losses(self):
|
||||
rows = [
|
||||
{'home_team_id': 'team1', 'away_team_id': 'x', 'score_home': 0, 'score_away': 1},
|
||||
{'home_team_id': 'team1', 'away_team_id': 'x', 'score_home': 0, 'score_away': 3},
|
||||
]
|
||||
cur = _make_cursor(rows)
|
||||
result = self.svc.compute_form_streaks(cur, 'team1', self.ts)
|
||||
|
||||
self.assertEqual(result['winning_streak'], 0)
|
||||
self.assertEqual(result['unbeaten_streak'], 0)
|
||||
self.assertAlmostEqual(result['scoring_rate'], 0.0)
|
||||
|
||||
|
||||
class TestComputeRefereeStats(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.svc = FeatureEnrichmentService()
|
||||
self.ts = 1700000000000
|
||||
|
||||
def test_returns_defaults_when_no_name(self):
|
||||
result = self.svc.compute_referee_stats(MagicMock(), None, self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_REFEREE)
|
||||
|
||||
def test_calculates_referee_tendencies(self):
|
||||
match_rows = [
|
||||
{'home_team_id': 'h1', 'score_home': 2, 'score_away': 0, 'match_id': 'm1'}, # home win
|
||||
{'home_team_id': 'h2', 'score_home': 1, 'score_away': 1, 'match_id': 'm2'}, # draw
|
||||
]
|
||||
card_row = {'yellows': 6, 'total_cards': 8}
|
||||
|
||||
cur = MagicMock()
|
||||
# First execute (match query) → match_rows
|
||||
# Second execute (card query) → card_row
|
||||
cur.fetchall.return_value = match_rows
|
||||
cur.fetchone.return_value = card_row
|
||||
|
||||
result = self.svc.compute_referee_stats(cur, 'Ref Name', self.ts)
|
||||
|
||||
self.assertEqual(result['experience'], 2)
|
||||
self.assertAlmostEqual(result['avg_goals'], (2 + 0 + 1 + 1) / 2)
|
||||
# home_bias = (1/2) - 0.46 = 0.04
|
||||
self.assertAlmostEqual(result['home_bias'], 0.04, places=4)
|
||||
self.assertAlmostEqual(result['avg_yellow'], 6 / 2)
|
||||
self.assertAlmostEqual(result['cards_total'], 8 / 2)
|
||||
|
||||
def test_returns_defaults_on_db_error(self):
|
||||
cur = _make_cursor(side_effect=Exception('timeout'))
|
||||
result = self.svc.compute_referee_stats(cur, 'Some Ref', self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_REFEREE)
|
||||
|
||||
|
||||
class TestComputeLeagueAverages(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.svc = FeatureEnrichmentService()
|
||||
self.ts = 1700000000000
|
||||
|
||||
def test_returns_defaults_when_no_league_id(self):
|
||||
result = self.svc.compute_league_averages(MagicMock(), None, self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_LEAGUE)
|
||||
|
||||
def test_calculates_league_averages(self):
|
||||
rows = [
|
||||
{'score_home': 1, 'score_away': 1}, # 2 goals
|
||||
{'score_home': 0, 'score_away': 0}, # 0 goals (zero-goal match)
|
||||
{'score_home': 3, 'score_away': 2}, # 5 goals
|
||||
]
|
||||
cur = _make_cursor(rows)
|
||||
result = self.svc.compute_league_averages(cur, 'league1', self.ts)
|
||||
|
||||
self.assertAlmostEqual(result['avg_goals'], 7 / 3, places=4)
|
||||
self.assertAlmostEqual(result['zero_goal_rate'], 1 / 3, places=4)
|
||||
|
||||
|
||||
class TestComputeMomentum(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.svc = FeatureEnrichmentService()
|
||||
self.ts = 1700000000000
|
||||
|
||||
def test_returns_zero_when_no_team_id(self):
|
||||
result = self.svc.compute_momentum(MagicMock(), '', self.ts)
|
||||
self.assertEqual(result, 0.0)
|
||||
|
||||
def test_returns_zero_when_no_rows(self):
|
||||
cur = _make_cursor(rows=[])
|
||||
result = self.svc.compute_momentum(cur, 'team1', self.ts)
|
||||
self.assertEqual(result, 0.0)
|
||||
|
||||
def test_all_wins_returns_one(self):
|
||||
"""All wins → momentum = 1.0 (max possible)."""
|
||||
rows = [
|
||||
{'home_team_id': 'team1', 'score_home': 3, 'score_away': 0},
|
||||
{'home_team_id': 'team1', 'score_home': 2, 'score_away': 1},
|
||||
]
|
||||
cur = _make_cursor(rows)
|
||||
result = self.svc.compute_momentum(cur, 'team1', self.ts)
|
||||
self.assertAlmostEqual(result, 1.0, places=4)
|
||||
|
||||
def test_all_losses_returns_negative(self):
|
||||
"""All losses → negative momentum."""
|
||||
rows = [
|
||||
{'home_team_id': 'team1', 'score_home': 0, 'score_away': 2},
|
||||
{'home_team_id': 'team1', 'score_home': 1, 'score_away': 3},
|
||||
]
|
||||
cur = _make_cursor(rows)
|
||||
result = self.svc.compute_momentum(cur, 'team1', self.ts)
|
||||
self.assertLess(result, 0.0)
|
||||
|
||||
def test_mixed_results(self):
|
||||
"""W, D, L → weighted score between -1 and 1."""
|
||||
rows = [
|
||||
{'home_team_id': 'team1', 'score_home': 1, 'score_away': 0}, # W (weight=3)
|
||||
{'home_team_id': 'x', 'away_team_id': 'team1', 'score_home': 0, 'score_away': 0}, # D (weight=2)
|
||||
{'home_team_id': 'team1', 'score_home': 0, 'score_away': 1}, # L (weight=1)
|
||||
]
|
||||
cur = _make_cursor(rows)
|
||||
result = self.svc.compute_momentum(cur, 'team1', self.ts)
|
||||
|
||||
# weighted = 3*3 + 1*2 + (-1)*1 = 9+2-1 = 10
|
||||
# max_possible = 3*3 + 3*2 + 3*1 = 18
|
||||
# normalised = 10/18 ≈ 0.5556
|
||||
self.assertAlmostEqual(result, round(10 / 18, 4), places=4)
|
||||
|
||||
def test_returns_zero_on_db_error(self):
|
||||
cur = _make_cursor(side_effect=Exception('broken pipe'))
|
||||
result = self.svc.compute_momentum(cur, 'team1', self.ts)
|
||||
self.assertEqual(result, 0.0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,110 +0,0 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
AI_ENGINE_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(AI_ENGINE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(AI_ENGINE_ROOT))
|
||||
|
||||
import main as ai_main
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
class MainApiFunctionTests(unittest.TestCase):
|
||||
def test_analyze_match_v20plus_returns_payload(self):
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.analyze_match.return_value = {"match_info": {"match_id": "m1"}}
|
||||
|
||||
with patch("main.get_single_match_orchestrator", return_value=orchestrator):
|
||||
result = _run(ai_main.analyze_match_v20plus("m1"))
|
||||
|
||||
self.assertEqual(result["match_info"]["match_id"], "m1")
|
||||
|
||||
def test_analyze_match_v20plus_raises_404(self):
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.analyze_match.return_value = None
|
||||
|
||||
with patch("main.get_single_match_orchestrator", return_value=orchestrator):
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
_run(ai_main.analyze_match_v20plus("missing"))
|
||||
|
||||
self.assertEqual(ctx.exception.status_code, 404)
|
||||
|
||||
def test_analyze_match_htms_v20plus_returns_payload(self):
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.analyze_match_htms.return_value = {
|
||||
"status": "ok",
|
||||
"engine_used": "v20plus_top_htms",
|
||||
}
|
||||
|
||||
with patch("main.get_single_match_orchestrator", return_value=orchestrator):
|
||||
result = _run(ai_main.analyze_match_htms_v20plus("m1"))
|
||||
|
||||
self.assertEqual(result["status"], "ok")
|
||||
self.assertEqual(result["engine_used"], "v20plus_top_htms")
|
||||
|
||||
def test_analyze_match_htft_timeout_validation(self):
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
_run(ai_main.analyze_match_htft_v20plus("m1", timeout_sec=2))
|
||||
|
||||
self.assertEqual(ctx.exception.status_code, 400)
|
||||
|
||||
def test_generate_coupon_v20plus_forwards_payload(self):
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.build_coupon.return_value = {"bets": []}
|
||||
|
||||
request = ai_main.CouponRequest(
|
||||
match_ids=["m1", "m2"],
|
||||
strategy="SAFE",
|
||||
max_matches=3,
|
||||
min_confidence=70,
|
||||
)
|
||||
|
||||
with patch("main.get_single_match_orchestrator", return_value=orchestrator):
|
||||
result = _run(ai_main.generate_coupon_v20plus(request))
|
||||
|
||||
self.assertEqual(result, {"bets": []})
|
||||
orchestrator.build_coupon.assert_called_once_with(
|
||||
match_ids=["m1", "m2"],
|
||||
strategy="SAFE",
|
||||
max_matches=3,
|
||||
min_confidence=70.0,
|
||||
)
|
||||
|
||||
def test_reversal_watchlist_validation(self):
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
_run(ai_main.get_reversal_watchlist_v20plus(count=0))
|
||||
self.assertEqual(ctx.exception.status_code, 400)
|
||||
|
||||
def test_reversal_watchlist_forwards_payload(self):
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.get_reversal_watchlist.return_value = {"watchlist": []}
|
||||
|
||||
with patch("main.get_single_match_orchestrator", return_value=orchestrator):
|
||||
result = _run(
|
||||
ai_main.get_reversal_watchlist_v20plus(
|
||||
count=12,
|
||||
horizon_hours=48,
|
||||
min_score=50.5,
|
||||
top_leagues_only=True,
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"watchlist": []})
|
||||
orchestrator.get_reversal_watchlist.assert_called_once_with(
|
||||
count=12,
|
||||
horizon_hours=48,
|
||||
min_score=50.5,
|
||||
top_leagues_only=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,768 +0,0 @@
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
AI_ENGINE_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(AI_ENGINE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(AI_ENGINE_ROOT))
|
||||
|
||||
from models.v20_ensemble import FullMatchPrediction
|
||||
from models.basketball_v25 import BasketballMatchPrediction
|
||||
from services.single_match_orchestrator import MatchData, SingleMatchOrchestrator
|
||||
|
||||
|
||||
class _CursorContext:
|
||||
def __init__(self, cursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self):
|
||||
return self._cursor
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class _ConnContext:
|
||||
def __init__(self, cursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def cursor(self, cursor_factory=None):
|
||||
return _CursorContext(self._cursor)
|
||||
|
||||
|
||||
class _StaticFetchAllCursor:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
self.executed = []
|
||||
|
||||
def execute(self, query, params=None):
|
||||
self.executed.append((query, params))
|
||||
|
||||
def fetchall(self):
|
||||
return list(self.rows)
|
||||
|
||||
|
||||
class _RouterCursor:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
live_row=None,
|
||||
hist_row=None,
|
||||
relational_rows=None,
|
||||
participation_rows=None,
|
||||
probable_rows=None,
|
||||
):
|
||||
self.live_row = live_row
|
||||
self.hist_row = hist_row
|
||||
self.relational_rows = relational_rows or []
|
||||
self.participation_rows = participation_rows or []
|
||||
self.probable_rows = probable_rows or []
|
||||
self.last_query = ""
|
||||
|
||||
def execute(self, query, params=None):
|
||||
self.last_query = query
|
||||
|
||||
def fetchone(self):
|
||||
if "FROM live_matches" in self.last_query:
|
||||
return self.live_row
|
||||
if "FROM matches m" in self.last_query:
|
||||
return self.hist_row
|
||||
return None
|
||||
|
||||
def fetchall(self):
|
||||
if "FROM odd_categories" in self.last_query:
|
||||
return list(self.relational_rows)
|
||||
if "FROM match_player_participation" in self.last_query and "GROUP BY" not in self.last_query:
|
||||
return list(self.participation_rows)
|
||||
if "GROUP BY mpp.player_id" in self.last_query:
|
||||
return list(self.probable_rows)
|
||||
return []
|
||||
|
||||
|
||||
def _build_orchestrator() -> SingleMatchOrchestrator:
|
||||
orchestrator = SingleMatchOrchestrator.__new__(SingleMatchOrchestrator)
|
||||
orchestrator.v25_predictor = MagicMock()
|
||||
orchestrator.v26_shadow_engine = None
|
||||
orchestrator.basketball_predictor = MagicMock()
|
||||
orchestrator.dsn = "postgresql://unit-test"
|
||||
orchestrator.engine_mode = "v25"
|
||||
orchestrator.league_reliability = {}
|
||||
orchestrator.market_calibration = {
|
||||
"MS": 0.82,
|
||||
"DC": 0.93,
|
||||
"OU15": 0.90,
|
||||
"OU25": 0.85,
|
||||
"OU35": 0.88,
|
||||
"BTTS": 0.83,
|
||||
"HT": 0.80,
|
||||
"HT_OU05": 0.88,
|
||||
}
|
||||
orchestrator.market_min_conf = {
|
||||
"MS": 52.0,
|
||||
"DC": 56.0,
|
||||
"OU15": 60.0,
|
||||
"OU25": 58.0,
|
||||
"OU35": 54.0,
|
||||
"BTTS": 57.0,
|
||||
"HT": 53.0,
|
||||
"HT_OU05": 55.0,
|
||||
}
|
||||
orchestrator.market_min_play_score = {
|
||||
"MS": 72.0,
|
||||
"DC": 62.0,
|
||||
"OU15": 64.0,
|
||||
"OU25": 70.0,
|
||||
"OU35": 76.0,
|
||||
"BTTS": 70.0,
|
||||
"HT": 74.0,
|
||||
"HT_OU05": 64.0,
|
||||
}
|
||||
orchestrator.market_min_edge = {
|
||||
"MS": 0.03,
|
||||
"DC": 0.01,
|
||||
"OU15": 0.01,
|
||||
"OU25": 0.02,
|
||||
"OU35": 0.04,
|
||||
"BTTS": 0.03,
|
||||
"HT": 0.04,
|
||||
"HT_OU05": 0.01,
|
||||
}
|
||||
return orchestrator
|
||||
|
||||
|
||||
class SingleMatchOrchestratorTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.orchestrator = _build_orchestrator()
|
||||
|
||||
def test_parse_odds_json_uses_exact_market_match_and_ignores_collisions(self):
|
||||
odds_json = {
|
||||
"Maç Sonucu": {"1": "2.15", "X": "3.20", "2": "3.30"},
|
||||
"İlk Yarı/Maç Sonucu": {"1/1": "4.30"},
|
||||
"2,5 Alt/Üst": {"Üst": "1.85", "Alt": "1.95"},
|
||||
"İY 0,5 Alt/Üst": {"Üst": "1.49", "Alt": "2.20"},
|
||||
"1. Yarı Ev Sahibi 0,5 Alt/Üst": {"Üst": "1.99", "Alt": "1.45"},
|
||||
"2,5 Kart Puanı Alt/Üst": {"Üst": "1.33", "Alt": "2.95"},
|
||||
"Karşılıklı Gol": {"Var": "1.75", "Yok": "2.05"},
|
||||
"1. Yarı Karşılıklı Gol": {"Var": "2.10", "Yok": "1.60"},
|
||||
"Çifte Şans": {"1-X": "1.33", "X-2": "1.62", "1-2": "1.30"},
|
||||
"1. Yarı Sonucu": {"1": "2.45", "X": "2.00", "2": "3.80"},
|
||||
}
|
||||
|
||||
parsed = self.orchestrator._parse_odds_json(odds_json)
|
||||
|
||||
self.assertEqual(parsed["ms_h"], 2.15)
|
||||
self.assertEqual(parsed["ms_d"], 3.20)
|
||||
self.assertEqual(parsed["ms_a"], 3.30)
|
||||
self.assertEqual(parsed["ou25_o"], 1.85)
|
||||
self.assertEqual(parsed["ou25_u"], 1.95)
|
||||
self.assertEqual(parsed["btts_y"], 1.75)
|
||||
self.assertEqual(parsed["btts_n"], 2.05)
|
||||
self.assertEqual(parsed["dc_1x"], 1.33)
|
||||
self.assertEqual(parsed["dc_x2"], 1.62)
|
||||
self.assertEqual(parsed["dc_12"], 1.30)
|
||||
self.assertEqual(parsed["ht_h"], 2.45)
|
||||
self.assertEqual(parsed["ht_d"], 2.00)
|
||||
self.assertEqual(parsed["ht_a"], 3.80)
|
||||
self.assertEqual(parsed["ht_ou05_o"], 1.49)
|
||||
self.assertEqual(parsed["ht_ou05_u"], 2.20)
|
||||
self.assertEqual(parsed["htft_11"], 4.30)
|
||||
|
||||
def test_parse_odds_json_accepts_selection_variants(self):
|
||||
odds_json = {
|
||||
"2,5 Alt/Üst": {"2,5 Üst": "1.91", "2,5 Alt": "1.86"},
|
||||
"Karşılıklı Gol": {"YES": "1.82", "NO": "1.96"},
|
||||
"Çifte Şans": {"1X": "1.28", "X2": "1.44", "12": "1.32"},
|
||||
}
|
||||
|
||||
parsed = self.orchestrator._parse_odds_json(odds_json)
|
||||
|
||||
self.assertEqual(parsed["ou25_o"], 1.91)
|
||||
self.assertEqual(parsed["ou25_u"], 1.86)
|
||||
self.assertEqual(parsed["btts_y"], 1.82)
|
||||
self.assertEqual(parsed["btts_n"], 1.96)
|
||||
self.assertEqual(parsed["dc_1x"], 1.28)
|
||||
self.assertEqual(parsed["dc_x2"], 1.44)
|
||||
self.assertEqual(parsed["dc_12"], 1.32)
|
||||
|
||||
def test_parse_odds_json_maps_all_football_markets_with_noise(self):
|
||||
odds_json = {
|
||||
"Maç Sonucu": {"1": "2.31", "X": "3.22", "2": "3.05"},
|
||||
"Çifte Şans": {"1-X": "1.34", "X-2": "1.52", "1-2": "1.28"},
|
||||
"1,5 Alt/Üst": {"Üst": "1.29", "Alt": "3.45"},
|
||||
"2,5 Alt/Üst": {"Üst": "1.71", "Alt": "2.05"},
|
||||
"3,5 Alt/Üst": {"Üst": "2.62", "Alt": "1.41"},
|
||||
"Karşılıklı Gol": {"Var": "1.66", "Yok": "2.11"},
|
||||
"1. Yarı Sonucu": {"1": "3.10", "X": "1.95", "2": "4.60"},
|
||||
"1. Yarı 0,5 Alt/Üst": {"Üst": "1.21", "Alt": "2.72"},
|
||||
# noise categories that must not overwrite football main markets
|
||||
"1. Yarı Ev Sahibi 0,5 Alt/Üst": {"Üst": "1.99", "Alt": "1.45"},
|
||||
"1. Yarı Deplasman 0,5 Alt/Üst": {"Üst": "1.73", "Alt": "1.63"},
|
||||
"1.Yarı 3,5 Korner Alt/Üst": {"Üst": "1.26", "Alt": "2.30"},
|
||||
"2,5 Kart Puanı Alt/Üst": {"Üst": "1.40", "Alt": "2.60"},
|
||||
}
|
||||
|
||||
parsed = self.orchestrator._parse_odds_json(odds_json)
|
||||
|
||||
self.assertEqual(parsed["ms_h"], 2.31)
|
||||
self.assertEqual(parsed["ms_d"], 3.22)
|
||||
self.assertEqual(parsed["ms_a"], 3.05)
|
||||
self.assertEqual(parsed["dc_1x"], 1.34)
|
||||
self.assertEqual(parsed["dc_x2"], 1.52)
|
||||
self.assertEqual(parsed["dc_12"], 1.28)
|
||||
self.assertEqual(parsed["ou15_o"], 1.29)
|
||||
self.assertEqual(parsed["ou15_u"], 3.45)
|
||||
self.assertEqual(parsed["ou25_o"], 1.71)
|
||||
self.assertEqual(parsed["ou25_u"], 2.05)
|
||||
self.assertEqual(parsed["ou35_o"], 2.62)
|
||||
self.assertEqual(parsed["ou35_u"], 1.41)
|
||||
self.assertEqual(parsed["btts_y"], 1.66)
|
||||
self.assertEqual(parsed["btts_n"], 2.11)
|
||||
self.assertEqual(parsed["ht_h"], 3.10)
|
||||
self.assertEqual(parsed["ht_d"], 1.95)
|
||||
self.assertEqual(parsed["ht_a"], 4.60)
|
||||
self.assertEqual(parsed["ht_ou05_o"], 1.21)
|
||||
self.assertEqual(parsed["ht_ou05_u"], 2.72)
|
||||
|
||||
def test_v25_market_odds_ignores_synthetic_default_when_selection_missing(self):
|
||||
odds_json = {
|
||||
"1,5 Alt/Üst": {"Alt": 5.70},
|
||||
"Çifte Şans": {"1-X": 1.30, "X-2": 1.38, "1-2": 1.09},
|
||||
}
|
||||
|
||||
parsed = self.orchestrator._parse_odds_json(odds_json)
|
||||
|
||||
self.assertEqual(parsed["ou15_o"], 0.0)
|
||||
self.assertEqual(
|
||||
self.orchestrator._v25_market_odds(parsed, "OU15", "Over"),
|
||||
1.0,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.orchestrator._v25_market_odds(parsed, "OU15", "Under"),
|
||||
5.7,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.orchestrator._v25_market_odds(parsed, "DC", "X2"),
|
||||
1.38,
|
||||
)
|
||||
|
||||
def test_parse_odds_json_extracts_basketball_ml_total_spread(self):
|
||||
odds_json = {
|
||||
"Maç Sonucu (Uzt. Dahil)": {"1": "1.74", "2": "2.08"},
|
||||
"Alt/Üst (163,5)": {"Üst": "1.86", "Alt": "1.94"},
|
||||
"1. Yarı Alt/Üst (81,5)": {"Üst": "1.89", "Alt": "1.91"},
|
||||
"1. Yarı Alt/Üst (100,5)": {"Üst": "1.83", "Alt": "1.97"},
|
||||
"Hnd. MS (0:5,5)": {"1": "1.91", "+5.5h": "1.87"},
|
||||
}
|
||||
|
||||
parsed = self.orchestrator._parse_odds_json(odds_json)
|
||||
|
||||
self.assertEqual(parsed["ml_h"], 1.74)
|
||||
self.assertEqual(parsed["ml_a"], 2.08)
|
||||
self.assertEqual(parsed["tot_line"], 163.5)
|
||||
self.assertEqual(parsed["tot_o"], 1.86)
|
||||
self.assertEqual(parsed["tot_u"], 1.94)
|
||||
self.assertEqual(parsed["spread_home_line"], -5.5)
|
||||
self.assertEqual(parsed["spread_h"], 1.91)
|
||||
self.assertEqual(parsed["spread_a"], 1.87)
|
||||
self.assertNotIn("ht_ou05_o", parsed)
|
||||
self.assertNotIn("ht_ou05_u", parsed)
|
||||
|
||||
def test_extract_odds_merges_relational_when_live_json_is_incomplete(self):
|
||||
row = {
|
||||
"match_id": "m-1",
|
||||
"odds": {"Maç Sonucu": {"1": 2.10, "X": 3.20, "2": 3.35}},
|
||||
}
|
||||
relational_rows = [
|
||||
{"category_name": "Çifte Şans", "selection_name": "1-X", "odd_value": 1.28},
|
||||
{"category_name": "Çifte Şans", "selection_name": "X-2", "odd_value": 1.44},
|
||||
{"category_name": "Çifte Şans", "selection_name": "1-2", "odd_value": 1.31},
|
||||
{"category_name": "2,5 Alt/Üst", "selection_name": "Üst", "odd_value": 1.89},
|
||||
{"category_name": "2,5 Alt/Üst", "selection_name": "Alt", "odd_value": 1.94},
|
||||
{"category_name": "Karşılıklı Gol", "selection_name": "Var", "odd_value": 1.77},
|
||||
{"category_name": "Karşılıklı Gol", "selection_name": "Yok", "odd_value": 2.02},
|
||||
{"category_name": "1. Yarı Sonucu", "selection_name": "1", "odd_value": 2.55},
|
||||
{"category_name": "1. Yarı Sonucu", "selection_name": "X", "odd_value": 1.98},
|
||||
{"category_name": "1. Yarı Sonucu", "selection_name": "2", "odd_value": 3.40},
|
||||
]
|
||||
cur = _StaticFetchAllCursor(relational_rows)
|
||||
|
||||
odds = self.orchestrator._extract_odds(cur, row)
|
||||
|
||||
self.assertEqual(odds["ms_h"], 2.10)
|
||||
self.assertEqual(odds["ms_d"], 3.20)
|
||||
self.assertEqual(odds["ms_a"], 3.35)
|
||||
self.assertEqual(odds["dc_x2"], 1.44)
|
||||
self.assertEqual(odds["ou25_o"], 1.89)
|
||||
self.assertEqual(odds["btts_y"], 1.77)
|
||||
self.assertEqual(odds["ht_d"], 1.98)
|
||||
self.assertEqual(len(cur.executed), 1)
|
||||
|
||||
def test_extract_odds_fills_default_ms_when_no_source_available(self):
|
||||
row = {"match_id": "m-2", "odds": None}
|
||||
cur = _StaticFetchAllCursor([])
|
||||
|
||||
odds = self.orchestrator._extract_odds(cur, row)
|
||||
|
||||
self.assertEqual(odds["ms_h"], SingleMatchOrchestrator.DEFAULT_MS_H)
|
||||
self.assertEqual(odds["ms_d"], SingleMatchOrchestrator.DEFAULT_MS_D)
|
||||
self.assertEqual(odds["ms_a"], SingleMatchOrchestrator.DEFAULT_MS_A)
|
||||
|
||||
def test_parse_lineups_json_supports_id_playerid_personid(self):
|
||||
lineups = {
|
||||
"home": {
|
||||
"xi": [
|
||||
{"id": "11"},
|
||||
{"playerId": "12"},
|
||||
],
|
||||
},
|
||||
"away": {
|
||||
"starting": [
|
||||
{"personId": "21"},
|
||||
"22",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
home, away = self.orchestrator._parse_lineups_json(lineups)
|
||||
|
||||
self.assertEqual(home, ["11", "12"])
|
||||
self.assertEqual(away, ["21", "22"])
|
||||
|
||||
def test_extract_lineups_uses_participation_and_probable_xi_fallbacks(self):
|
||||
row = {
|
||||
"match_id": "m-3",
|
||||
"home_team_id": "h1",
|
||||
"away_team_id": "a1",
|
||||
"match_date_ms": 1700000000000,
|
||||
"lineups": {
|
||||
"home": {"xi": [{"personId": "h-live-1"}]},
|
||||
"away": {},
|
||||
},
|
||||
}
|
||||
participation = [
|
||||
{"team_id": "a1", "player_id": "a-db-1"},
|
||||
{"team_id": "a1", "player_id": "a-db-2"},
|
||||
]
|
||||
cur = _StaticFetchAllCursor(participation)
|
||||
|
||||
with patch.object(
|
||||
self.orchestrator,
|
||||
"_build_probable_xi",
|
||||
side_effect=[["h-prob-1"], ["a-prob-1"]],
|
||||
) as probable_xi:
|
||||
home, away, source = self.orchestrator._extract_lineups(cur, row)
|
||||
|
||||
self.assertEqual(home, ["h-live-1"])
|
||||
self.assertEqual(away, ["a-db-1", "a-db-2"])
|
||||
self.assertEqual(source, "none")
|
||||
probable_xi.assert_not_called()
|
||||
|
||||
def test_extract_lineups_falls_back_to_probable_xi_when_live_and_participation_missing(self):
|
||||
row = {
|
||||
"match_id": "m-4",
|
||||
"home_team_id": "h2",
|
||||
"away_team_id": "a2",
|
||||
"match_date_ms": 1700000000000,
|
||||
"lineups": None,
|
||||
}
|
||||
cur = _StaticFetchAllCursor([])
|
||||
|
||||
with patch.object(
|
||||
self.orchestrator,
|
||||
"_build_probable_xi",
|
||||
side_effect=[["h-prob-1", "h-prob-2"], ["a-prob-1"]],
|
||||
) as probable_xi:
|
||||
home, away, source = self.orchestrator._extract_lineups(cur, row)
|
||||
|
||||
self.assertEqual(home, ["h-prob-1", "h-prob-2"])
|
||||
self.assertEqual(away, ["a-prob-1"])
|
||||
self.assertEqual(source, "probable_xi")
|
||||
self.assertEqual(probable_xi.call_count, 2)
|
||||
|
||||
def test_load_match_data_parses_live_row_json_and_sidelined(self):
|
||||
odds_payload = {
|
||||
"Maç Sonucu": {"1": 2.10, "X": 3.30, "2": 3.50},
|
||||
"Çifte Şans": {"1-X": 1.30, "X-2": 1.52, "1-2": 1.34},
|
||||
"1,5 Alt/Üst": {"Üst": 1.33, "Alt": 2.90},
|
||||
"2,5 Alt/Üst": {"Üst": 1.91, "Alt": 1.85},
|
||||
"3,5 Alt/Üst": {"Üst": 2.95, "Alt": 1.38},
|
||||
"Karşılıklı Gol": {"Var": 1.84, "Yok": 1.92},
|
||||
"1. Yarı Sonucu": {"1": 2.55, "X": 1.97, "2": 3.45},
|
||||
}
|
||||
lineups_payload = {
|
||||
"home": {"xi": [{"personId": "101"}, {"personId": "102"}]},
|
||||
"away": {"xi": [{"personId": "201"}, {"personId": "202"}]},
|
||||
}
|
||||
live_row = {
|
||||
"match_id": "live-101",
|
||||
"home_team_id": "h-101",
|
||||
"away_team_id": "a-101",
|
||||
"league_id": "l-101",
|
||||
"sport": "FOOTBALL",
|
||||
"match_date_ms": 1760000000000,
|
||||
"odds": json.dumps(odds_payload),
|
||||
"lineups": json.dumps(lineups_payload),
|
||||
"sidelined": json.dumps(
|
||||
{
|
||||
"homeTeam": {"totalSidelined": 1, "players": []},
|
||||
"awayTeam": {"totalSidelined": 0, "players": []},
|
||||
}
|
||||
),
|
||||
"referee_name": "John Ref",
|
||||
"home_team_name": "Home FC",
|
||||
"away_team_name": "Away FC",
|
||||
"league_name": "League Name",
|
||||
}
|
||||
cursor = _RouterCursor(live_row=live_row)
|
||||
|
||||
with patch("services.single_match_orchestrator.psycopg2.connect", return_value=_ConnContext(cursor)):
|
||||
data = self.orchestrator._load_match_data("live-101")
|
||||
|
||||
self.assertIsNotNone(data)
|
||||
self.assertEqual(data.match_id, "live-101")
|
||||
self.assertEqual(data.home_team_id, "h-101")
|
||||
self.assertEqual(data.away_team_id, "a-101")
|
||||
self.assertEqual(data.sport, "football")
|
||||
self.assertEqual(data.referee_name, "John Ref")
|
||||
self.assertEqual(data.home_lineup, ["101", "102"])
|
||||
self.assertEqual(data.away_lineup, ["201", "202"])
|
||||
self.assertEqual(data.lineup_source, "none")
|
||||
self.assertEqual(data.sidelined_data["homeTeam"]["totalSidelined"], 1)
|
||||
self.assertEqual(data.odds_data["dc_x2"], 1.52)
|
||||
self.assertEqual(data.odds_data["ht_h"], 2.55)
|
||||
|
||||
def test_analyze_match_forwards_all_core_fields_to_predictor(self):
|
||||
match_data = MatchData(
|
||||
match_id="live-55",
|
||||
home_team_id="home-55",
|
||||
away_team_id="away-55",
|
||||
home_team_name="Home 55",
|
||||
away_team_name="Away 55",
|
||||
match_date_ms=1760000000000,
|
||||
sport="football",
|
||||
league_id="league-55",
|
||||
league_name="League 55",
|
||||
referee_name="Ref 55",
|
||||
odds_data={"ms_h": 2.4, "ms_d": 3.1, "ms_a": 2.9},
|
||||
home_lineup=["h1", "h2"],
|
||||
away_lineup=["a1", "a2"],
|
||||
sidelined_data={
|
||||
"homeTeam": {"totalSidelined": 2, "players": []},
|
||||
"awayTeam": {"totalSidelined": 1, "players": []},
|
||||
},
|
||||
home_goals_avg=1.6,
|
||||
home_conceded_avg=1.1,
|
||||
away_goals_avg=1.2,
|
||||
away_conceded_avg=1.4,
|
||||
home_position=5,
|
||||
away_position=8,
|
||||
lineup_source="confirmed_live",
|
||||
)
|
||||
prediction = FullMatchPrediction(match_id="live-55", home_team="Home 55", away_team="Away 55")
|
||||
|
||||
self.orchestrator._load_match_data = MagicMock(return_value=match_data)
|
||||
self.orchestrator.v25_predictor.predict_market_bundle = MagicMock(return_value={"MS": {"pick": "1"}})
|
||||
self.orchestrator._build_v25_features = MagicMock(return_value={})
|
||||
self.orchestrator._get_v25_signal = MagicMock(return_value={"MS": {"pick": "1"}})
|
||||
self.orchestrator._build_v25_prediction = MagicMock(return_value=prediction)
|
||||
self.orchestrator._build_prediction_package = MagicMock(return_value={"ok": True})
|
||||
|
||||
result = self.orchestrator.analyze_match("live-55")
|
||||
|
||||
self.assertEqual(result, {"ok": True})
|
||||
self.orchestrator._build_v25_features.assert_called_once_with(match_data)
|
||||
self.orchestrator._get_v25_signal.assert_called_once_with(match_data, {})
|
||||
self.orchestrator._build_v25_prediction.assert_called_once_with(
|
||||
match_data,
|
||||
{},
|
||||
{"MS": {"pick": "1"}},
|
||||
)
|
||||
|
||||
def test_analyze_match_routes_basketball_to_basketball_predictor(self):
|
||||
match_data = MatchData(
|
||||
match_id="b-live-1",
|
||||
home_team_id="bh",
|
||||
away_team_id="ba",
|
||||
home_team_name="Home B",
|
||||
away_team_name="Away B",
|
||||
match_date_ms=1760000000000,
|
||||
sport="basketball",
|
||||
league_id="bleague",
|
||||
league_name="B League",
|
||||
referee_name=None,
|
||||
odds_data={"ml_h": 1.75, "ml_a": 2.05, "tot_line": 161.5, "tot_o": 1.88, "tot_u": 1.92},
|
||||
home_lineup=None,
|
||||
away_lineup=None,
|
||||
sidelined_data={"homeTeam": {"totalSidelined": 1}, "awayTeam": {"totalSidelined": 0}},
|
||||
home_goals_avg=85.0,
|
||||
home_conceded_avg=79.0,
|
||||
away_goals_avg=82.0,
|
||||
away_conceded_avg=81.0,
|
||||
home_position=4,
|
||||
away_position=7,
|
||||
lineup_source="none",
|
||||
)
|
||||
prediction = BasketballMatchPrediction(
|
||||
match_id="b-live-1",
|
||||
home_team_name="Home B",
|
||||
away_team_name="Away B",
|
||||
league_name="B League",
|
||||
)
|
||||
|
||||
self.orchestrator._load_match_data = MagicMock(return_value=match_data)
|
||||
self.orchestrator.basketball_predictor.predict = MagicMock(return_value=prediction)
|
||||
self.orchestrator._build_basketball_prediction_package = MagicMock(
|
||||
return_value={"sport": "basketball", "ok": True}
|
||||
)
|
||||
|
||||
result = self.orchestrator.analyze_match("b-live-1")
|
||||
|
||||
self.assertEqual(result, {"sport": "basketball", "ok": True})
|
||||
self.orchestrator.basketball_predictor.predict.assert_called_once()
|
||||
kwargs = self.orchestrator.basketball_predictor.predict.call_args.kwargs
|
||||
self.assertEqual(kwargs["match_id"], "b-live-1")
|
||||
self.assertEqual(kwargs["home_team_id"], "bh")
|
||||
self.assertEqual(kwargs["away_team_id"], "ba")
|
||||
self.assertEqual(kwargs["league_id"], "bleague")
|
||||
self.assertEqual(kwargs["odds_data"]["ml_h"], 1.75)
|
||||
self.orchestrator.v25_predictor.predict_market_bundle.assert_not_called()
|
||||
|
||||
def test_build_market_rows_maps_odds_keys_correctly(self):
|
||||
data = MatchData(
|
||||
match_id="m-rows",
|
||||
home_team_id="h",
|
||||
away_team_id="a",
|
||||
home_team_name="Home",
|
||||
away_team_name="Away",
|
||||
match_date_ms=1760000000000,
|
||||
sport="football",
|
||||
league_id=None,
|
||||
league_name="",
|
||||
referee_name=None,
|
||||
odds_data={
|
||||
"ms_h": 2.3,
|
||||
"ms_d": 3.2,
|
||||
"ms_a": 3.1,
|
||||
"dc_x2": 1.45,
|
||||
"ou15_o": 1.36,
|
||||
"ou25_u": 1.92,
|
||||
"ou35_o": 2.85,
|
||||
"btts_y": 1.88,
|
||||
"ht_h": 2.55,
|
||||
"ht_ou05_o": 1.47,
|
||||
},
|
||||
home_lineup=None,
|
||||
away_lineup=None,
|
||||
sidelined_data=None,
|
||||
home_goals_avg=1.5,
|
||||
home_conceded_avg=1.2,
|
||||
away_goals_avg=1.2,
|
||||
away_conceded_avg=1.4,
|
||||
home_position=10,
|
||||
away_position=10,
|
||||
lineup_source="none",
|
||||
)
|
||||
pred = FullMatchPrediction(
|
||||
match_id="m-rows",
|
||||
home_team="Home",
|
||||
away_team="Away",
|
||||
ms_home_prob=0.25,
|
||||
ms_draw_prob=0.30,
|
||||
ms_away_prob=0.45,
|
||||
ms_pick="2",
|
||||
ms_confidence=69.0,
|
||||
dc_1x_prob=0.60,
|
||||
dc_x2_prob=0.72,
|
||||
dc_12_prob=0.68,
|
||||
dc_pick="X2",
|
||||
dc_confidence=67.0,
|
||||
over_15_prob=0.74,
|
||||
under_15_prob=0.26,
|
||||
ou15_pick="1.5 Üst",
|
||||
ou15_confidence=72.0,
|
||||
over_25_prob=0.44,
|
||||
under_25_prob=0.56,
|
||||
ou25_pick="2.5 Alt",
|
||||
ou25_confidence=61.0,
|
||||
over_35_prob=0.39,
|
||||
under_35_prob=0.61,
|
||||
ou35_pick="3.5 Over",
|
||||
ou35_confidence=58.0,
|
||||
btts_yes_prob=0.57,
|
||||
btts_no_prob=0.43,
|
||||
btts_pick="Yes",
|
||||
btts_confidence=63.0,
|
||||
ht_home_prob=0.41,
|
||||
ht_draw_prob=0.39,
|
||||
ht_away_prob=0.20,
|
||||
ht_pick="1",
|
||||
ht_confidence=60.0,
|
||||
ht_over_05_prob=0.64,
|
||||
ht_under_05_prob=0.36,
|
||||
ht_ou_pick="Over 0.5",
|
||||
)
|
||||
|
||||
rows = self.orchestrator._build_market_rows(data, pred)
|
||||
by_market = {row["market"]: row for row in rows}
|
||||
|
||||
self.assertEqual(by_market["MS"]["odds"], 3.1)
|
||||
self.assertEqual(by_market["DC"]["odds"], 1.45)
|
||||
self.assertEqual(by_market["OU15"]["odds"], 1.36)
|
||||
self.assertEqual(by_market["OU25"]["odds"], 1.92)
|
||||
self.assertEqual(by_market["OU35"]["odds"], 2.85)
|
||||
self.assertEqual(by_market["BTTS"]["odds"], 1.88)
|
||||
self.assertEqual(by_market["HT"]["odds"], 2.55)
|
||||
self.assertEqual(by_market["HT_OU05"]["odds"], 1.47)
|
||||
|
||||
def test_build_basketball_market_rows_maps_odds_keys_correctly(self):
|
||||
data = MatchData(
|
||||
match_id="b-rows",
|
||||
home_team_id="bh",
|
||||
away_team_id="ba",
|
||||
home_team_name="Home B",
|
||||
away_team_name="Away B",
|
||||
match_date_ms=1760000000000,
|
||||
sport="basketball",
|
||||
league_id="bl",
|
||||
league_name="Basketball League",
|
||||
referee_name=None,
|
||||
odds_data={
|
||||
"ml_h": 1.73,
|
||||
"ml_a": 2.10,
|
||||
"tot_line": 162.5,
|
||||
"tot_o": 1.89,
|
||||
"tot_u": 1.93,
|
||||
"spread_home_line": -4.5,
|
||||
"spread_h": 1.91,
|
||||
"spread_a": 1.88,
|
||||
},
|
||||
home_lineup=None,
|
||||
away_lineup=None,
|
||||
sidelined_data=None,
|
||||
home_goals_avg=84.0,
|
||||
home_conceded_avg=80.0,
|
||||
away_goals_avg=82.0,
|
||||
away_conceded_avg=81.0,
|
||||
home_position=5,
|
||||
away_position=8,
|
||||
lineup_source="none",
|
||||
)
|
||||
pred = {
|
||||
"match_id": "b-rows",
|
||||
"market_board": {
|
||||
"ML": {"1": "62%", "2": "38%"},
|
||||
"Totals": {"Under 162.5": "43%", "Over 162.5": "57%"},
|
||||
"Spread": {"Away +4.5": "46%", "Home -4.5": "54%"}
|
||||
}
|
||||
}
|
||||
|
||||
rows = self.orchestrator._build_basketball_market_rows(data, pred)
|
||||
by_market = {row["market"]: row for row in rows}
|
||||
|
||||
self.assertEqual(by_market["ML"]["odds"], 1.73)
|
||||
self.assertEqual(by_market["TOTAL"]["odds"], 1.89)
|
||||
self.assertEqual(by_market["SPREAD"]["odds"], 1.91)
|
||||
|
||||
def test_compute_data_quality_flags_missing_referee_and_lineup(self):
|
||||
data = MatchData(
|
||||
match_id="dq-1",
|
||||
home_team_id="h",
|
||||
away_team_id="a",
|
||||
home_team_name="Home",
|
||||
away_team_name="Away",
|
||||
match_date_ms=1760000000000,
|
||||
sport="football",
|
||||
league_id=None,
|
||||
league_name="",
|
||||
referee_name=None,
|
||||
odds_data={"ms_h": 2.5, "ms_d": 3.2, "ms_a": 2.9},
|
||||
home_lineup=["h1", "h2"],
|
||||
away_lineup=["a1"],
|
||||
sidelined_data=None,
|
||||
home_goals_avg=1.5,
|
||||
home_conceded_avg=1.2,
|
||||
away_goals_avg=1.2,
|
||||
away_conceded_avg=1.4,
|
||||
home_position=10,
|
||||
away_position=10,
|
||||
lineup_source="none",
|
||||
)
|
||||
|
||||
quality = self.orchestrator._compute_data_quality(data)
|
||||
|
||||
self.assertIn("lineup_incomplete", quality["flags"])
|
||||
self.assertIn("missing_referee", quality["flags"])
|
||||
self.assertEqual(quality["label"], "MEDIUM")
|
||||
|
||||
def test_load_match_data_returns_none_when_team_ids_missing(self):
|
||||
live_row = {
|
||||
"match_id": "live-missing-ids",
|
||||
"home_team_id": None,
|
||||
"away_team_id": None,
|
||||
"league_id": "l-1",
|
||||
"sport": "football",
|
||||
"match_date_ms": 1760000000000,
|
||||
"odds": None,
|
||||
"lineups": None,
|
||||
"sidelined": None,
|
||||
"referee_name": None,
|
||||
"home_team_name": "Home",
|
||||
"away_team_name": "Away",
|
||||
"league_name": "League",
|
||||
}
|
||||
cursor = _RouterCursor(live_row=live_row)
|
||||
|
||||
with patch("services.single_match_orchestrator.psycopg2.connect", return_value=_ConnContext(cursor)):
|
||||
data = self.orchestrator._load_match_data("live-missing-ids")
|
||||
|
||||
self.assertIsNone(data)
|
||||
|
||||
def test_decorate_market_row_blocks_required_market_when_odds_missing(self):
|
||||
data = MatchData(
|
||||
match_id="dq-odds",
|
||||
home_team_id="h",
|
||||
away_team_id="a",
|
||||
home_team_name="Home",
|
||||
away_team_name="Away",
|
||||
match_date_ms=1760000000000,
|
||||
sport="football",
|
||||
league_id="l1",
|
||||
league_name="League",
|
||||
referee_name="Ref",
|
||||
odds_data={"ms_h": 2.2, "ms_d": 3.2, "ms_a": 3.0},
|
||||
home_lineup=["h"] * 11,
|
||||
away_lineup=["a"] * 11,
|
||||
sidelined_data=None,
|
||||
home_goals_avg=1.5,
|
||||
home_conceded_avg=1.2,
|
||||
away_goals_avg=1.2,
|
||||
away_conceded_avg=1.4,
|
||||
home_position=7,
|
||||
away_position=9,
|
||||
lineup_source="confirmed_live",
|
||||
)
|
||||
prediction = FullMatchPrediction(match_id="dq-odds", home_team="Home", away_team="Away")
|
||||
quality = self.orchestrator._compute_data_quality(data)
|
||||
row = {
|
||||
"market": "HT_OU05",
|
||||
"pick": "İY 0.5 Üst",
|
||||
"probability": 0.65,
|
||||
"confidence": 66.0,
|
||||
"odds": 0.0,
|
||||
}
|
||||
|
||||
out = self.orchestrator._decorate_market_row(data, prediction, quality, row)
|
||||
self.assertFalse(out["playable"])
|
||||
self.assertIn("market_odds_missing", out["decision_reasons"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,142 +0,0 @@
|
||||
"""
|
||||
Unit Test for NEW Skip Logic in BetRecommender
|
||||
==============================================
|
||||
Run with: python ai-engine/tests/test_skip_logic.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
# Add paths
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
|
||||
|
||||
from core.calculators.bet_recommender import BetRecommender, RecommendationResult, MarketPredictionDTO
|
||||
from core.calculators.risk_assessor import RiskAnalysis
|
||||
from core.calculators.match_result_calculator import MatchResultPrediction
|
||||
from core.calculators.over_under_calculator import OverUnderPrediction
|
||||
from config.config_loader import get_config
|
||||
|
||||
@dataclass
|
||||
class DummyContext:
|
||||
"""Minimal mock for CalculationContext"""
|
||||
odds_data: dict
|
||||
|
||||
class TestSkipLogic(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Mock config to pass into BetRecommender
|
||||
self.mock_config = {
|
||||
"recommendations.market_weights": {"MS": 1.0, "ÇŞ": 0.9, "BTTS": 0.9, "2.5 Üst/Alt": 0.9},
|
||||
"recommendations.safe_markets": ["ÇŞ", "1.5 Üst/Alt"],
|
||||
"recommendations.market_accuracy": {"MS": 65, "ÇŞ": 75, "BTTS": 60, "2.5 Üst/Alt": 65},
|
||||
"recommendations.baseline_accuracy": 65.0,
|
||||
"recommendations.confidence_threshold": 60,
|
||||
"recommendations.value_confidence_min": 45,
|
||||
"recommendations.value_confidence_max": 60,
|
||||
"recommendations.value_edge_margin": 0.03,
|
||||
"recommendations.value_upgrade_edge": 5.0,
|
||||
"recommendations.risk_safe_boost": 1.2,
|
||||
"recommendations.risk_ms_penalty_high": 0.5,
|
||||
"recommendations.risk_other_penalty": 0.7,
|
||||
"recommendations.risk_ms_penalty_medium": 0.8,
|
||||
}
|
||||
self.recommender = BetRecommender(self.mock_config)
|
||||
|
||||
def _make_risk(self, level="MEDIUM", is_surprise=False):
|
||||
return RiskAnalysis(risk_level=level, is_surprise_risk=is_surprise, risk_score=0.5)
|
||||
|
||||
def _make_ms_pred(self, pick, conf):
|
||||
# pick: "1", "X", "2"
|
||||
probs = {"1": {"ms_home_prob": 0.5, "ms_draw_prob": 0.3, "ms_away_prob": 0.2},
|
||||
"X": {"ms_home_prob": 0.2, "ms_draw_prob": 0.5, "ms_away_prob": 0.3},
|
||||
"2": {"ms_home_prob": 0.2, "ms_draw_prob": 0.3, "ms_away_prob": 0.5}}
|
||||
p = probs.get(pick, probs["1"])
|
||||
return MatchResultPrediction(
|
||||
ms_pick=pick, ms_confidence=conf,
|
||||
dc_pick="1X", dc_confidence=0,
|
||||
dc_1x_prob=0.7, dc_x2_prob=0.7, dc_12_prob=0.7,
|
||||
**p
|
||||
)
|
||||
|
||||
def _make_ou_pred(self):
|
||||
return OverUnderPrediction(
|
||||
ou25_pick="2.5 Üst", ou25_confidence=50.0,
|
||||
over_25_prob=0.55, under_25_prob=0.45,
|
||||
|
||||
btts_pick="Var", btts_confidence=50.0,
|
||||
btts_yes_prob=0.55, btts_no_prob=0.45,
|
||||
|
||||
ou15_pick="1.5 Üst", ou15_confidence=60.0, over_15_prob=0.7, under_15_prob=0.3,
|
||||
ou35_pick="3.5 Alt", ou35_confidence=50.0, over_35_prob=0.3, under_35_prob=0.7
|
||||
)
|
||||
|
||||
def test_low_confidence_should_skip(self):
|
||||
"""Confidence < 45% should be SKIPPED"""
|
||||
ms_pred = self._make_ms_pred(pick="2", conf=40.0)
|
||||
ou_pred = self._make_ou_pred()
|
||||
risk = self._make_risk("MEDIUM")
|
||||
ctx = DummyContext(odds_data={"ms_2": 2.5})
|
||||
|
||||
res = self.recommender.calculate(ctx, ms_pred, ou_pred, risk)
|
||||
|
||||
# Check if MS bet is skipped
|
||||
ms_bet = next((b for b in res.skipped_bets if b.market_type == "MS"), None)
|
||||
self.assertIsNotNone(ms_bet, "MS bet with 40% conf should be skipped!")
|
||||
self.assertTrue(ms_bet.is_skip)
|
||||
|
||||
def test_good_confidence_should_recommend(self):
|
||||
"""Confidence > 60% and Good Odds should be RECOMMENDED"""
|
||||
ms_pred = self._make_ms_pred(pick="1", conf=70.0)
|
||||
ou_pred = self._make_ou_pred()
|
||||
risk = self._make_risk("MEDIUM")
|
||||
# Odds 1.80 for 70% prob = Good Value (Need real odds for MS to pass)
|
||||
ctx = DummyContext(odds_data={"ms_1": 1.80, "ou15_o": 1.50}) # Added ou15 odds
|
||||
|
||||
res = self.recommender.calculate(ctx, ms_pred, ou_pred, risk)
|
||||
|
||||
# Check if ANY bet is recommended (doesn't have to be MS, but usually is)
|
||||
self.assertGreater(len(res.recommended_bets), 0, "At least one bet should be recommended!")
|
||||
# Check that MS bet is NOT skipped
|
||||
ms_bet = next((b for b in res.recommended_bets if b.market_type == "MS"), None)
|
||||
if ms_bet:
|
||||
self.assertFalse(ms_bet.is_skip)
|
||||
|
||||
def test_negative_edge_should_skip(self):
|
||||
"""Even with high confidence, if Odds are too low (Bad Value), SKIP"""
|
||||
ms_pred = self._make_ms_pred(pick="1", conf=70.0) # 70% prob
|
||||
ou_pred = self._make_ou_pred()
|
||||
risk = self._make_risk("MEDIUM")
|
||||
# Odds 1.10 -> Implied 90%. Our prob is 70%. Edge is -20% -> SKIP
|
||||
ctx = DummyContext(odds_data={"ms_1": 1.10})
|
||||
|
||||
res = self.recommender.calculate(ctx, ms_pred, ou_pred, risk)
|
||||
|
||||
ms_bet = next((b for b in res.skipped_bets if b.market_type == "MS"), None)
|
||||
self.assertIsNotNone(ms_bet, "MS bet with terrible odds (Negative Edge) should be skipped!")
|
||||
self.assertTrue(ms_bet.is_skip)
|
||||
|
||||
def test_no_bets_recommendation(self):
|
||||
"""If all bets are low confidence, best_bet should be None"""
|
||||
ms_pred = self._make_ms_pred(pick="1", conf=30.0) # Very low conf
|
||||
ou_pred = self._make_ou_pred()
|
||||
# Reset ALL OU confs to low
|
||||
ou_pred.ou25_confidence = 30.0
|
||||
ou_pred.btts_confidence = 30.0
|
||||
ou_pred.ou15_confidence = 30.0 # This was 60 in setUp, causing the fail!
|
||||
ou_pred.ou35_confidence = 30.0
|
||||
|
||||
risk = self._make_risk("MEDIUM")
|
||||
ctx = DummyContext(odds_data={"ms_1": 2.0})
|
||||
|
||||
res = self.recommender.calculate(ctx, ms_pred, ou_pred, risk)
|
||||
|
||||
self.assertIsNone(res.best_bet, "If everything is skipped, there should be no best_bet.")
|
||||
self.assertEqual(len(res.recommended_bets), 0, "No bets should be recommended!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("🧪 Running Skip Logic Unit Tests...")
|
||||
print("="*50)
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,286 +0,0 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
AI_ENGINE_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(AI_ENGINE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(AI_ENGINE_ROOT))
|
||||
|
||||
from services.v26_shadow_engine import V26ShadowEngine
|
||||
|
||||
|
||||
def _build_prediction():
|
||||
return SimpleNamespace(
|
||||
risk_level="MEDIUM",
|
||||
risk_score=42.0,
|
||||
is_surprise_risk=False,
|
||||
surprise_type="",
|
||||
surprise_score=0.0,
|
||||
surprise_comment="",
|
||||
surprise_reasons=[],
|
||||
risk_warnings=[],
|
||||
team_confidence=71.0,
|
||||
player_confidence=64.0,
|
||||
odds_confidence=75.0,
|
||||
referee_confidence=58.0,
|
||||
predicted_ft_score="2-1",
|
||||
predicted_ht_score="1-0",
|
||||
home_xg=1.72,
|
||||
away_xg=1.08,
|
||||
total_xg=2.8,
|
||||
ft_scores_top5=[
|
||||
{"score": "2-1", "prob": 0.093},
|
||||
{"score": "1-1", "prob": 0.086},
|
||||
],
|
||||
ms_home_prob=0.52,
|
||||
ms_draw_prob=0.24,
|
||||
ms_away_prob=0.24,
|
||||
)
|
||||
|
||||
|
||||
def _build_data(referee_name="Ref A", lineup_source="confirmed_live", league_id="league1"):
|
||||
return SimpleNamespace(
|
||||
match_id="m1",
|
||||
home_team_name="Home",
|
||||
away_team_name="Away",
|
||||
league_id=league_id,
|
||||
league_name="League",
|
||||
match_date_ms=1710000000000,
|
||||
sport="football",
|
||||
home_lineup=["h"] * 11,
|
||||
away_lineup=["a"] * 11,
|
||||
lineup_source=lineup_source,
|
||||
referee_name=referee_name,
|
||||
odds_data={
|
||||
"ms_h": 2.1,
|
||||
"ms_d": 3.4,
|
||||
"ms_a": 3.7,
|
||||
"dc_1x": 1.28,
|
||||
"dc_x2": 1.68,
|
||||
"dc_12": 1.34,
|
||||
"ou15_o": 1.24,
|
||||
"ou15_u": 4.1,
|
||||
"ou25_o": 1.77,
|
||||
"ou25_u": 2.05,
|
||||
"ou35_o": 2.95,
|
||||
"ou35_u": 1.4,
|
||||
"btts_y": 1.74,
|
||||
"btts_n": 2.04,
|
||||
"ht_h": 2.72,
|
||||
"ht_d": 2.05,
|
||||
"ht_a": 4.8,
|
||||
"ht_ou05_o": 1.38,
|
||||
"ht_ou05_u": 2.85,
|
||||
"ht_ou15_o": 2.48,
|
||||
"ht_ou15_u": 1.48,
|
||||
"oe_odd": 1.92,
|
||||
"oe_even": 1.9,
|
||||
"cards_o": 1.98,
|
||||
"cards_u": 1.84,
|
||||
"hcap_h": 3.3,
|
||||
"hcap_d": 3.7,
|
||||
"hcap_a": 1.93,
|
||||
"htft_11": 3.8,
|
||||
"htft_1x": 5.1,
|
||||
"htft_12": 16.5,
|
||||
"htft_x1": 5.6,
|
||||
"htft_xx": 4.8,
|
||||
"htft_x2": 7.4,
|
||||
"htft_21": 22.0,
|
||||
"htft_2x": 12.0,
|
||||
"htft_22": 6.2,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class V26ShadowEngineTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = V26ShadowEngine()
|
||||
self.engine.top_league_ids = {"top1"}
|
||||
self.prediction = _build_prediction()
|
||||
self.quality = {
|
||||
"label": "HIGH",
|
||||
"score": 0.88,
|
||||
"home_lineup_count": 11,
|
||||
"away_lineup_count": 11,
|
||||
"lineup_source": "confirmed_live",
|
||||
"flags": [],
|
||||
}
|
||||
self.v25_signal = {
|
||||
"MS": {"probs": {"1": 0.46, "X": 0.27, "2": 0.27}},
|
||||
"HT": {"probs": {"1": 0.39, "X": 0.41, "2": 0.20}},
|
||||
"HTFT": {"probs": {"1/1": 0.22, "X/X": 0.18, "2/2": 0.14}},
|
||||
"HCAP": {"probs": {"1": 0.21, "X": 0.19, "2": 0.60}},
|
||||
"CARDS": {"probs": {"Under": 0.53, "Over": 0.47}},
|
||||
}
|
||||
|
||||
def test_build_package_exposes_shadow_metadata(self):
|
||||
package = self.engine.build_package(
|
||||
data=_build_data(),
|
||||
prediction=self.prediction,
|
||||
v25_signal=self.v25_signal,
|
||||
quality=self.quality,
|
||||
)
|
||||
|
||||
self.assertEqual(package["model_version"], "v26.shadow.2")
|
||||
self.assertIn("calibration_version", package)
|
||||
self.assertIn("decision_trace_id", package)
|
||||
self.assertIn("market_reliability", package)
|
||||
self.assertTrue(package["bet_summary"])
|
||||
|
||||
def test_cards_defaults_to_pass_when_referee_missing(self):
|
||||
package = self.engine.build_package(
|
||||
data=_build_data(referee_name=None),
|
||||
prediction=self.prediction,
|
||||
v25_signal=self.v25_signal,
|
||||
quality=self.quality,
|
||||
)
|
||||
|
||||
cards = next(item for item in package["bet_summary"] if item["market"] == "CARDS")
|
||||
self.assertFalse(cards["playable"])
|
||||
self.assertEqual(cards["bet_grade"], "PASS")
|
||||
|
||||
def test_select_main_pick_prioritizes_ms_when_playable(self):
|
||||
rows = [
|
||||
{
|
||||
"market": "OU25",
|
||||
"pick": "2.5 Üst",
|
||||
"playable": True,
|
||||
"selection_score": 86.0,
|
||||
"play_score": 83.0,
|
||||
"edge": 0.15,
|
||||
"calibrated_confidence": 72.0,
|
||||
},
|
||||
{
|
||||
"market": "MS",
|
||||
"pick": "1",
|
||||
"playable": True,
|
||||
"selection_score": 81.0,
|
||||
"play_score": 82.0,
|
||||
"edge": 0.08,
|
||||
"calibrated_confidence": 64.0,
|
||||
},
|
||||
]
|
||||
|
||||
main_pick = self.engine._select_main_pick(rows)
|
||||
|
||||
self.assertIsNotNone(main_pick)
|
||||
self.assertEqual(main_pick["market"], "MS")
|
||||
self.assertEqual(main_pick["pick_reason"], "ms_priority_market")
|
||||
|
||||
def test_build_package_exposes_surprise_pick_when_reversal_is_hot(self):
|
||||
prediction = _build_prediction()
|
||||
prediction.is_surprise_risk = True
|
||||
prediction.surprise_score = 82.0
|
||||
prediction.surprise_type = "favorite_reversal"
|
||||
v25_signal = dict(self.v25_signal)
|
||||
v25_signal["HTFT"] = {
|
||||
"probs": {
|
||||
"1/2": 0.24,
|
||||
"X/2": 0.14,
|
||||
"1/1": 0.12,
|
||||
"X/X": 0.10,
|
||||
}
|
||||
}
|
||||
package = self.engine.build_package(
|
||||
data=_build_data(),
|
||||
prediction=prediction,
|
||||
v25_signal=v25_signal,
|
||||
quality=self.quality,
|
||||
)
|
||||
|
||||
self.assertIn("surprise_hunter", package)
|
||||
self.assertIn("surprise_pick", package)
|
||||
self.assertTrue(package["surprise_hunter"]["playable"])
|
||||
self.assertEqual(package["surprise_pick"]["market"], "HTFT")
|
||||
self.assertEqual(package["surprise_pick"]["strategy_channel"], "surprise_sidecar")
|
||||
self.assertEqual(package["surprise_hunter"]["strategy_channel"], "surprise_sidecar")
|
||||
self.assertGreaterEqual(package["surprise_pick"]["surprise_score"], 66.0)
|
||||
self.assertEqual(package["main_pick"]["strategy_channel"], "standard")
|
||||
self.assertNotEqual(package["main_pick"].get("strategy_channel"), package["surprise_pick"].get("strategy_channel"))
|
||||
self.assertNotEqual(package["main_pick"].get("pick_reason"), "favorite_reversal_signal")
|
||||
|
||||
def test_top_league_policy_suppresses_early_and_extra_goal_markets(self):
|
||||
package = self.engine.build_package(
|
||||
data=_build_data(league_id="top1"),
|
||||
prediction=self.prediction,
|
||||
v25_signal=self.v25_signal,
|
||||
quality=self.quality,
|
||||
)
|
||||
|
||||
summary = {item["market"]: item for item in package["bet_summary"]}
|
||||
self.assertFalse(summary["HT_OU05"]["playable"])
|
||||
self.assertTrue(
|
||||
"top_league_early_market_suppressed" in summary["HT_OU05"]["reasons"]
|
||||
or "top_league_ht_ou05_over_disabled" in summary["HT_OU05"]["reasons"]
|
||||
)
|
||||
|
||||
playable_goal_cluster = [
|
||||
item for item in package["bet_summary"]
|
||||
if item["market"] in {"OU15", "OU25", "OU35", "BTTS"} and item["playable"]
|
||||
]
|
||||
self.assertLessEqual(len(playable_goal_cluster), 1)
|
||||
|
||||
def test_scoreline_consistency_blocks_conflicting_markets(self):
|
||||
rows = [
|
||||
{
|
||||
"market": "MS",
|
||||
"raw_pick": "1",
|
||||
"pick": "1",
|
||||
"playable": True,
|
||||
"bet_grade": "A",
|
||||
"stake_units": 1.0,
|
||||
"decision_reasons": [],
|
||||
},
|
||||
{
|
||||
"market": "BTTS",
|
||||
"raw_pick": "Yes",
|
||||
"pick": "KG Var",
|
||||
"playable": True,
|
||||
"bet_grade": "A",
|
||||
"stake_units": 1.0,
|
||||
"decision_reasons": [],
|
||||
},
|
||||
{
|
||||
"market": "OU25",
|
||||
"raw_pick": "Over",
|
||||
"pick": "2.5 Üst",
|
||||
"playable": True,
|
||||
"bet_grade": "A",
|
||||
"stake_units": 1.0,
|
||||
"decision_reasons": [],
|
||||
},
|
||||
{
|
||||
"market": "OU25",
|
||||
"raw_pick": "Under",
|
||||
"pick": "2.5 Alt",
|
||||
"playable": True,
|
||||
"bet_grade": "A",
|
||||
"stake_units": 1.0,
|
||||
"decision_reasons": [],
|
||||
},
|
||||
]
|
||||
prediction = _build_prediction()
|
||||
prediction.predicted_ft_score = "1-0"
|
||||
prediction.predicted_ht_score = "1-0"
|
||||
|
||||
controlled = self.engine._apply_scoreline_consistency_controls(rows, prediction)
|
||||
by_market_pick = {(row["market"], row["raw_pick"]): row for row in controlled}
|
||||
|
||||
self.assertTrue(by_market_pick[("MS", "1")]["playable"])
|
||||
self.assertIn(
|
||||
"scoreline_scenario_aligned",
|
||||
by_market_pick[("MS", "1")]["decision_reasons"],
|
||||
)
|
||||
self.assertFalse(by_market_pick[("BTTS", "Yes")]["playable"])
|
||||
self.assertFalse(by_market_pick[("OU25", "Over")]["playable"])
|
||||
self.assertTrue(by_market_pick[("OU25", "Under")]["playable"])
|
||||
self.assertIn(
|
||||
"scoreline_scenario_conflict",
|
||||
by_market_pick[("BTTS", "Yes")]["decision_reasons"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+63
-50
@@ -1,6 +1,6 @@
|
||||
# Social Poster Modülü — Otomatik Sosyal Medya Paylaşım Sistemi
|
||||
|
||||
Son güncelleme: 1 Mart 2026
|
||||
Son güncelleme: 5 Mayıs 2026
|
||||
|
||||
---
|
||||
|
||||
@@ -13,11 +13,11 @@ Top liglerdeki maçların AI tahminlerini **otomatik olarak görselleştirip** I
|
||||
## 2. Mimari Akış
|
||||
|
||||
```
|
||||
Cron (*/10 dk) → LiveMatch sorgusu (top_leagues.json filtresi)
|
||||
Cron (*/15 dk) → LiveMatch sorgusu (top_leagues.json filtresi)
|
||||
→ AI Engine V20+ POST /v20plus/analyze/{match_id}
|
||||
→ PredictionCardDto oluştur
|
||||
→ Node Canvas ile 1080x1920 PNG render
|
||||
→ Gemini ile Türkçe caption üret
|
||||
→ Node Canvas ile futbol/basketbol 1080x1080 JPEG render
|
||||
→ Ollama/Gemini ile Türkçe SEO uyumlu caption üret
|
||||
→ Twitter / Facebook / Instagram API'ye paylaş
|
||||
```
|
||||
|
||||
@@ -44,41 +44,46 @@ src/modules/social-poster/
|
||||
|
||||
### 4.1 SocialPosterService
|
||||
|
||||
**Cron:** Her 10 dakikada bir çalışır. 25–40 dakika içinde başlayacak maçları `top_leagues.json` filtresiyle bulur.
|
||||
**Cron:** Her 15 dakikada bir çalışır. Varsayılan olarak 25–45 dakika içinde başlayacak futbol ve basketbol maçlarını `top_leagues.json` filtresiyle bulur.
|
||||
|
||||
**Tekrar paylaşım koruması:** Başarılı platform paylaşımı alan maç ID'leri `storage/social-poster-posted.json` içinde son 500 kayıt olarak tutulur. Servis restart sonrası aynı maç tekrar paylaşılmaz.
|
||||
|
||||
**Pipeline:** `predictAndPost(match)` → Tahmin al → Görsel üret → Caption üret → Paylaş
|
||||
|
||||
**AI Engine İsteği:**
|
||||
|
||||
```typescript
|
||||
// POST — GET değil! AI Engine v20plus POST bekler.
|
||||
axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, { timeout: 30000 })
|
||||
axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, {
|
||||
timeout: 30000,
|
||||
});
|
||||
```
|
||||
|
||||
**Veri Haritalandırma (V20+ → CardDto):**
|
||||
|
||||
| V20+ Response Alanı | CardDto Alanı |
|
||||
|---|---|
|
||||
| `score_prediction.ht` | `htScore` (ör: "1-1") |
|
||||
| `score_prediction.ft` | `ftScore` (ör: "2-1") |
|
||||
| `main_pick.confidence` | `scoreConfidence` (ör: 65) |
|
||||
| V20+ Response Alanı | CardDto Alanı |
|
||||
| ----------------------- | ---------------------------------------------- |
|
||||
| `score_prediction.ht` | `htScore` (ör: "1-1") |
|
||||
| `score_prediction.ft` | `ftScore` (ör: "2-1") |
|
||||
| `main_pick.confidence` | `scoreConfidence` (ör: 65) |
|
||||
| `bet_summary[]` (array) | `topPicks[]` (ilk 3, confidence'a göre sıralı) |
|
||||
| `risk.level` | `riskLevel` (LOW/MEDIUM/HIGH/EXTREME) |
|
||||
| `match_info.home_team` | `homeTeam` (fallback) |
|
||||
| `risk.level` | `riskLevel` (LOW/MEDIUM/HIGH/EXTREME) |
|
||||
| `match_info.home_team` | `homeTeam` (fallback) |
|
||||
|
||||
**Bet Summary Market Kodları:**
|
||||
|
||||
| Kod | Türkçe | English |
|
||||
|---|---|---|
|
||||
| MS | Maç Sonucu | Match Result |
|
||||
| OU15 | Üst 1.5 Gol | Over 1.5 |
|
||||
| OU25 | Üst 2.5 Gol | Over 2.5 |
|
||||
| OU35 | Üst 3.5 Gol | Over 3.5 |
|
||||
| BTTS | Karşılıklı Gol | Both Teams Score |
|
||||
| DC | Çifte Şans | Double Chance |
|
||||
| HT | İlk Yarı Sonucu | Half Time Result |
|
||||
| HT_OU05 | İY 0.5 Üst/Alt | HT Over/Under 0.5 |
|
||||
| OE | Tek/Çift | Odd/Even |
|
||||
| HTFT | İY/MS | HT/FT |
|
||||
| Kod | Türkçe | English |
|
||||
| ------- | --------------- | ----------------- |
|
||||
| MS | Maç Sonucu | Match Result |
|
||||
| OU15 | Üst 1.5 Gol | Over 1.5 |
|
||||
| OU25 | Üst 2.5 Gol | Over 2.5 |
|
||||
| OU35 | Üst 3.5 Gol | Over 3.5 |
|
||||
| BTTS | Karşılıklı Gol | Both Teams Score |
|
||||
| DC | Çifte Şans | Double Chance |
|
||||
| HT | İlk Yarı Sonucu | Half Time Result |
|
||||
| HT_OU05 | İY 0.5 Üst/Alt | HT Over/Under 0.5 |
|
||||
| OE | Tek/Çift | Odd/Even |
|
||||
| HTFT | İY/MS | HT/FT |
|
||||
|
||||
### 4.2 ImageRendererService
|
||||
|
||||
@@ -89,6 +94,7 @@ axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, { timeout: 30000 }
|
||||
**Boyut:** 1080×1920 px (Instagram Story / Reels uyumlu)
|
||||
|
||||
**Özellikler:**
|
||||
|
||||
- Koyu gradient arka plan (#0a0e27 → #1a1040 → #0d1b2a)
|
||||
- Lig adı + tarih başlık satırı
|
||||
- Takım logoları (200×200px) — `public/uploads/teams/` altından okunur
|
||||
@@ -100,6 +106,7 @@ axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, { timeout: 30000 }
|
||||
- Alt bilgi: "⚡ AI Powered by SuggestBet"
|
||||
|
||||
**Logo Çözümleme:**
|
||||
|
||||
```
|
||||
1. Yerel dosya varsa → public/uploads/teams/xxx.png oku
|
||||
2. URL http ile başlıyorsa → HTTP ile indir
|
||||
@@ -118,10 +125,10 @@ Gemini API kullanarak maç verisi JSON'ından Türkçe post metni üretir.
|
||||
|
||||
## 5. API Endpointleri
|
||||
|
||||
| Method | Path | Auth | Açıklama |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/social-poster/preview/:matchId` | @Public | Sadece görsel üret + caption üret (paylaşma) |
|
||||
| POST | `/api/social-poster/post/:matchId` | @Public | Görsel üret + caption üret + tüm platformlara paylaş |
|
||||
| Method | Path | Auth | Açıklama |
|
||||
| ------ | ------------------------------------- | ------- | ---------------------------------------------------- |
|
||||
| GET | `/api/social-poster/preview/:matchId` | @Public | Sadece görsel üret + caption üret (paylaşma) |
|
||||
| POST | `/api/social-poster/post/:matchId` | @Public | Görsel üret + caption üret + tüm platformlara paylaş |
|
||||
|
||||
> **Not:** Test endpointleri `@Public()` dekoratörüyle auth bypass edilmiştir. Production'da kaldırılmalı veya admin-only yapılmalıdır.
|
||||
|
||||
@@ -129,14 +136,20 @@ Gemini API kullanarak maç verisi JSON'ından Türkçe post metni üretir.
|
||||
|
||||
## 6. Environment Değişkenleri
|
||||
|
||||
| Key | Zorunlu | Varsayılan | Açıklama |
|
||||
|---|---|---|---|
|
||||
| `AI_ENGINE_URL` | ✅ | `http://localhost:8000` | AI Engine base URL |
|
||||
| `APP_BASE_URL` | ✅ | `http://localhost:3000` | Logo URL çözümleme için |
|
||||
| `SOCIAL_POSTER_ENABLED` | ❌ | `false` | Cron job'ı aktif/pasif |
|
||||
| `GOOGLE_API_KEY` | ❌ | — | Gemini caption için |
|
||||
| Twitter API keys | ❌ | — | Twitter paylaşım için |
|
||||
| Meta API keys | ❌ | — | FB/IG paylaşım için |
|
||||
| Key | Zorunlu | Varsayılan | Açıklama |
|
||||
| --------------------------------------------- | ------- | ------------------------ | -------------------------------------------------------------------- |
|
||||
| `AI_ENGINE_URL` | ✅ | `http://localhost:8000` | AI Engine base URL |
|
||||
| `APP_BASE_URL` | ✅ | `http://localhost:3000` | Meta'nın çekebileceği public görsel URL'i ve logo URL çözümleme için |
|
||||
| `SOCIAL_POSTER_ENABLED` | ❌ | `false` | Cron job'ı aktif/pasif |
|
||||
| `SOCIAL_POSTER_SPORTS` | ❌ | `football,basketball` | Otomatik paylaşılacak sporlar |
|
||||
| `SOCIAL_POSTER_WINDOW_MIN` | ❌ | `25` | Başlama zaman penceresi alt sınırı (dakika) |
|
||||
| `SOCIAL_POSTER_WINDOW_MAX` | ❌ | `45` | Başlama zaman penceresi üst sınırı (dakika) |
|
||||
| `OLLAMA_BASE_URL` | ❌ | `http://localhost:11434` | Lokal LLM endpoint'i |
|
||||
| `OLLAMA_MODEL` / `SOCIAL_POSTER_OLLAMA_MODEL` | ❌ | — | Caption üretiminde kullanılacak lokal model |
|
||||
| `GOOGLE_API_KEY` | ❌ | — | Gemini caption için |
|
||||
| Twitter API keys | ❌ | — | X medya upload + `/2/tweets` paylaşımı için OAuth 1.0a user context |
|
||||
| `META_GRAPH_API_VERSION` | ❌ | `v25.0` | Meta Graph API sürümü |
|
||||
| Meta API keys | ❌ | — | FB/IG paylaşım için |
|
||||
|
||||
---
|
||||
|
||||
@@ -144,9 +157,9 @@ Gemini API kullanarak maç verisi JSON'ından Türkçe post metni üretir.
|
||||
|
||||
```json
|
||||
{
|
||||
"canvas": "^2.x", // Node Canvas — görsel üretimi
|
||||
"axios": "^1.x", // HTTP istekleri (AI Engine + logo indirme)
|
||||
"@nestjs/schedule": "*" // Cron job desteği
|
||||
"canvas": "^2.x", // Node Canvas — görsel üretimi
|
||||
"axios": "^1.x", // HTTP istekleri (AI Engine + logo indirme)
|
||||
"@nestjs/schedule": "*" // Cron job desteği
|
||||
}
|
||||
```
|
||||
|
||||
@@ -165,10 +178,10 @@ RUN apk add --no-cache cairo-dev pango-dev jpeg-dev giflib-dev librsvg-dev
|
||||
|
||||
### Port Yönetimi
|
||||
|
||||
| Servis | Port |
|
||||
|---|---|
|
||||
| NestJS Backend | 3000 (production: 150X) |
|
||||
| AI Engine | 8000 (dev: 8005 — Windows port kısıtlaması) |
|
||||
| Servis | Port |
|
||||
| -------------- | ------------------------------------------- |
|
||||
| NestJS Backend | 3000 (production: 150X) |
|
||||
| AI Engine | 8000 (dev: 8005 — Windows port kısıtlaması) |
|
||||
|
||||
### Dosya Sistemi
|
||||
|
||||
@@ -182,9 +195,9 @@ public/
|
||||
|
||||
## 9. Bilinen Sorunlar & Çözümler
|
||||
|
||||
| Sorun | Sebep | Çözüm |
|
||||
|---|---|---|
|
||||
| `WinError 10013` port erişim hatası | Windows Hyper-V port rezervasyonu | Farklı port kullan (8005) |
|
||||
| `Invalid prisma.liveMatch.findUnique()` | Prisma client eskimiş | `npx prisma generate` çalıştır |
|
||||
| `405 Method Not Allowed` AI Engine | GET yerine POST gerekiyor | `axios.post()` kullan |
|
||||
| Logolar görünmüyor (lokal dev) | Logo dosyaları sunucuda, lokalde yok | Deploy'da çalışır, lokal'de graceful skip |
|
||||
| Sorun | Sebep | Çözüm |
|
||||
| --------------------------------------- | ------------------------------------ | ----------------------------------------- |
|
||||
| `WinError 10013` port erişim hatası | Windows Hyper-V port rezervasyonu | Farklı port kullan (8005) |
|
||||
| `Invalid prisma.liveMatch.findUnique()` | Prisma client eskimiş | `npx prisma generate` çalıştır |
|
||||
| `405 Method Not Allowed` AI Engine | GET yerine POST gerekiyor | `axios.post()` kullan |
|
||||
| Logolar görünmüyor (lokal dev) | Logo dosyaları sunucuda, lokalde yok | Deploy'da çalışır, lokal'de graceful skip |
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,370 @@
|
||||
# V28-Pro-Max Model Architecture Documentation
|
||||
|
||||
> **Model Version:** `v28-pro-max`
|
||||
> **Engine File:** `ai-engine/services/single_match_orchestrator.py` (4656 satır)
|
||||
> **Son Güncelleme:** 2026-04-24
|
||||
|
||||
---
|
||||
|
||||
## 1. Genel Bakış
|
||||
|
||||
V28-Pro-Max, üç bağımsız tahmin katmanını (V25, V27, V28) tek bir orchestrator içinde birleştiren **üçlü hibrit AI tahmin motorudur**. Her maç için 13+ bahis pazarını analiz eder, olasılık hesaplar, risk değerlendirir ve "Value Bet" tespiti yapar.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ SingleMatchOrchestrator │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │
|
||||
│ │ V25 │ │ V27 │ │ V28 │ │
|
||||
│ │ Ensemble │ │ Dual-Eng │ │ Odds-Band │ │
|
||||
│ │ (XGB+LGB)│ │ Divergnce│ │ Historical │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └───────┬────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └──────────────┼────────────────┘ │
|
||||
│ ▼ │
|
||||
│ FullMatchPrediction │
|
||||
│ │ │
|
||||
│ ┌───────────┼───────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ Market Rows Risk Calc Triple Value │
|
||||
│ │ │ │ │
|
||||
│ └───────────┼───────────┘ │
|
||||
│ ▼ │
|
||||
│ _build_prediction_package() │
|
||||
│ → JSON Response (v28-pro-max) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Katman Detayları
|
||||
|
||||
### 2.1 V25 — Ensemble ML Katmanı
|
||||
**Dosya:** `ai-engine/models/v25_ensemble.py`
|
||||
|
||||
- **Algoritmalar:** XGBoost + LightGBM ensemble
|
||||
- **Girdi:** Pre-match feature vektörü (form, elo, odds, kadro, hakem vb.)
|
||||
- **Çıktı:** Tüm pazarlar için olasılık dağılımları + confidence skorları
|
||||
- **Özellik:** Odds-aware (bahis oranlarını feature olarak kullanır)
|
||||
- **Target leakage koruması:** Maç sonucu bilgisi asla feature olarak kullanılmaz
|
||||
|
||||
```python
|
||||
# V25 çağrılma noktası (orchestrator L310-315)
|
||||
v25_signal = v25_predictor.predict(features)
|
||||
# Çıktı: {MS: {home: 0.45, draw: 0.28, away: 0.27}, OU25: {...}, BTTS: {...}, ...}
|
||||
```
|
||||
|
||||
### 2.2 V27 — Dual-Engine Divergence Katmanı
|
||||
**Dosya:** `ai-engine/models/v27_predictor.py`
|
||||
|
||||
- **Amaç:** Odds-FREE temel olasılıkları hesaplar (sadece form/elo/kadro)
|
||||
- **Mekanizma:** V25 (odds-aware) vs V27 (odds-free) karşılaştırması
|
||||
- **Divergence Tespiti:** İki motor arasındaki fark → bahisçinin fiyatlandırma hatasını tespit eder
|
||||
- **Çıktı:** `ms_divergence`, `ou25_divergence`, `is_value` sinyalleri
|
||||
|
||||
```python
|
||||
# Divergence hesaplama (orchestrator L830-863)
|
||||
ms_divergence = {
|
||||
"home": v25_home_prob - v27_home_prob, # Pozitif = V25 bahisçiyle hemfikir
|
||||
"away": v25_away_prob - v27_away_prob, # Negatif = Model bahisçiden farklı düşünüyor
|
||||
}
|
||||
ms_value = {
|
||||
"home": {"is_value": v27_home > implied_home and abs(div) > 0.05},
|
||||
"away": {"is_value": v27_away > implied_away and abs(div) > 0.05},
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 V28 — Odds-Band Historical Performance Katmanı
|
||||
**Dosya:** `ai-engine/features/odds_band_analyzer.py`
|
||||
|
||||
- **Amaç:** "Bu oran bandında tarihsel olarak ne oldu?" sorusunu yanıtlar
|
||||
- **Mekanizma:** Maçın mevcut oranını bir banda yerleştirir (ör: MS Home 1.70-1.90), ardından veritabanındaki aynı banddaki geçmiş maçları sorgular
|
||||
- **Sorgu:** PostgreSQL üzerinden takım-spesifik tarihsel performans
|
||||
|
||||
```python
|
||||
# OddsBandAnalyzer.compute_all() çıktısı — 18 pazar için band metrikleri:
|
||||
{
|
||||
"home_band_ms_win_rate": 0.62, # Ev sahibi bu oran bandında %62 kazanmış
|
||||
"home_band_ms_sample": 34, # 34 maçlık örneklem
|
||||
"band_ou25_over_rate": 0.58, # Bu banddaki maçların %58'i 2.5 üst
|
||||
"band_btts_yes_rate": 0.51, # KG Var oranı
|
||||
"band_htft_11_rate": 0.28, # İY/MS 1/1 oranı
|
||||
"band_cards_referee_avg": 4.2, # Hakem kart ortalaması
|
||||
# ... toplam 60+ feature
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Analiz Edilen Bahis Pazarları (13+)
|
||||
|
||||
| # | Pazar | Kod | Olasılık Alanları | Odds Anahtarları |
|
||||
|---|-------|-----|-------------------|------------------|
|
||||
| 1 | Maç Sonucu | `MS` | home/draw/away | ms_h, ms_d, ms_a |
|
||||
| 2 | Çifte Şans | `DC` | 1X/X2/12 | dc_1x, dc_x2, dc_12 |
|
||||
| 3 | Üst/Alt 1.5 | `OU15` | over/under | ou15_o, ou15_u |
|
||||
| 4 | Üst/Alt 2.5 | `OU25` | over/under | ou25_o, ou25_u |
|
||||
| 5 | Üst/Alt 3.5 | `OU35` | over/under | ou35_o, ou35_u |
|
||||
| 6 | Karşılıklı Gol | `BTTS` | yes/no | btts_y, btts_n |
|
||||
| 7 | İlk Yarı Sonucu | `HT` | 1/X/2 | ht_h, ht_d, ht_a |
|
||||
| 8 | İY/MS (9 kombo) | `HTFT` | 1/1, 1/X, 1/2, X/1, X/X, X/2, 2/1, 2/X, 2/2 | htft_11..htft_22 |
|
||||
| 9 | Tek/Çift | `OE` | odd/even | oe_odd, oe_even |
|
||||
| 10 | İY Üst/Alt 0.5 | `HT_OU05` | over/under | ht_ou05_o, ht_ou05_u |
|
||||
| 11 | İY Üst/Alt 1.5 | `HT_OU15` | over/under | ht_ou15_o, ht_ou15_u |
|
||||
| 12 | Kartlar | `CARDS` | over/under | cards_o, cards_u |
|
||||
| 13 | Handikap | `HCAP` | 1/X/2 | hcap_h, hcap_d, hcap_a |
|
||||
|
||||
---
|
||||
|
||||
## 4. Triple Value Detection (V28 Ana Yeniliği)
|
||||
|
||||
V28'in en kritik özelliği: **3 bağımsız kaynağı çapraz kontrol ederek "gerçek değer" tespiti yapması.**
|
||||
|
||||
```
|
||||
Triple Value = V27 Divergence + V28 Band Rate + Odds Implied Probability
|
||||
|
||||
Koşullar (hepsi sağlanmalı):
|
||||
1. V27 olasılığı > bahisçi implied olasılığı (v27_confirms)
|
||||
2. Band tarihsel oranı > implied olasılık (band_confirms)
|
||||
3. Kombine edge > %5 (edge > 0.05)
|
||||
4. Band örneklem >= 8 maç (band_sample >= 8)
|
||||
|
||||
→ Tüm koşullar sağlanırsa: is_value = True
|
||||
```
|
||||
|
||||
**Örnek:**
|
||||
```
|
||||
Galatasaray vs Beşiktaş — MS Home (1.85 oran)
|
||||
├── Implied Prob: 1/1.85 = 0.54 (%54)
|
||||
├── V27 (odds-free): 0.61 (%61) → ✅ V27 confirms (0.61 > 0.54)
|
||||
├── V28 Band Rate: 0.62 (%62, 34 maç) → ✅ Band confirms (0.62 > 0.54)
|
||||
├── Combined Prob: (0.61 + 0.62) / 2 = 0.615
|
||||
├── Edge: 0.615 - 0.54 = 0.075 (%7.5) → ✅ Edge > 5%
|
||||
└── is_value = TRUE → "Bu bahis değerli!"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Market Row Dekorasyon Pipeline'ı
|
||||
|
||||
Her pazar aşağıdaki pipeline'dan geçer:
|
||||
|
||||
```
|
||||
_build_market_rows() → Ham market row'ları oluştur (13 pazar)
|
||||
↓
|
||||
_apply_market_consistency() → Pazarlar arası tutarlılık kontrolü
|
||||
↓
|
||||
_decorate_market_row() → Her row'a playability, grading, staking ekle
|
||||
↓
|
||||
Sort by (playable, play_score) → En iyi pick'ler başa gelir
|
||||
```
|
||||
|
||||
### 5.1 Decorate Market Row — Quant Hybrid Sistemi
|
||||
|
||||
Her market row şu metriklerle dekore edilir:
|
||||
|
||||
| Metrik | Formül | Açıklama |
|
||||
|--------|--------|----------|
|
||||
| `calibrated_confidence` | `raw_conf × market_calibration` | Kalibre edilmiş güven |
|
||||
| `ev_edge` | `(prob × odds) - 1.0` | Expected Value edge |
|
||||
| `simple_edge` | `prob - (1/odds)` | Basit olasılık farkı |
|
||||
| `play_score` | `cal_conf + (edge × 100 × edge_mult) - penalties` | Oynanabilirlik skoru |
|
||||
| `stake_units` | Quarter-Kelly Criterion | Önerilen bahis miktarı |
|
||||
| `bet_grade` | A/B/C/PASS | EV edge bazlı not |
|
||||
|
||||
### 5.2 Playability Gates (Güvenlik Kapıları)
|
||||
|
||||
Bir market row'un "playable" olması için tüm kapılardan geçmesi gerekir:
|
||||
|
||||
1. **Confidence Gate:** `calibrated_conf >= min_conf` (pazar bazlı eşik)
|
||||
2. **Odds Gate:** Odds-required pazarlarda `odds > 1.01`
|
||||
3. **Risk-Quality Gate:** HIGH/EXTREME risk + LOW kalite → BLOK
|
||||
4. **Negative Edge Gate:** `simple_edge < neg_threshold` → BLOK
|
||||
5. **EV Edge Gate:** `ev_edge < min_edge` → BLOK
|
||||
6. **Play Score Gate:** `play_score < min_play_score` → BLOK
|
||||
|
||||
### 5.3 Kelly Criterion Staking
|
||||
|
||||
```python
|
||||
# Quarter-Kelly (¼ Kelly, 10-unit bankroll)
|
||||
f* = ((b × p) - q) / b # Full Kelly
|
||||
stake = f* × 0.25 × 10 # Quarter Kelly × bankroll
|
||||
stake = min(stake, 3.0) # Cap: max 3 unit
|
||||
stake = max(stake, 0.25) # Floor: min 0.25 unit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Guaranteed Pick Logic (V32 Calibration-Aware)
|
||||
|
||||
Ana pick seçimi 4 öncelik sırasıyla yapılır:
|
||||
|
||||
```
|
||||
Priority 1: HIGH_ACCURACY markets (DC, OU15, HT_OU05)
|
||||
+ Odds >= 1.30 + Confidence >= 44%
|
||||
→ is_guaranteed = True, reason = "high_accuracy_market"
|
||||
|
||||
Priority 2: Any playable + Odds >= 1.30 + Conf >= 44%
|
||||
→ is_guaranteed = True, reason = "confidence_threshold_met"
|
||||
|
||||
Priority 3: Any playable + Odds >= 1.30
|
||||
→ is_guaranteed = False, reason = "odds_only_fallback"
|
||||
|
||||
Priority 4: Best non-playable (last resort)
|
||||
→ is_guaranteed = False, reason = "last_resort"
|
||||
```
|
||||
|
||||
**Value Pick:** `main_pick`'ten farklı, odds >= 1.60, confidence >= %40 olan en iyi alternatif.
|
||||
|
||||
**Aggressive Pick:** HT/FT reversal senaryoları (1/2, 2/1, X/1, X/2) arasından en yüksek olasılıklı.
|
||||
|
||||
---
|
||||
|
||||
## 7. Risk Assessment Sistemi
|
||||
|
||||
```python
|
||||
risk_score = 100 - max_market_conf + lineup_penalty + referee_penalty + parity_penalty
|
||||
|
||||
# Penalty'ler:
|
||||
lineup_penalty = 12.0 (kadro yok) | 7.0 (probable_xi) | 0.0 (confirmed)
|
||||
referee_penalty = 6.0 (hakem yok) | 0.0
|
||||
parity_penalty = 8.0 (|ms_edge| < 0.08) | 0.0
|
||||
|
||||
# Risk seviyeleri:
|
||||
EXTREME: score >= 78
|
||||
HIGH: score >= 62
|
||||
MEDIUM: score >= 40
|
||||
LOW: score < 40
|
||||
```
|
||||
|
||||
### Surprise Risk Tespiti
|
||||
- `is_surprise_risk = True` → Risk HIGH/EXTREME VEYA draw_prob >= %30
|
||||
- `surprise_type`: `balanced_match_risk` veya `draw_pressure`
|
||||
|
||||
---
|
||||
|
||||
## 8. xG ve Skor Tahmini
|
||||
|
||||
```python
|
||||
base_home_xg = (home_goals_avg + away_xga) / 2
|
||||
base_away_xg = (away_goals_avg + home_xga) / 2
|
||||
|
||||
# MS edge ve BTTS etkisiyle düzeltme:
|
||||
home_xg = base_home_xg + (ms_edge × 0.55) + (btts_prob - 0.5) × 0.18
|
||||
away_xg = base_away_xg - (ms_edge × 0.55) + (btts_prob - 0.5) × 0.18
|
||||
|
||||
# Liga ortalamasıyla ölçekleme:
|
||||
total_target = league_avg_goals × 0.55 + team_avgs × 0.45 + ou25_signal × 1.15
|
||||
scale = total_target / (home_xg + away_xg)
|
||||
final_home_xg = home_xg × scale
|
||||
final_away_xg = away_xg × scale
|
||||
|
||||
# Skor tahmini:
|
||||
FT = round(home_xg) - round(away_xg)
|
||||
HT = round(home_xg × 0.45) - round(away_xg × 0.45)
|
||||
Top5 = Poisson dağılımı ile en olası 5 skor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Data Quality Skoru
|
||||
|
||||
```python
|
||||
quality_score = odds_score × 0.35 + lineup_score × 0.35 + ref_score × 0.15 + form_score × 0.15
|
||||
|
||||
# Etiketleme:
|
||||
HIGH: score >= 0.75
|
||||
MEDIUM: score >= 0.45
|
||||
LOW: score < 0.45
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Çıktı JSON Kontratı
|
||||
|
||||
```json
|
||||
{
|
||||
"model_version": "v28-pro-max",
|
||||
"match_info": { "match_id", "home_team", "away_team", "league", ... },
|
||||
"data_quality": { "label", "score", "lineup_source", "flags" },
|
||||
"risk": { "level", "score", "is_surprise_risk", "warnings" },
|
||||
"engine_breakdown": { "team", "player", "odds", "referee" },
|
||||
"main_pick": { "market", "pick", "confidence", "odds", "ev_edge", "bet_grade", "is_guaranteed" },
|
||||
"value_pick": { ... },
|
||||
"aggressive_pick": { "market": "HT/FT", "pick": "1/2", ... },
|
||||
"bet_advice": { "playable", "suggested_stake_units", "reason" },
|
||||
"bet_summary": [ { "market", "pick", "calibrated_confidence", "bet_grade", "ev_edge", ... } ],
|
||||
"supporting_picks": [ ... ],
|
||||
"score_prediction": { "ft", "ht", "xg_home", "xg_away", "xg_total" },
|
||||
"scenario_top5": [ "1-0", "2-1", ... ],
|
||||
"market_board": { "MS": {...}, "DC": {...}, "OU25": {...}, ... },
|
||||
"v25_signal": { "available", "markets", "value_bets" },
|
||||
"reasoning_factors": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. League-Specific Odds Reliability (V31)
|
||||
|
||||
Bazı liglerin bahis oranları daha güvenilirdir. Bu bilgi `_decorate_market_row` içinde edge ağırlıklandırmasında kullanılır:
|
||||
|
||||
```python
|
||||
odds_rel = league_reliability.get(league_id, 0.35) # 0.0 - 1.0
|
||||
edge_multiplier = 0.60 + (odds_rel × 0.60) # 0.60 - 1.20
|
||||
|
||||
# Güvenilir lig → edge daha fazla ağırlık alır
|
||||
# Güvenilsiz lig → model confidence'a daha çok güvenilir
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Dosya Haritası
|
||||
|
||||
```
|
||||
ai-engine/
|
||||
├── services/
|
||||
│ └── single_match_orchestrator.py ← Ana orchestrator (4656 satır)
|
||||
├── models/
|
||||
│ ├── v25_ensemble.py ← XGBoost + LightGBM ensemble
|
||||
│ └── v27_predictor.py ← Odds-free fundamental predictor
|
||||
├── features/
|
||||
│ └── odds_band_analyzer.py ← V28 tarihsel band analizi
|
||||
└── main.py ← FastAPI endpoint (/predict)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Akış Özeti
|
||||
|
||||
```
|
||||
HTTP POST /predict {match_id}
|
||||
│
|
||||
▼
|
||||
SingleMatchOrchestrator.analyze_match(match_id)
|
||||
│
|
||||
├── _load_match_data() → DB'den maç + odds + kadro + form
|
||||
│
|
||||
├── V25: v25_predictor.predict(features)
|
||||
│ → 13 pazar olasılık + confidence
|
||||
│
|
||||
├── V27: v27_predictor.predict(features)
|
||||
│ → Odds-free MS/OU25 olasılıkları
|
||||
│ → Divergence hesaplama
|
||||
│
|
||||
├── V28: odds_band_analyzer.compute_all()
|
||||
│ → 18 pazar için tarihsel band metrikleri
|
||||
│
|
||||
├── Triple Value Detection
|
||||
│ → V27 + V28 + Implied çapraz kontrol
|
||||
│
|
||||
├── _enrich_prediction() → xG, risk, skor tahmini
|
||||
│
|
||||
├── _build_market_rows() → 13+ ham market row
|
||||
├── _apply_market_consistency()
|
||||
├── _decorate_market_row() → EV, Kelly, grading
|
||||
│
|
||||
├── Guaranteed Pick Selection → main_pick, value_pick, aggressive_pick
|
||||
│
|
||||
└── _build_prediction_package() → Final JSON kontratı
|
||||
```
|
||||
Generated
+15
-39
@@ -26,7 +26,7 @@
|
||||
"@nestjs/swagger": "^11.2.4",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@prisma/client": "5.22.0",
|
||||
"axios": "^1.13.6",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^5.66.4",
|
||||
@@ -46,7 +46,7 @@
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pino": "^10.1.0",
|
||||
"pino-http": "^11.0.0",
|
||||
"prisma": "^5.22.0",
|
||||
"prisma": "5.22.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"twitter-api-v2": "^1.29.0",
|
||||
@@ -1145,7 +1145,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -3001,7 +3000,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz",
|
||||
"integrity": "sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"axios": "^1.3.1",
|
||||
@@ -3095,7 +3093,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -3262,7 +3259,6 @@
|
||||
"version": "11.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.11.tgz",
|
||||
"integrity": "sha512-R/+A8XFqLgN8zNs2twhrOaE7dJbRQhdPX3g46am4RT/x8xGLqDphrXkUIno4cGUZHxbczChBAaAPTdPv73wDZA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"file-type": "21.2.0",
|
||||
"iterare": "1.2.1",
|
||||
@@ -3308,7 +3304,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.11.tgz",
|
||||
"integrity": "sha512-H9i+zT3RvHi7tDc+lCmWHJ3ustXveABCr+Vcpl96dNOxgmrx4elQSTC4W93Mlav2opfLV+p0UTHY6L+bpUA4zA==",
|
||||
"hasInstallScript": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@nuxt/opencollective": "0.4.1",
|
||||
"fast-safe-stringify": "2.1.1",
|
||||
@@ -3388,7 +3383,6 @@
|
||||
"version": "11.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.11.tgz",
|
||||
"integrity": "sha512-kyABSskdMRIAMWL0SlbwtDy4yn59RL4HDdwHDz/fxWuv7/53YP8Y2DtV3/sHqY5Er0msMVTZrM38MjqXhYL7gw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cors": "2.8.5",
|
||||
"express": "5.2.1",
|
||||
@@ -3409,7 +3403,6 @@
|
||||
"version": "11.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.11.tgz",
|
||||
"integrity": "sha512-0z6pLg9CuTXtz7q2lRZoPOU94DN28OTa39f4cQrlZysKA6QrKM7w7z6xqb4g32qjF+LQHFNRmMJtE/pLrxBaig==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"socket.io": "4.8.3",
|
||||
"tslib": "2.8.1"
|
||||
@@ -3784,7 +3777,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
|
||||
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
|
||||
"hasInstallScript": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.13"
|
||||
},
|
||||
@@ -3849,7 +3841,6 @@
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"generic-pool": "3.9.0",
|
||||
@@ -4755,7 +4746,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "*",
|
||||
"@types/json-schema": "*"
|
||||
@@ -4877,7 +4867,6 @@
|
||||
"version": "22.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
|
||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -5042,7 +5031,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.52.0.tgz",
|
||||
"integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.52.0",
|
||||
"@typescript-eslint/types": "8.52.0",
|
||||
@@ -5680,7 +5668,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5734,7 +5721,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -5926,7 +5912,6 @@
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
@@ -6240,7 +6225,6 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -6313,7 +6297,6 @@
|
||||
"version": "5.66.4",
|
||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.66.4.tgz",
|
||||
"integrity": "sha512-y2VRk2z7d1YNI2JQDD7iThoD0X/0iZZ3VEp8lqT5s5U0XDl9CIjXp1LQgmE9EKy6ReHtzmYXS1f328PnUbZGtQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cron-parser": "4.9.0",
|
||||
"ioredis": "5.8.2",
|
||||
@@ -6387,7 +6370,6 @@
|
||||
"version": "7.2.7",
|
||||
"resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.7.tgz",
|
||||
"integrity": "sha512-TKeeb9nSybk1e9E5yAiPVJ6YKdX9FYhwqqy8fBfVKAFVTJYZUNmeIvwjURW6+UikNsO6l2ta27thYgo/oumDsw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cacheable/utils": "^2.3.2",
|
||||
"keyv": "^5.5.4"
|
||||
@@ -6601,7 +6583,6 @@
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
@@ -6651,14 +6632,12 @@
|
||||
"node_modules/class-transformer": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
||||
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
|
||||
"peer": true
|
||||
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw=="
|
||||
},
|
||||
"node_modules/class-validator": {
|
||||
"version": "0.14.3",
|
||||
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
|
||||
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/validator": "^13.15.3",
|
||||
"libphonenumber-js": "^1.11.1",
|
||||
@@ -7497,7 +7476,8 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
@@ -7555,7 +7535,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -7615,7 +7594,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -7846,7 +7824,6 @@
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -9051,7 +9028,6 @@
|
||||
"resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz",
|
||||
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "30.2.0",
|
||||
"@jest/types": "30.2.0",
|
||||
@@ -9895,7 +9871,6 @@
|
||||
"version": "5.5.5",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.5.tgz",
|
||||
"integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@keyv/serialize": "^1.1.1"
|
||||
}
|
||||
@@ -10688,6 +10663,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
@@ -10920,7 +10896,6 @@
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
|
||||
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"passport-strategy": "1.x.x",
|
||||
"pause": "0.0.1",
|
||||
@@ -11047,7 +11022,6 @@
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz",
|
||||
"integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
@@ -11077,7 +11051,6 @@
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-http/-/pino-http-11.0.0.tgz",
|
||||
"integrity": "sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"get-caller-file": "^2.0.5",
|
||||
"pino": "^10.0.0",
|
||||
@@ -11286,7 +11259,6 @@
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
|
||||
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -11340,7 +11312,6 @@
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
||||
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
||||
"hasInstallScript": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@prisma/engines": "5.22.0"
|
||||
},
|
||||
@@ -12479,7 +12450,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -12794,7 +12764,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
@@ -12950,7 +12919,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -13298,6 +13266,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
|
||||
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
@@ -13315,6 +13284,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
|
||||
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3"
|
||||
},
|
||||
@@ -13327,6 +13297,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
|
||||
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esrecurse": "^4.3.0",
|
||||
"estraverse": "^4.1.1"
|
||||
@@ -13340,6 +13311,7 @@
|
||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
|
||||
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
@@ -13348,13 +13320,15 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/webpack/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -13364,6 +13338,7 @@
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
@@ -13376,6 +13351,7 @@
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
|
||||
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.9",
|
||||
"ajv": "^8.9.0",
|
||||
|
||||
+5
-5
@@ -22,14 +22,14 @@
|
||||
"ai:backtest": "python ai-engine/scripts/backtest_v2_runtime.py",
|
||||
"ai:train:vqwen": "python ai-engine/scripts/train_vqwen_v3.py",
|
||||
"feeder:historical": "ts-node -r tsconfig-paths/register src/scripts/run-feeder.ts",
|
||||
"feeder:previous-day": "ts-node -r tsconfig-paths/register src/scripts/run-feeder.ts",
|
||||
"feeder:repair": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-repair.ts",
|
||||
"feeder:previous-day": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-previous-day.ts",
|
||||
"feeder:fill-gaps": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-filtered.ts",
|
||||
"feeder:basketball": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-basketball.ts",
|
||||
"feeder:live": "ts-node -r tsconfig-paths/register src/scripts/run-live-feeder.ts",
|
||||
"cleanup:live": "ts-node -r tsconfig-paths/register src/scripts/cleanup-live-matches.ts",
|
||||
"swagger:summary": "ts-node -r tsconfig-paths/register src/scripts/export-swagger-endpoints-summary.ts",
|
||||
"postman:export": "ts-node -r tsconfig-paths/register src/scripts/export-postman-collection.ts"
|
||||
,
|
||||
"postman:export": "ts-node -r tsconfig-paths/register src/scripts/export-postman-collection.ts",
|
||||
"ai:extract:v26": "python3 ai-engine/scripts/extract_training_data_v26.py",
|
||||
"ai:train:v26": "python3 ai-engine/scripts/train_v26_shadow.py",
|
||||
"ai:backtest:v26": "python3 ai-engine/scripts/backtest_v26_shadow.py",
|
||||
@@ -55,7 +55,7 @@
|
||||
"@nestjs/swagger": "^11.2.4",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@prisma/client": "5.22.0",
|
||||
"axios": "^1.13.6",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^5.66.4",
|
||||
@@ -75,7 +75,7 @@
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pino": "^10.1.0",
|
||||
"pino-http": "^11.0.0",
|
||||
"prisma": "^5.22.0",
|
||||
"prisma": "5.22.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"twitter-api-v2": "^1.29.0",
|
||||
|
||||
+33
-2
@@ -543,6 +543,7 @@ model User {
|
||||
analyses Analysis[]
|
||||
refreshTokens RefreshToken[]
|
||||
usageLimit UsageLimit?
|
||||
subscription Subscription?
|
||||
coupons UserCoupon[]
|
||||
totoCoupons TotoCoupon[]
|
||||
|
||||
@@ -551,6 +552,27 @@ model User {
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(uuid())
|
||||
userId String @unique @map("user_id")
|
||||
paddleSubscriptionId String? @unique @map("paddle_subscription_id")
|
||||
paddleCustomerId String? @map("paddle_customer_id")
|
||||
plan SubscriptionStatus @default(free)
|
||||
billingInterval BillingInterval? @map("billing_interval")
|
||||
currentPeriodStart DateTime? @map("current_period_start")
|
||||
currentPeriodEnd DateTime? @map("current_period_end")
|
||||
cancelledAt DateTime? @map("cancelled_at")
|
||||
cancelEffectiveDate DateTime? @map("cancel_effective_date")
|
||||
paddlePriceId String? @map("paddle_price_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([paddleSubscriptionId])
|
||||
@@index([paddleCustomerId])
|
||||
@@map("subscriptions")
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(uuid())
|
||||
token String @unique
|
||||
@@ -569,6 +591,8 @@ model UsageLimit {
|
||||
userId String @unique @map("user_id")
|
||||
analysisCount Int @default(0) @map("analysis_count")
|
||||
couponCount Int @default(0) @map("coupon_count")
|
||||
maxAnalyses Int @default(3) @map("max_analyses")
|
||||
maxCoupons Int @default(1) @map("max_coupons")
|
||||
lastResetDate DateTime @map("last_reset_date") @db.Date
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@@ -765,8 +789,15 @@ enum UserRole {
|
||||
|
||||
enum SubscriptionStatus {
|
||||
free
|
||||
active
|
||||
expired
|
||||
plus
|
||||
premium
|
||||
past_due
|
||||
cancelled
|
||||
}
|
||||
|
||||
enum BillingInterval {
|
||||
monthly
|
||||
yearly
|
||||
}
|
||||
|
||||
enum PlayerPosition {
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ async function main() {
|
||||
firstName: 'Super',
|
||||
lastName: 'Admin',
|
||||
role: UserRole.superadmin,
|
||||
subscriptionStatus: SubscriptionStatus.active,
|
||||
subscriptionStatus: SubscriptionStatus.free,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -1,109 +0,0 @@
|
||||
|
||||
import os
|
||||
import sys
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
# Path alignment
|
||||
sys.path.append(os.getcwd())
|
||||
sys.path.append(os.path.join(os.getcwd(), 'ai-engine'))
|
||||
|
||||
from pipeline.tiered_loader import TieredDataLoader
|
||||
from pipeline.sequence_builder import SequenceBuilder
|
||||
from models.hybrid_v11 import HybridDeepModel
|
||||
from features.odds_history import OddsHistoryEngine
|
||||
from features.synthetic_xg import SyntheticXGModel
|
||||
|
||||
DEVICE = 'cpu'
|
||||
MODEL_PATH = 'ai-engine/models/v11_hybrid_model.pth'
|
||||
TARGET_ID = 'en78ih6ec7exnpxcku3xc3das'
|
||||
|
||||
def audit():
|
||||
print(f"🕵️ Auditing Match: {TARGET_ID}")
|
||||
|
||||
# 1. Pipeline Data
|
||||
builder = SequenceBuilder()
|
||||
X, y, meta = builder.build_sequences()
|
||||
|
||||
# Check if target is in dataset
|
||||
idx_list = meta.index[meta['match_id'] == TARGET_ID].tolist()
|
||||
if not idx_list:
|
||||
print("❌ Match not found in generated sequences. Is it too old or too new?")
|
||||
return
|
||||
|
||||
idx = idx_list[0]
|
||||
row_meta = meta.iloc[idx]
|
||||
|
||||
# 2. Features
|
||||
loader = TieredDataLoader()
|
||||
odds_df = loader.load_gold_data([TARGET_ID])
|
||||
eng = OddsHistoryEngine()
|
||||
xg_model = SyntheticXGModel()
|
||||
|
||||
# Team Mapping
|
||||
unique_teams = meta['team_id'].unique()
|
||||
team_map = {tid: i for i, tid in enumerate(unique_teams)}
|
||||
|
||||
# 3. Predict exactly like Backtest
|
||||
state = torch.load(MODEL_PATH, map_location=DEVICE)
|
||||
emb_key = 'entity_emb.weight' if 'entity_emb.weight' in state else 'team_embedding.weight'
|
||||
saved_vocab_size = state[emb_key].shape[0]
|
||||
|
||||
model = HybridDeepModel(num_teams=saved_vocab_size)
|
||||
new_state = {k.replace('team_embedding', 'entity_emb'): v for k, v in state.items()}
|
||||
model.load_state_dict(new_state, strict=False)
|
||||
model.eval()
|
||||
|
||||
# Data components
|
||||
team_idx = team_map.get(row_meta['team_id'], 0)
|
||||
entities = torch.LongTensor([team_idx, 0]).unsqueeze(0)
|
||||
seq = torch.FloatTensor(X[idx]).unsqueeze(0)
|
||||
|
||||
# Context (Odds + xG)
|
||||
odds_lookup = {}
|
||||
for _, r in odds_df.iterrows():
|
||||
mid = r['match_id']
|
||||
if mid not in odds_lookup: odds_lookup[mid] = {}
|
||||
if r['category'] == 'Maç Sonucu': odds_lookup[mid][r['selection']] = r['odd_value']
|
||||
elif r['category'] == '2,5 Alt/Üst':
|
||||
if 'Üst' in r['selection']: odds_lookup[mid]['Over'] = r['odd_value']
|
||||
else: odds_lookup[mid]['Under'] = r['odd_value']
|
||||
|
||||
odds = odds_lookup.get(TARGET_ID, {'1': 1.0, 'X': 1.0, '2': 1.0, 'Over': 1.0, 'Under': 1.0})
|
||||
syn_xg = 1.35 # Placeholder in trainer for xG component if used
|
||||
hist_win_rate = eng.get_feature(row_meta['team_id'], float(odds.get('1', 1.0)))
|
||||
|
||||
ctx = torch.FloatTensor([
|
||||
float(odds.get('1', 1.0)), float(odds.get('X', 1.0)), float(odds.get('2', 1.0)),
|
||||
float(odds.get('Over', 1.0)), float(odds.get('Under', 1.0)),
|
||||
syn_xg, syn_xg,
|
||||
hist_win_rate
|
||||
]).unsqueeze(0)
|
||||
|
||||
with torch.no_grad():
|
||||
logits_res, pred_goals, logits_btts, logits_ht_ft = model(entities, seq, ctx)
|
||||
probs = F.softmax(logits_res, dim=1).numpy()[0]
|
||||
prob_btts = torch.sigmoid(logits_btts).item()
|
||||
probs_ht = F.softmax(logits_ht_ft, dim=1).numpy()[0]
|
||||
|
||||
print("\n📊 INTERNAL PIPELINE PREDICTION:")
|
||||
print(f"Target Team: {row_meta['team_id']}")
|
||||
print(f"1X2 Probs: Home:{probs[0]:.4f} Draw:{probs[1]:.4f} Away:{probs[2]:.4f}")
|
||||
print(f"BTTS Prob: {prob_btts:.4f}")
|
||||
|
||||
ht_map = ["1/1", "1/X", "1/2", "X/1", "X/X", "X/2", "2/1", "2/X", "2/2"]
|
||||
top3_ht = np.argsort(probs_ht)[-3:][::-1]
|
||||
print("Top 3 HT/FT:")
|
||||
for idx_ht in top3_ht:
|
||||
print(f" {ht_map[idx_ht]}: {probs_ht[idx_ht]:.4f}")
|
||||
|
||||
actual_res = y[idx][0]
|
||||
actual_ht_idx = int(y[idx][3])
|
||||
print(f"\n✅ ACTUAL REALITY:")
|
||||
print(f"Result (Y): {actual_res} (0.0=Away)")
|
||||
print(f"HT/FT Class: {actual_ht_idx} ({ht_map[actual_ht_idx]})")
|
||||
|
||||
if __name__ == "__main__":
|
||||
audit()
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test surprise detection on known surprise matches."""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, 'ai-engine')
|
||||
from services.single_match_orchestrator import SingleMatchOrchestrator
|
||||
import json
|
||||
|
||||
# Test Bayern vs Augsburg (24 Jan 2026) - 1/2 Reversal
|
||||
match_id = 'en78ih6ec7exnpxcku3xc3das'
|
||||
|
||||
orch = SingleMatchOrchestrator()
|
||||
result = orch.analyze_match(match_id)
|
||||
|
||||
if result:
|
||||
print('=== Bayern Munch vs Augsburg (24 Jan 2026) ===')
|
||||
print('Actual: HT 1-0, FT 1-2 (1/2 Reversal!)')
|
||||
print()
|
||||
|
||||
# Check risk
|
||||
risk = result.get('risk', {})
|
||||
print(f"Risk Level: {risk.get('level', 'N/A')}")
|
||||
print(f"Is Surprise Risk: {risk.get('is_surprise_risk', False)}")
|
||||
print(f"Surprise Type: {risk.get('surprise_type', 'N/A')}")
|
||||
print(f"Risk Score: {risk.get('score', 'N/A')}")
|
||||
print()
|
||||
|
||||
# Check HT/FT probabilities from market_board
|
||||
htft = result.get('market_board', {}).get('HTFT', {}).get('probs', {})
|
||||
print('HT/FT Probabilities:')
|
||||
if htft:
|
||||
for k, v in sorted(htft.items(), key=lambda x: x[1], reverse=True):
|
||||
print(f" {k}: {v*100:.1f}%")
|
||||
else:
|
||||
print(" EMPTY!")
|
||||
print()
|
||||
|
||||
# Check main pick
|
||||
main = result.get('main_pick', {})
|
||||
print(f"Main Pick: {main.get('market', 'N/A')} - {main.get('pick', 'N/A')}")
|
||||
print(f"Confidence: {main.get('calibrated_confidence', 'N/A')}%")
|
||||
print(f"Is Guaranteed: {main.get('is_guaranteed', False)}")
|
||||
print()
|
||||
|
||||
# Check aggressive pick
|
||||
agg = result.get('aggressive_pick', {})
|
||||
if agg:
|
||||
print(f"Aggressive Pick: {agg.get('market', 'N/A')} - {agg.get('pick', 'N/A')}")
|
||||
print(f"Odds: {agg.get('odds', 'N/A')}")
|
||||
print()
|
||||
|
||||
# Check bet_summary for HTFT
|
||||
bet_summary = result.get('bet_summary', [])
|
||||
for bet in bet_summary:
|
||||
if bet.get('market') == 'HTFT':
|
||||
print(f"HTFT Bet: {bet}")
|
||||
else:
|
||||
print('Match not found')
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test the improved surprise detection logic"""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, 'ai-engine')
|
||||
|
||||
from core.calculators.risk_assessor import RiskAssessor
|
||||
from config.config_loader import get_config
|
||||
|
||||
def test_surprise_detection():
|
||||
config = get_config()
|
||||
assessor = RiskAssessor(config)
|
||||
|
||||
# Test cases based on real scenarios
|
||||
test_cases = [
|
||||
{
|
||||
'name': 'Bayern vs Augsburg (1.30 odds, 2% 1/2 prob)',
|
||||
'odds': {'ms_h': 1.30, 'ms_d': 5.00, 'ms_a': 8.00},
|
||||
'ht_ft': {'1/1': 0.30, '1/X': 0.07, '1/2': 0.02, 'X/1': 0.15, 'X/X': 0.16, 'X/2': 0.09, '2/1': 0.03, '2/X': 0.04, '2/2': 0.14},
|
||||
'expected_surprise': True,
|
||||
'expected_type': '1/2 Potential Upset'
|
||||
},
|
||||
{
|
||||
'name': 'Strong favorite (1.20 odds, 1.5% 1/2 prob)',
|
||||
'odds': {'ms_h': 1.20, 'ms_d': 6.00, 'ms_a': 12.00},
|
||||
'ht_ft': {'1/1': 0.35, '1/X': 0.05, '1/2': 0.015, 'X/1': 0.20, 'X/X': 0.15, 'X/2': 0.05, '2/1': 0.02, '2/X': 0.03, '2/2': 0.10},
|
||||
'expected_surprise': True,
|
||||
'expected_type': '1/2 Potential Upset'
|
||||
},
|
||||
{
|
||||
'name': 'Moderate favorite (1.50 odds, 3% 1/2 prob)',
|
||||
'odds': {'ms_h': 1.50, 'ms_d': 4.00, 'ms_a': 6.00},
|
||||
'ht_ft': {'1/1': 0.28, '1/X': 0.08, '1/2': 0.03, 'X/1': 0.18, 'X/X': 0.15, 'X/2': 0.08, '2/1': 0.04, '2/X': 0.05, '2/2': 0.11},
|
||||
'expected_surprise': True,
|
||||
'expected_type': '1/2 Potential Upset'
|
||||
},
|
||||
{
|
||||
'name': 'Even match (2.00 odds, 5% 1/2 prob)',
|
||||
'odds': {'ms_h': 2.00, 'ms_d': 3.30, 'ms_a': 3.30},
|
||||
'ht_ft': {'1/1': 0.20, '1/X': 0.10, '1/2': 0.05, 'X/1': 0.15, 'X/X': 0.15, 'X/2': 0.10, '2/1': 0.05, '2/X': 0.10, '2/2': 0.10},
|
||||
'expected_surprise': False, # No clear favorite
|
||||
'expected_type': None
|
||||
},
|
||||
{
|
||||
'name': 'Away favorite (1.40 away odds, 2% 2/1 prob)',
|
||||
'odds': {'ms_h': 6.00, 'ms_d': 4.00, 'ms_a': 1.40},
|
||||
'ht_ft': {'1/1': 0.10, '1/X': 0.05, '1/2': 0.04, 'X/1': 0.08, 'X/X': 0.15, 'X/2': 0.20, '2/1': 0.02, '2/X': 0.06, '2/2': 0.30},
|
||||
'expected_surprise': True,
|
||||
'expected_type': '2/1 Potential Upset'
|
||||
},
|
||||
]
|
||||
|
||||
print("=" * 70)
|
||||
print("SURPRISE DETECTION TEST RESULTS")
|
||||
print("=" * 70)
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for tc in test_cases:
|
||||
class MockCtx:
|
||||
is_surprise = False
|
||||
is_top_league = True
|
||||
sport = 'football'
|
||||
xgboost_preds = {'ht_ft': tc['ht_ft']}
|
||||
odds_data = tc['odds']
|
||||
|
||||
result = assessor.assess_risk(MockCtx())
|
||||
|
||||
# Check if result matches expectation
|
||||
is_correct = result.is_surprise_risk == tc['expected_surprise']
|
||||
if tc['expected_type'] and result.surprise_type != tc['expected_type']:
|
||||
is_correct = False
|
||||
|
||||
status = "✅ PASS" if is_correct else "❌ FAIL"
|
||||
if is_correct:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
print(f"\n{status} - {tc['name']}")
|
||||
print(f" Expected: surprise={tc['expected_surprise']}, type={tc['expected_type']}")
|
||||
print(f" Got: surprise={result.is_surprise_risk}, type={result.surprise_type}")
|
||||
if result.reasons:
|
||||
print(f" Reasons: {result.reasons}")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(f"SUMMARY: {passed} passed, {failed} failed")
|
||||
print("=" * 70)
|
||||
|
||||
return failed == 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = test_surprise_detection()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test UpsetEngine on Bayern vs Augsburg match."""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, 'ai-engine')
|
||||
from features.upset_engine import get_upset_engine
|
||||
from data.db import get_clean_dsn
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from datetime import datetime
|
||||
|
||||
# Get match data
|
||||
conn = psycopg2.connect(get_clean_dsn())
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
cur.execute("""
|
||||
SELECT m.id, m.home_team_id, m.away_team_id, m.score_home, m.score_away,
|
||||
m.ht_score_home, m.ht_score_away, m.mst_utc,
|
||||
th.name as home_name, ta.name as away_name, l.name as league
|
||||
FROM matches m
|
||||
JOIN teams th ON m.home_team_id = th.id
|
||||
JOIN teams ta ON m.away_team_id = ta.id
|
||||
JOIN leagues l ON m.league_id = l.id
|
||||
WHERE m.id = 'en78ih6ec7exnpxcku3xc3das'
|
||||
""")
|
||||
match = cur.fetchone()
|
||||
conn.close()
|
||||
|
||||
if match:
|
||||
print('=== Bayern Munch vs Augsburg (24 Jan 2026) ===')
|
||||
print(f"Actual: HT {match['ht_score_home']}-{match['ht_score_away']}, FT {match['score_home']}-{match['score_away']} (1/2 Reversal!)")
|
||||
print()
|
||||
|
||||
# Test UpsetEngine
|
||||
engine = get_upset_engine()
|
||||
|
||||
# Calculate upset potential using get_features
|
||||
result = engine.get_features(
|
||||
home_team_name=match['home_name'],
|
||||
home_team_id=match['home_team_id'],
|
||||
away_team_name=match['away_name'],
|
||||
league_name=match['league'],
|
||||
home_position=1, # Bayern is typically top
|
||||
away_position=15, # Augsburg is typically lower
|
||||
match_date_ms=match['mst_utc'],
|
||||
total_teams=18,
|
||||
)
|
||||
|
||||
print('UpsetEngine Results:')
|
||||
print(f" Atmosphere Score: {result.get('upset_atmosphere', 0):.2f}")
|
||||
print(f" Motivation Score: {result.get('upset_motivation', 0):.2f}")
|
||||
print(f" Fatigue Score: {result.get('upset_fatigue', 0):.2f}")
|
||||
print(f" Historical Score: {result.get('upset_historical', 0):.2f}")
|
||||
print(f" TOTAL UPSET POTENTIAL: {result.get('upset_potential', 0):.2f}")
|
||||
print()
|
||||
|
||||
# Check if upset was detected
|
||||
if result.get('upset_potential', 0) > 0.5:
|
||||
print("🔥 HIGH UPSET POTENTIAL DETECTED!")
|
||||
elif result.get('upset_potential', 0) > 0.3:
|
||||
print("⚠️ MEDIUM UPSET POTENTIAL")
|
||||
else:
|
||||
print("❌ LOW UPSET POTENTIAL - Model did not detect this as upset")
|
||||
else:
|
||||
print('Match not found')
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { AppController } from "./app.controller";
|
||||
import { AppService } from "./app.service";
|
||||
|
||||
describe("AppController", () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe("root", () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe("Hello World!");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,8 @@ import { LeaguesModule } from "./modules/leagues/leagues.module";
|
||||
import { AnalysisModule } from "./modules/analysis/analysis.module";
|
||||
import { CouponsModule } from "./modules/coupons/coupons.module";
|
||||
import { SporTotoModule } from "./modules/spor-toto/spor-toto.module";
|
||||
import { AiProxyModule } from "./modules/ai-proxy/ai-proxy.module";
|
||||
import { SubscriptionsModule } from "./modules/subscriptions/subscriptions.module";
|
||||
|
||||
// Services and Tasks
|
||||
import { ServicesModule } from "./services/services.module";
|
||||
@@ -76,6 +78,7 @@ const historicalFeederMode = process.env.FEEDER_MODE === "historical";
|
||||
// Configuration
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: [".env.local", ".env"],
|
||||
validate: validateEnv,
|
||||
load: [
|
||||
appConfig,
|
||||
@@ -201,6 +204,8 @@ const historicalFeederMode = process.env.FEEDER_MODE === "historical";
|
||||
AnalysisModule,
|
||||
CouponsModule,
|
||||
SporTotoModule,
|
||||
AiProxyModule,
|
||||
SubscriptionsModule,
|
||||
|
||||
// Services and Scheduled Tasks
|
||||
ServicesModule,
|
||||
|
||||
@@ -69,9 +69,8 @@ export class AiEngineClient {
|
||||
this.defaultTimeoutMs = options.timeoutMs ?? 30000;
|
||||
this.maxRetries = options.maxRetries ?? 2;
|
||||
this.retryDelayMs = options.retryDelayMs ?? 750;
|
||||
this.circuitBreakerThreshold = options.circuitBreakerThreshold ?? 3;
|
||||
this.circuitBreakerCooldownMs =
|
||||
options.circuitBreakerCooldownMs ?? 30000;
|
||||
this.circuitBreakerThreshold = options.circuitBreakerThreshold ?? 5;
|
||||
this.circuitBreakerCooldownMs = options.circuitBreakerCooldownMs ?? 15000;
|
||||
|
||||
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);
|
||||
@@ -133,7 +134,13 @@ export class AiEngineClient {
|
||||
const shouldRetry = attempt < retries && this.isRetriableError(error);
|
||||
|
||||
if (!shouldRetry) {
|
||||
this.registerFailure(error);
|
||||
// Only register circuit breaker failure for server/network errors, not client errors (4xx)
|
||||
if (this.isServerError(error)) {
|
||||
this.registerFailure(error);
|
||||
} else {
|
||||
// It's a successful contact with the engine (e.g. 404, 422), so reset failures
|
||||
this.resetFailures();
|
||||
}
|
||||
throw this.toRequestError(error);
|
||||
}
|
||||
|
||||
@@ -162,7 +169,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", {
|
||||
@@ -175,8 +183,11 @@ export class AiEngineClient {
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`[${this.serviceName}] AI circuit breaker cooldown elapsed, allowing a recovery attempt`,
|
||||
`[${this.serviceName}] AI circuit breaker cooldown elapsed, allowing a recovery attempt (resetting failures from ${this.consecutiveFailures})`,
|
||||
);
|
||||
// Half-open state: reset failures so a single retry failure doesn't
|
||||
// immediately re-open the circuit at threshold+1
|
||||
this.consecutiveFailures = 0;
|
||||
this.circuitOpenedAt = null;
|
||||
}
|
||||
|
||||
@@ -218,6 +229,27 @@ export class AiEngineClient {
|
||||
return status >= 500 || status === 429 || error.code === "ECONNABORTED";
|
||||
}
|
||||
|
||||
private isServerError(error: unknown): boolean {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
return true; // Not an axios error, assume internal/network error
|
||||
}
|
||||
if (!error.response) {
|
||||
return true; // Network error, timeout, etc.
|
||||
}
|
||||
// Only count infrastructure-level errors toward circuit breaker:
|
||||
// - No response (network failure) → already handled above
|
||||
// - Timeout (ECONNABORTED) → infrastructure
|
||||
// - 429 (rate limit) → infrastructure
|
||||
// - 502/503/504 (proxy/gateway errors) → infrastructure
|
||||
// Do NOT count 500 (app-level crash in AI Engine) — it may be
|
||||
// match-specific and shouldn't block all other matches.
|
||||
if (error.code === "ECONNABORTED") {
|
||||
return true;
|
||||
}
|
||||
const status = error.response.status;
|
||||
return status === 429 || status === 502 || status === 503 || status === 504;
|
||||
}
|
||||
|
||||
private toRequestError(error: unknown): AiEngineRequestError {
|
||||
if (error instanceof AiEngineRequestError) {
|
||||
return error;
|
||||
|
||||
@@ -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")}`;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const envSchema = z.object({
|
||||
// Database
|
||||
DATABASE_URL: z.string().url(),
|
||||
// AI Engine
|
||||
AI_ENGINE_URL: z.string().url().default("http://localhost:8000"),
|
||||
AI_ENGINE_URL: z.string().url(),
|
||||
AI_ENGINE_MODE: z.enum(["v28-pro-max", "dual"]).default("v28-pro-max"),
|
||||
|
||||
// JWT
|
||||
@@ -56,13 +56,31 @@ export const envSchema = z.object({
|
||||
.string()
|
||||
.transform((val) => val === "true")
|
||||
.default("false" as any),
|
||||
SOCIAL_POSTER_SPORTS: z.string().default("football,basketball"),
|
||||
SOCIAL_POSTER_WINDOW_MIN: z.coerce.number().default(25),
|
||||
SOCIAL_POSTER_WINDOW_MAX: z.coerce.number().default(45),
|
||||
SOCIAL_POSTER_OLLAMA_MODEL: z.string().optional(),
|
||||
APP_BASE_URL: z.string().url().optional(),
|
||||
TWITTER_API_KEY: z.string().optional(),
|
||||
TWITTER_API_SECRET: z.string().optional(),
|
||||
TWITTER_ACCESS_TOKEN: z.string().optional(),
|
||||
TWITTER_ACCESS_SECRET: z.string().optional(),
|
||||
META_GRAPH_API_VERSION: z.string().default("v25.0"),
|
||||
META_PAGE_ACCESS_TOKEN: z.string().optional(),
|
||||
META_PAGE_ID: z.string().optional(),
|
||||
META_IG_USER_ID: z.string().optional(),
|
||||
OLLAMA_BASE_URL: z.string().url().optional(),
|
||||
OLLAMA_MODEL: z.string().optional(),
|
||||
|
||||
// Paddle (Subscription Billing)
|
||||
PADDLE_API_KEY: z.string().optional(),
|
||||
PADDLE_WEBHOOK_SECRET: z.string().optional(),
|
||||
PADDLE_CLIENT_TOKEN: z.string().optional(),
|
||||
PADDLE_ENVIRONMENT: z.enum(["sandbox", "production"]).default("sandbox"),
|
||||
PADDLE_PLUS_MONTHLY_PRICE_ID: z.string().optional(),
|
||||
PADDLE_PLUS_YEARLY_PRICE_ID: z.string().optional(),
|
||||
PADDLE_PREMIUM_MONTHLY_PRICE_ID: z.string().optional(),
|
||||
PADDLE_PREMIUM_YEARLY_PRICE_ID: z.string().optional(),
|
||||
|
||||
// Optional Features
|
||||
ENABLE_MAIL: booleanString,
|
||||
|
||||
@@ -9,5 +9,12 @@
|
||||
"serverError": "An unexpected error occurred",
|
||||
"unauthorized": "You are not authorized to perform this action",
|
||||
"forbidden": "Access denied",
|
||||
"badRequest": "Invalid request"
|
||||
"badRequest": "Bad request",
|
||||
"SUCCESS_USER_ROLE_UPDATED": "User role updated",
|
||||
"SUCCESS_USER_SUBSCRIPTION_UPDATED": "User subscription updated",
|
||||
"SUCCESS_USER_DELETED": "User deleted",
|
||||
"SUCCESS_USER_STATUS_UPDATED": "User status updated",
|
||||
"SUCCESS_SETTING_UPDATED": "Setting updated",
|
||||
"SUCCESS_ALL_LIMITS_RESET": "All usage limits reset",
|
||||
"SUCCESS_USER_LIMITS_RESET": "User usage limits reset"
|
||||
}
|
||||
|
||||
@@ -10,5 +10,13 @@
|
||||
"TENANT_NOT_FOUND": "Tenant not found",
|
||||
"VALIDATION_FAILED": "Validation failed",
|
||||
"INTERNAL_ERROR": "An internal error occurred, please try again later",
|
||||
"AUTH_REQUIRED": "Authentication required, please provide a valid token"
|
||||
"AUTH_REQUIRED": "Authentication required, please provide a valid token",
|
||||
"USAGE_LIMIT_EXCEEDED": "You have exceeded your daily usage limit. Please upgrade your plan.",
|
||||
"ANALYSIS_LIMIT_EXCEEDED": "You have exceeded your daily analysis limit. Please upgrade your plan.",
|
||||
"COUPON_LIMIT_EXCEEDED": "You have exceeded your daily coupon limit. Please upgrade your plan.",
|
||||
"INVALID_PLAN_TYPE": "Invalid plan type. Must be free, plus, or premium.",
|
||||
"MATCH_NOT_FOUND": "Match not found",
|
||||
"PREDICTION_GENERATION_FAILED": "Failed to generate prediction",
|
||||
"SMART_COUPON_GENERATION_FAILED": "Failed to generate Smart Coupon",
|
||||
"ANALYSIS_FAILED": "None of the provided matches could be analyzed successfully"
|
||||
}
|
||||
|
||||
@@ -9,5 +9,12 @@
|
||||
"serverError": "Beklenmeyen bir hata oluştu",
|
||||
"unauthorized": "Bu işlemi yapmaya yetkiniz yok",
|
||||
"forbidden": "Erişim reddedildi",
|
||||
"badRequest": "Geçersiz istek"
|
||||
"badRequest": "Geçersiz istek",
|
||||
"SUCCESS_USER_ROLE_UPDATED": "Kullanıcı rolü güncellendi",
|
||||
"SUCCESS_USER_SUBSCRIPTION_UPDATED": "Kullanıcı aboneliği güncellendi",
|
||||
"SUCCESS_USER_DELETED": "Kullanıcı başarıyla silindi",
|
||||
"SUCCESS_USER_STATUS_UPDATED": "Kullanıcı durumu güncellendi",
|
||||
"SUCCESS_SETTING_UPDATED": "Ayar güncellendi",
|
||||
"SUCCESS_ALL_LIMITS_RESET": "Tüm kullanıcı limitleri sıfırlandı",
|
||||
"SUCCESS_USER_LIMITS_RESET": "Kullanıcı limitleri sıfırlandı"
|
||||
}
|
||||
|
||||
@@ -10,5 +10,13 @@
|
||||
"TENANT_NOT_FOUND": "Kiracı bulunamadı",
|
||||
"VALIDATION_FAILED": "Doğrulama başarısız",
|
||||
"INTERNAL_ERROR": "Bir iç hata oluştu, lütfen daha sonra tekrar deneyin",
|
||||
"AUTH_REQUIRED": "Kimlik doğrulama gerekli, lütfen geçerli bir token sağlayın"
|
||||
"AUTH_REQUIRED": "Kimlik doğrulama gerekli, lütfen geçerli bir token sağlayın",
|
||||
"USAGE_LIMIT_EXCEEDED": "Günlük kullanım limitinizi doldurdunuz. Lütfen paketinizi yükseltin.",
|
||||
"ANALYSIS_LIMIT_EXCEEDED": "Günlük analiz limitinizi doldurdunuz. Lütfen paketinizi yükseltin.",
|
||||
"COUPON_LIMIT_EXCEEDED": "Günlük kupon limitinizi doldurdunuz. Lütfen paketinizi yükseltin.",
|
||||
"INVALID_PLAN_TYPE": "Geçersiz paket tipi. (free, plus, premium olmalıdır)",
|
||||
"MATCH_NOT_FOUND": "Maç bulunamadı",
|
||||
"PREDICTION_GENERATION_FAILED": "Tahmin oluşturulamadı",
|
||||
"SMART_COUPON_GENERATION_FAILED": "Akıllı kupon oluşturulamadı",
|
||||
"ANALYSIS_FAILED": "Sağlanan maçların hiçbiri başarıyla analiz edilemedi"
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ async function bootstrap() {
|
||||
"https://suggestbet.bilgich.com",
|
||||
"https://iddaai.com",
|
||||
"https://www.iddaai.com",
|
||||
"http://localhost:6195",
|
||||
]
|
||||
: true,
|
||||
credentials: true,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
UseInterceptors,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
CacheInterceptor,
|
||||
@@ -18,7 +19,12 @@ import {
|
||||
CACHE_MANAGER,
|
||||
} from "@nestjs/cache-manager";
|
||||
import * as cacheManager from "cache-manager";
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } 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";
|
||||
@@ -31,6 +37,8 @@ import {
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { UserResponseDto } from "../users/dto/user.dto";
|
||||
import { UserRole } from "@prisma/client";
|
||||
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
||||
import { PlanType } from "../subscriptions/dto/subscription.dto";
|
||||
|
||||
@ApiTags("Admin")
|
||||
@ApiBearerAuth()
|
||||
@@ -40,12 +48,14 @@ export class AdminController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: cacheManager.Cache,
|
||||
private readonly subscriptionsService: SubscriptionsService,
|
||||
) {}
|
||||
|
||||
// ================== Users Management ==================
|
||||
|
||||
@Get("users")
|
||||
@ApiOperation({ summary: "Get all users (admin)" })
|
||||
@SwaggerResponse({ status: 200, type: [UserResponseDto] })
|
||||
async getAllUsers(
|
||||
@Query() pagination: PaginationDto,
|
||||
): Promise<ApiResponse<PaginatedData<UserResponseDto>>> {
|
||||
@@ -75,6 +85,7 @@ export class AdminController {
|
||||
|
||||
@Get("users/:id")
|
||||
@ApiOperation({ summary: "Get user by ID" })
|
||||
@SwaggerResponse({ status: 200, type: UserResponseDto })
|
||||
async getUserById(
|
||||
@Param("id") id: string,
|
||||
): Promise<ApiResponse<UserResponseDto>> {
|
||||
@@ -98,6 +109,7 @@ export class AdminController {
|
||||
|
||||
@Put("users/:id/toggle-active")
|
||||
@ApiOperation({ summary: "Toggle user active status" })
|
||||
@SwaggerResponse({ status: 200, type: UserResponseDto })
|
||||
async toggleUserActive(
|
||||
@Param("id") id: string,
|
||||
): Promise<ApiResponse<UserResponseDto>> {
|
||||
@@ -114,12 +126,13 @@ export class AdminController {
|
||||
|
||||
return createSuccessResponse(
|
||||
plainToInstance(UserResponseDto, updated),
|
||||
"User status updated",
|
||||
"common.SUCCESS_USER_STATUS_UPDATED",
|
||||
);
|
||||
}
|
||||
|
||||
@Put("users/:id/role")
|
||||
@ApiOperation({ summary: "Update user role" })
|
||||
@SwaggerResponse({ status: 200, type: UserResponseDto })
|
||||
async updateUserRole(
|
||||
@Param("id") id: string,
|
||||
@Body() data: { role: UserRole },
|
||||
@@ -131,41 +144,19 @@ export class AdminController {
|
||||
|
||||
return createSuccessResponse(
|
||||
plainToInstance(UserResponseDto, user),
|
||||
"User role updated",
|
||||
);
|
||||
}
|
||||
|
||||
@Put("users/:id/subscription")
|
||||
@ApiOperation({ summary: "Update user subscription" })
|
||||
async updateUserSubscription(
|
||||
@Param("id") id: string,
|
||||
@Body()
|
||||
data: { subscriptionStatus: string; subscriptionExpiresAt?: string },
|
||||
): Promise<ApiResponse<UserResponseDto>> {
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
subscriptionStatus: data.subscriptionStatus as any,
|
||||
subscriptionExpiresAt: data.subscriptionExpiresAt
|
||||
? new Date(data.subscriptionExpiresAt)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
return createSuccessResponse(
|
||||
plainToInstance(UserResponseDto, user),
|
||||
"User subscription updated",
|
||||
"common.SUCCESS_USER_ROLE_UPDATED",
|
||||
);
|
||||
}
|
||||
|
||||
@Delete("users/:id")
|
||||
@ApiOperation({ summary: "Soft delete a user" })
|
||||
@SwaggerResponse({ status: 200, description: "User deleted" })
|
||||
async deleteUser(@Param("id") id: string): Promise<ApiResponse<null>> {
|
||||
await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
return createSuccessResponse(null, "User deleted");
|
||||
return createSuccessResponse(null, "common.SUCCESS_USER_DELETED");
|
||||
}
|
||||
|
||||
// ================== App Settings ==================
|
||||
@@ -175,6 +166,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" } },
|
||||
})
|
||||
async getAllSettings(): Promise<ApiResponse<Record<string, string>>> {
|
||||
const settings = await this.prisma.appSetting.findMany();
|
||||
const settingsMap: Record<string, string> = {};
|
||||
@@ -186,6 +181,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" } },
|
||||
},
|
||||
})
|
||||
async updateSetting(
|
||||
@Param("key") key: string,
|
||||
@Body() data: { value: string },
|
||||
@@ -198,7 +200,7 @@ export class AdminController {
|
||||
await this.cacheManager.del("app_settings");
|
||||
return createSuccessResponse(
|
||||
{ key: setting.key, value: setting.value || "" },
|
||||
"Setting updated",
|
||||
"common.SUCCESS_SETTING_UPDATED",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -206,6 +208,10 @@ export class AdminController {
|
||||
|
||||
@Get("usage-limits")
|
||||
@ApiOperation({ summary: "Get all usage limits" })
|
||||
@SwaggerResponse({
|
||||
status: 200,
|
||||
schema: { type: "array", items: { type: "object" } },
|
||||
})
|
||||
async getAllUsageLimits(@Query() pagination: PaginationDto) {
|
||||
const { skip, take } = pagination;
|
||||
|
||||
@@ -233,6 +239,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" } } },
|
||||
})
|
||||
async resetAllUsageLimits(): Promise<ApiResponse<{ count: number }>> {
|
||||
const result = await this.prisma.usageLimit.updateMany({
|
||||
data: {
|
||||
@@ -244,7 +254,57 @@ export class AdminController {
|
||||
|
||||
return createSuccessResponse(
|
||||
{ count: result.count },
|
||||
"All usage limits reset",
|
||||
"common.SUCCESS_ALL_LIMITS_RESET",
|
||||
);
|
||||
}
|
||||
|
||||
@Post("usage-limits/reset/:userId")
|
||||
@ApiOperation({ summary: "Reset usage limits for a single user" })
|
||||
@SwaggerResponse({ status: 200 })
|
||||
async resetUserUsageLimits(
|
||||
@Param("userId") userId: string,
|
||||
): Promise<ApiResponse<null>> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException("USER_NOT_FOUND");
|
||||
|
||||
await this.prisma.usageLimit.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
analysisCount: 0,
|
||||
couponCount: 0,
|
||||
lastResetDate: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return createSuccessResponse(null, "common.SUCCESS_USER_LIMITS_RESET");
|
||||
}
|
||||
|
||||
@Put("users/:userId/subscription")
|
||||
@ApiOperation({ summary: "Update a user's subscription tier" })
|
||||
@SwaggerResponse({ status: 200 })
|
||||
async updateUserSubscription(
|
||||
@Param("userId") userId: string,
|
||||
@Body() data: { plan: string },
|
||||
): Promise<ApiResponse<null>> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException("USER_NOT_FOUND");
|
||||
|
||||
const validPlans = [PlanType.FREE, PlanType.PLUS, PlanType.PREMIUM];
|
||||
const newPlan = data.plan as PlanType;
|
||||
if (!validPlans.includes(newPlan)) {
|
||||
throw new BadRequestException("INVALID_PLAN_TYPE");
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { subscriptionStatus: newPlan },
|
||||
});
|
||||
|
||||
await this.subscriptionsService.syncLimitsWithPlan(userId, newPlan);
|
||||
|
||||
return createSuccessResponse(
|
||||
null,
|
||||
"common.SUCCESS_USER_SUBSCRIPTION_UPDATED",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -252,6 +312,7 @@ export class AdminController {
|
||||
|
||||
@Get("analytics/overview")
|
||||
@ApiOperation({ summary: "Get system analytics overview" })
|
||||
@SwaggerResponse({ status: 200, schema: { type: "object" } })
|
||||
async getAnalyticsOverview() {
|
||||
const [
|
||||
totalUsers,
|
||||
@@ -263,7 +324,9 @@ export class AdminController {
|
||||
] = await Promise.all([
|
||||
this.prisma.user.count(),
|
||||
this.prisma.user.count({ where: { isActive: true } }),
|
||||
this.prisma.user.count({ where: { subscriptionStatus: "active" } }),
|
||||
this.prisma.user.count({
|
||||
where: { subscriptionStatus: { in: ["plus", "premium"] } },
|
||||
}),
|
||||
this.prisma.match.count(),
|
||||
this.prisma.prediction.count(),
|
||||
this.prisma.userCoupon.count(),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AdminController } from "./admin.controller";
|
||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||
|
||||
@Module({
|
||||
imports: [SubscriptionsModule],
|
||||
controllers: [AdminController],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { All, Body, Controller, Req } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
|
||||
import { AiProxyService } from "./ai-proxy.service";
|
||||
|
||||
@Controller("ai-engine")
|
||||
export class AiProxyController {
|
||||
constructor(private readonly aiProxyService: AiProxyService) {}
|
||||
|
||||
@All("*path")
|
||||
proxy(@Req() request: Request, @Body() body: unknown) {
|
||||
return this.aiProxyService.proxy({
|
||||
method: request.method,
|
||||
originalUrl: request.originalUrl,
|
||||
query: request.query as Record<string, unknown>,
|
||||
body,
|
||||
acceptLanguage: request.headers["accept-language"],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
|
||||
import { AiProxyController } from "./ai-proxy.controller";
|
||||
import { AiProxyService } from "./ai-proxy.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.register({
|
||||
timeout: 45000,
|
||||
maxRedirects: 0,
|
||||
}),
|
||||
],
|
||||
controllers: [AiProxyController],
|
||||
providers: [AiProxyService],
|
||||
})
|
||||
export class AiProxyModule {}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { AxiosError, Method } from "axios";
|
||||
|
||||
interface ProxyRequest {
|
||||
method: string;
|
||||
originalUrl: string;
|
||||
query: Record<string, unknown>;
|
||||
body: unknown;
|
||||
acceptLanguage?: string | string[];
|
||||
}
|
||||
|
||||
interface AllowedRoute {
|
||||
method: Method;
|
||||
pattern: RegExp;
|
||||
}
|
||||
|
||||
const ALLOWED_AI_ROUTES: AllowedRoute[] = [
|
||||
{ method: "GET", pattern: /^\/$/ },
|
||||
{ method: "GET", pattern: /^\/health$/ },
|
||||
{ method: "POST", pattern: /^\/v20plus\/analyze\/[^/]+$/ },
|
||||
{ method: "GET", pattern: /^\/v20plus\/analyze-htms\/[^/]+$/ },
|
||||
{ method: "GET", pattern: /^\/v20plus\/analyze-htft\/[^/]+$/ },
|
||||
{ method: "POST", pattern: /^\/v20plus\/coupon$/ },
|
||||
{ method: "GET", pattern: /^\/v20plus\/daily-banker$/ },
|
||||
{ method: "GET", pattern: /^\/v20plus\/reversal-watchlist$/ },
|
||||
{ method: "GET", pattern: /^\/v2\/health$/ },
|
||||
{ method: "POST", pattern: /^\/v2\/analyze\/[^/]+$/ },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class AiProxyService {
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async proxy(request: ProxyRequest) {
|
||||
const path = this.extractProxyPath(request.originalUrl);
|
||||
const method = request.method.toUpperCase() as Method;
|
||||
|
||||
if (!this.isAllowed(method, path)) {
|
||||
throw new ForbiddenException("AI_PROXY_ROUTE_NOT_ALLOWED");
|
||||
}
|
||||
|
||||
const baseUrl = this.configService.getOrThrow<string>("AI_ENGINE_URL");
|
||||
const targetUrl = new URL(path, baseUrl);
|
||||
|
||||
try {
|
||||
const response = await this.httpService.axiosRef.request({
|
||||
url: targetUrl.toString(),
|
||||
method,
|
||||
params: request.query,
|
||||
data: request.body,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"accept-language": Array.isArray(request.acceptLanguage)
|
||||
? request.acceptLanguage[0]
|
||||
: request.acceptLanguage,
|
||||
},
|
||||
timeout: 45000,
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status) => status >= 200 && status < 500,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError;
|
||||
throw new BadGatewayException({
|
||||
message: "AI_PROXY_UPSTREAM_FAILED",
|
||||
status: axiosError.response?.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private extractProxyPath(originalUrl: string): string {
|
||||
const withoutQuery = originalUrl.split("?")[0] || "";
|
||||
const marker = "/ai-engine";
|
||||
const markerIndex = withoutQuery.indexOf(marker);
|
||||
if (markerIndex === -1) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
const path = withoutQuery.slice(markerIndex + marker.length);
|
||||
return path.length === 0 ? "/" : path;
|
||||
}
|
||||
|
||||
private isAllowed(method: Method, path: string): boolean {
|
||||
return ALLOWED_AI_ROUTES.some(
|
||||
(route) => route.method === method && route.pattern.test(path),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,18 @@ export class AnalysisController {
|
||||
@Post("analyze-matches")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Analyze multiple matches for coupon" })
|
||||
@ApiResponse({ status: 200, description: "Analysis successful" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Analysis successful",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
data: { type: "object" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Invalid input" })
|
||||
@ApiResponse({ status: 429, description: "Usage limit exceeded" })
|
||||
async analyzeMatches(
|
||||
@@ -48,7 +59,7 @@ export class AnalysisController {
|
||||
);
|
||||
|
||||
if (!canProceed) {
|
||||
throw new ForbiddenException("You have exceeded your daily usage limit");
|
||||
throw new ForbiddenException("USAGE_LIMIT_EXCEEDED");
|
||||
}
|
||||
|
||||
// Run analysis
|
||||
@@ -57,7 +68,7 @@ export class AnalysisController {
|
||||
if (!result) {
|
||||
return {
|
||||
success: false,
|
||||
message: "None of the provided matches could be analyzed successfully",
|
||||
message: "ANALYSIS_FAILED",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,7 +103,17 @@ export class AnalysisController {
|
||||
*/
|
||||
@Get("history")
|
||||
@ApiOperation({ summary: "Get analysis history" })
|
||||
@ApiResponse({ status: 200, description: "History retrieved" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "History retrieved",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
data: { type: "array", items: { type: "object" } },
|
||||
},
|
||||
},
|
||||
})
|
||||
async getHistory(@CurrentUser() user: any) {
|
||||
const history = await this.analysisService.getAnalysisHistory(user.id);
|
||||
return { success: true, data: history };
|
||||
|
||||
@@ -84,7 +84,7 @@ export class AnalysisService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check user usage limit
|
||||
* Check user usage limit (plan-aware via UsageLimit table)
|
||||
*/
|
||||
async checkUsageLimit(
|
||||
userId: string,
|
||||
@@ -96,24 +96,23 @@ export class AnalysisService {
|
||||
});
|
||||
|
||||
if (!usageLimit) {
|
||||
// Create default limit
|
||||
// Create default limit with free-tier maxes
|
||||
await this.prisma.usageLimit.create({
|
||||
data: {
|
||||
userId,
|
||||
analysisCount: 0,
|
||||
couponCount: 0,
|
||||
maxAnalyses: 3,
|
||||
maxCoupons: 1,
|
||||
lastResetDate: new Date(),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check limits (default: 10 analyses, 3 coupons per day)
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
const isPremium = user?.subscriptionStatus === "active";
|
||||
|
||||
const maxAnalyses = isPremium ? 50 : 10;
|
||||
const maxCoupons = isPremium ? 10 : 3;
|
||||
// Use plan-aware limits from DB (set by SubscriptionsService.syncLimitsWithPlan)
|
||||
const maxAnalyses = usageLimit.maxAnalyses ?? 3;
|
||||
const maxCoupons = usageLimit.maxCoupons ?? 1;
|
||||
|
||||
if (isCoupon) {
|
||||
return usageLimit.couponCount < maxCoupons;
|
||||
|
||||
@@ -67,7 +67,17 @@ export class AuthController {
|
||||
@Post("logout")
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: "Logout and invalidate refresh token" })
|
||||
@ApiOkResponse({ description: "Logout successful" })
|
||||
@ApiOkResponse({
|
||||
description: "Logout successful",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
message: { type: "string" },
|
||||
data: { type: "null" },
|
||||
},
|
||||
},
|
||||
})
|
||||
async logout(
|
||||
@Body() dto: RefreshTokenDto,
|
||||
@I18n() i18n: I18nContext,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -53,7 +53,18 @@ export class CouponsController {
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Analyze single match with V20 model" })
|
||||
@ApiResponse({ status: 200, description: "Match analysis" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Match analysis",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
data: { type: "object" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
async analyzeMatch(@Body() dto: AnalyzeMatchDto) {
|
||||
const analysis = await this.smartCouponService.analyzeMatch(dto.matchId);
|
||||
if (!analysis) {
|
||||
@@ -99,6 +110,18 @@ export class CouponsController {
|
||||
@ApiOperation({
|
||||
summary: "Generate a high-confidence banko combo (2 matches)",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Daily banko coupon",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
data: { type: "object" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
async getDailyBanko(@Body() dto: DailyBankoDto) {
|
||||
// If no match IDs provided, fetch from system (top 50 upcoming)
|
||||
let candidateMatches = dto.matchIds || [];
|
||||
@@ -146,7 +169,18 @@ export class CouponsController {
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Suggest Smart Coupon" })
|
||||
@ApiResponse({ status: 200, description: "Smart Coupon generated" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Smart Coupon generated",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
data: { type: "object" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
async suggestCoupon(@Body() dto: SuggestCouponDto) {
|
||||
// If no match IDs provided, fetch from system (top 50 upcoming)
|
||||
let candidateMatches = dto.matchIds || [];
|
||||
@@ -237,6 +271,18 @@ export class CouponsController {
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: "Create and save a user coupon" })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: "Coupon created",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
data: { type: "object" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
async createCoupon(@Body() dto: CreateCouponDto, @Req() req: any) {
|
||||
// req.user is populated by JwtAuthGuard
|
||||
const coupon = await this.userCouponService.createCoupon(req.user, dto);
|
||||
@@ -251,6 +297,18 @@ export class CouponsController {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: "Get user betting statistics" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "User statistics",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
data: { type: "object" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
async getUserStats(@Req() req: any) {
|
||||
const stats = await this.userCouponService.getUserStatistics(req.user.id);
|
||||
return { success: true, data: stats };
|
||||
@@ -263,7 +321,18 @@ export class CouponsController {
|
||||
@Get("history")
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: "Get coupon history" })
|
||||
@ApiResponse({ status: 200, description: "History retrieved" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "History retrieved",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
data: { type: "array", items: { type: "object" } },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
async getHistory(@Query("limit") limit?: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
const results = await this.couponsService.getCouponHistory(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -167,7 +167,7 @@ export class FeederPersistenceService {
|
||||
|
||||
const leagueId = this.safeString(league.id);
|
||||
if (leagueId) {
|
||||
const logoUrl = `https://file.mackolikfeeds.com/areas/${leagueId}`;
|
||||
const logoUrl = `https://file.mackolikfeeds.com/competitions/${leagueId}`;
|
||||
const localPath = `public/uploads/competitions/${leagueId}.png`;
|
||||
imageDownloads.push(
|
||||
ImageUtils.downloadImage(logoUrl, localPath)
|
||||
@@ -853,22 +853,89 @@ export class FeederPersistenceService {
|
||||
}
|
||||
|
||||
async getExistingMatchIds(matchIds: string[]): Promise<string[]> {
|
||||
const matches = await this.prisma.match.findMany({
|
||||
where: {
|
||||
id: { in: matchIds },
|
||||
AND: [
|
||||
{ oddCategories: { some: {} } },
|
||||
{
|
||||
OR: [
|
||||
{ footballTeamStats: { some: {} } },
|
||||
{ basketballTeamStats: { some: {} } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return matches.map((m) => m.id);
|
||||
if (matchIds.length === 0) return [];
|
||||
|
||||
// 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 }>>(
|
||||
`
|
||||
SELECT m.id
|
||||
FROM matches m
|
||||
WHERE m.id = ANY($1::text[])
|
||||
AND EXISTS (SELECT 1 FROM odd_categories oc WHERE oc.match_id = m.id)
|
||||
AND (
|
||||
(m.sport = 'football'
|
||||
AND EXISTS (SELECT 1 FROM football_team_stats fts WHERE fts.match_id = m.id)
|
||||
AND (SELECT count(*) FROM match_player_participation mpp
|
||||
WHERE mpp.match_id = m.id AND mpp.is_starting = true) >= 18)
|
||||
OR
|
||||
(m.sport = 'basketball'
|
||||
AND EXISTS (SELECT 1 FROM basketball_team_stats bts WHERE bts.match_id = m.id)
|
||||
AND EXISTS (SELECT 1 FROM basketball_player_stats bps WHERE bps.match_id = m.id))
|
||||
)
|
||||
`,
|
||||
matchIds,
|
||||
);
|
||||
|
||||
return result.map((r) => r.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* For a list of match IDs that ALREADY exist in DB,
|
||||
* returns which data scopes are missing per match.
|
||||
* Only checks completed (Ended) football/basketball matches.
|
||||
*/
|
||||
async getMissingScopes(matchIds: string[]): Promise<Map<string, string[]>> {
|
||||
const result = new Map<string, string[]>();
|
||||
if (matchIds.length === 0) return result;
|
||||
|
||||
// Use raw SQL for performance on Raspberry Pi.
|
||||
// Note: state is 'postGame' in DB, not 'Ended'.
|
||||
const rows = await this.prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
id: string;
|
||||
sport: string;
|
||||
fts_count: bigint;
|
||||
pp_count: bigint;
|
||||
bts_count: bigint;
|
||||
bps_count: bigint;
|
||||
oc_count: bigint;
|
||||
}>
|
||||
>(
|
||||
`
|
||||
SELECT m.id, m.sport::text,
|
||||
(SELECT count(*) FROM football_team_stats fts WHERE fts.match_id = m.id) as fts_count,
|
||||
(SELECT count(*) FROM match_player_participation mpp WHERE mpp.match_id = m.id) as pp_count,
|
||||
(SELECT count(*) FROM basketball_team_stats bts WHERE bts.match_id = m.id) as bts_count,
|
||||
(SELECT count(*) FROM basketball_player_stats bps WHERE bps.match_id = m.id) as bps_count,
|
||||
(SELECT count(*) FROM odd_categories oc WHERE oc.match_id = m.id) as oc_count
|
||||
FROM matches m
|
||||
WHERE m.id = ANY($1::text[])
|
||||
AND m.state = 'postGame'
|
||||
`,
|
||||
matchIds,
|
||||
);
|
||||
|
||||
for (const m of rows) {
|
||||
const missing: string[] = [];
|
||||
|
||||
if (m.sport === "football") {
|
||||
if (Number(m.fts_count) === 0) missing.push("stats");
|
||||
if (Number(m.pp_count) < 18) missing.push("lineups");
|
||||
} else if (m.sport === "basketball") {
|
||||
if (Number(m.bts_count) === 0) missing.push("stats");
|
||||
if (Number(m.bps_count) === 0) missing.push("lineups");
|
||||
}
|
||||
|
||||
if (Number(m.oc_count) === 0) missing.push("odds");
|
||||
|
||||
if (missing.length > 0) {
|
||||
result.set(m.id, missing);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async hasOdds(matchId: string): Promise<boolean> {
|
||||
|
||||
@@ -43,6 +43,14 @@ export class FeederService {
|
||||
private readonly MAX_RETRIES = 50;
|
||||
private readonly DAILY_SYNC_TIME_ZONE = "Europe/Istanbul";
|
||||
|
||||
/** Watchdog heartbeat – updated on every match/date activity */
|
||||
public lastActivityAt: number = Date.now();
|
||||
|
||||
/** Call this to bump the heartbeat */
|
||||
private heartbeat(): void {
|
||||
this.lastActivityAt = Date.now();
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly scraperService: FeederScraperService,
|
||||
private readonly transformerService: FeederTransformerService,
|
||||
@@ -168,7 +176,7 @@ export class FeederService {
|
||||
// writing to live_matches. Historical scan should only fill matches table.
|
||||
endDate.setDate(endDate.getDate() - 2);
|
||||
|
||||
const stateKey = `historical_scan_state_${sports.join("_")}${targetLeagueIds.length > 0 ? "_filtered" : ""}_desc`;
|
||||
const stateKey = `historical_full_data_v2_state_${sports.join("_")}${targetLeagueIds.length > 0 ? "_filtered" : ""}_desc`;
|
||||
let currentDate: Date | null = null;
|
||||
|
||||
// Resume from saved state
|
||||
@@ -259,6 +267,7 @@ export class FeederService {
|
||||
): Promise<void> {
|
||||
const { onlyCompletedMatches = false, refreshExistingMatches = false } =
|
||||
options;
|
||||
this.heartbeat();
|
||||
this.logger.log(`[${sport}] 📅 Processing: ${dateString}`);
|
||||
|
||||
try {
|
||||
@@ -310,9 +319,20 @@ export class FeederService {
|
||||
const { startTs: targetDateStartTs, endTs: targetDateEndTs } =
|
||||
this.getDayBoundsForTimeZone(dateString, this.DAILY_SYNC_TIME_ZONE);
|
||||
|
||||
// DEBUG: Log sample mstUtc values vs target bounds to diagnose filtering
|
||||
if (allMatches.length > 0) {
|
||||
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(", ")}]`,
|
||||
);
|
||||
}
|
||||
|
||||
const dateFilteredMatches = allMatches.filter((m) => {
|
||||
const matchTs = m.mstUtc;
|
||||
return matchTs >= targetDateStartTs && matchTs <= targetDateEndTs;
|
||||
// mstUtc is in milliseconds from API, bounds are in seconds
|
||||
const matchTsSec = Math.floor(m.mstUtc / 1000);
|
||||
return matchTsSec >= targetDateStartTs && matchTsSec <= targetDateEndTs;
|
||||
});
|
||||
|
||||
const apiReturnedCount = allMatches.length;
|
||||
@@ -365,21 +385,76 @@ export class FeederService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Filter out already existing matches to skip processing
|
||||
// 2. Filter out already existing matches & patch incomplete ones
|
||||
const allIds = matchesToProcess.map((m) => m.id);
|
||||
const existingIds =
|
||||
await this.persistenceService.getExistingMatchIds(allIds);
|
||||
const totalCount = matchesToProcess.length;
|
||||
|
||||
// ── Patch incomplete existing matches ──────────────────────
|
||||
// Find matches that ARE in DB but have missing data scopes
|
||||
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...`,
|
||||
);
|
||||
|
||||
for (const [matchId, missingScopes] of allExistingInDb) {
|
||||
const matchSummary = matchesToProcess.find((m) => m.id === matchId);
|
||||
if (!matchSummary) continue;
|
||||
|
||||
for (const scope of missingScopes) {
|
||||
await this.delay(500);
|
||||
try {
|
||||
const patchScope: "all" | "lineups" | "odds" =
|
||||
scope === "odds"
|
||||
? "odds"
|
||||
: scope === "lineups"
|
||||
? "lineups"
|
||||
: "all";
|
||||
|
||||
const result = await this.processSingleMatch(
|
||||
matchSummary,
|
||||
data.competitions,
|
||||
sport,
|
||||
true, // force
|
||||
patchScope,
|
||||
);
|
||||
|
||||
this.heartbeat();
|
||||
if (result.success) {
|
||||
this.logger.log(
|
||||
`[${sport}] ✅ Patched [${scope}] for ${matchId} ${matchSummary.homeTeam.name} vs ${matchSummary.awayTeam.name}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`[${sport}] ⚠️ Patch [${scope}] failed for ${matchId}`,
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.logger.warn(
|
||||
`[${sport}] ❌ Patch [${scope}] exception for ${matchId}: ${e.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Now filter out COMPLETE existing matches (skip them)
|
||||
if (!refreshExistingMatches && existingIds.length > 0) {
|
||||
// Re-check after patching - which ones are now complete?
|
||||
const updatedExistingIds =
|
||||
await this.persistenceService.getExistingMatchIds(allIds);
|
||||
matchesToProcess = matchesToProcess.filter(
|
||||
(m) => !existingIds.includes(m.id),
|
||||
(m) => !updatedExistingIds.includes(m.id),
|
||||
);
|
||||
}
|
||||
|
||||
if (matchesToProcess.length === 0) {
|
||||
this.logger.log(
|
||||
`[${sport}] [${dateString}] All ${totalCount} matches already exist. Skipping...`,
|
||||
`[${sport}] [${dateString}] All ${totalCount} matches processed (${existingIds.length} existed, ${allExistingInDb.size} patched). Done.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -390,7 +465,7 @@ export class FeederService {
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
`[${sport}] [${dateString}] Processing ${matchesToProcess.length}/${totalCount} matches (Skipped ${existingIds.length} existing)`,
|
||||
`[${sport}] [${dateString}] Processing ${matchesToProcess.length}/${totalCount} new matches (${existingIds.length} existing, ${allExistingInDb.size} patched)`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -420,6 +495,7 @@ export class FeederService {
|
||||
refreshExistingMatches,
|
||||
);
|
||||
|
||||
this.heartbeat();
|
||||
if (result.success) {
|
||||
this.logger.log(
|
||||
`[${sport}] ✅ successful for ${match.id} ${match.homeTeam.name} vs ${match.awayTeam.name}`,
|
||||
@@ -432,6 +508,7 @@ export class FeederService {
|
||||
failedMatches.push(match);
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.heartbeat();
|
||||
this.logger.warn(
|
||||
`[${sport}] Sequential error for ${match.id}: ${e.message}`,
|
||||
);
|
||||
@@ -452,7 +529,7 @@ export class FeederService {
|
||||
match,
|
||||
data.competitions,
|
||||
sport,
|
||||
refreshExistingMatches,
|
||||
true, // FORCE: re-fetch incomplete data
|
||||
);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
@@ -753,14 +830,12 @@ export class FeederService {
|
||||
}
|
||||
|
||||
// Starting Formation & Substitutes (Always for lineups or all)
|
||||
// V20 OPTIMIZATION: Disabled to speed up feeder and reduce 502 errors.
|
||||
// We only use Team Stats for V20 model.
|
||||
/*
|
||||
if (scope === 'all' || scope === 'lineups') {
|
||||
if (scope === "all" || scope === "lineups") {
|
||||
// Starting Formation
|
||||
try {
|
||||
const formationData =
|
||||
await this.scraperService.fetchStartingFormation(matchId);
|
||||
const formationData = await fetchResilient("Formation", () =>
|
||||
this.scraperService.fetchStartingFormation(matchId),
|
||||
);
|
||||
if (formationData?.stats) {
|
||||
this.transformerService.processLineup(
|
||||
formationData.stats.home || [],
|
||||
@@ -780,14 +855,15 @@ export class FeederService {
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.message?.includes('502')) hasCriticalError = true;
|
||||
if (e.message?.includes("502")) hasCriticalError = true;
|
||||
this.logger.warn(`[${matchId}] Formation failed: ${e.message}`);
|
||||
}
|
||||
|
||||
// Substitutes
|
||||
try {
|
||||
const subsData =
|
||||
await this.scraperService.fetchSubstitutions(matchId);
|
||||
const subsData = await fetchResilient("Subs", () =>
|
||||
this.scraperService.fetchSubstitutions(matchId),
|
||||
);
|
||||
if (subsData?.stats) {
|
||||
this.transformerService.processLineup(
|
||||
subsData.stats.home || [],
|
||||
@@ -807,11 +883,10 @@ export class FeederService {
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.message?.includes('502')) hasCriticalError = true;
|
||||
if (e.message?.includes("502")) hasCriticalError = true;
|
||||
this.logger.warn(`[${matchId}] Subs failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// Game Stats & Officials
|
||||
if (scope === "all") {
|
||||
@@ -869,7 +944,37 @@ export class FeederService {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Persist to Database
|
||||
// ── Pre-save completeness gate ──────────────────────────────
|
||||
// If a 502 caused missing data, do NOT save. The data exists on
|
||||
// the API and will be available shortly. Skip and retry instead.
|
||||
const completedMatch = isMatchCompleted({
|
||||
state: headerData?.matchStatus ?? matchSummary.state,
|
||||
status: matchSummary.status,
|
||||
substate: matchSummary.substate,
|
||||
statusBoxContent: matchSummary.statusBoxContent,
|
||||
scoreHome: headerData?.scoreHome ?? matchSummary.score?.home,
|
||||
scoreAway: headerData?.scoreAway ?? matchSummary.score?.away,
|
||||
});
|
||||
|
||||
const missingParts: string[] = [];
|
||||
if (scope === "all" && completedMatch) {
|
||||
if (sport === "football" && !stats) missingParts.push("Stats");
|
||||
if (sport === "football" && participationData.length < 18)
|
||||
missingParts.push("Lineups");
|
||||
if (sport === "basketball" && !basketballTeamStats)
|
||||
missingParts.push("BoxScore");
|
||||
if (oddsArray.length === 0) missingParts.push("Odds");
|
||||
}
|
||||
|
||||
// 502 caused missing data → do NOT save, retry later
|
||||
if (hasCriticalError && missingParts.length > 0) {
|
||||
this.logger.warn(
|
||||
`[${matchId}] ⛔ SKIPPED SAVE: 502 errors caused missing [${missingParts.join(", ")}]. Will retry for complete data.`,
|
||||
);
|
||||
return { success: false, retryable: true };
|
||||
}
|
||||
|
||||
// 4. SAVE
|
||||
let saved = false;
|
||||
if (scope === "lineups") {
|
||||
saved = await this.persistenceService.saveLineups(
|
||||
@@ -923,32 +1028,11 @@ export class FeederService {
|
||||
*/
|
||||
// ==========================================
|
||||
|
||||
const completedMatch = isMatchCompleted({
|
||||
state: headerData?.matchStatus ?? matchSummary.state,
|
||||
status: matchSummary.status,
|
||||
substate: matchSummary.substate,
|
||||
statusBoxContent: matchSummary.statusBoxContent,
|
||||
scoreHome: headerData?.scoreHome ?? matchSummary.score?.home,
|
||||
scoreAway: headerData?.scoreAway ?? matchSummary.score?.away,
|
||||
});
|
||||
|
||||
const missingParts: string[] = [];
|
||||
if (scope === "all" && completedMatch) {
|
||||
if (sport === "football" && !stats) missingParts.push("Stats");
|
||||
if (sport === "basketball" && !basketballTeamStats)
|
||||
missingParts.push("BoxScore");
|
||||
if (oddsArray.length === 0) missingParts.push("Odds");
|
||||
}
|
||||
|
||||
if (saved && (hasCriticalError || missingParts.length > 0)) {
|
||||
const reason = hasCriticalError
|
||||
? "missing data after upstream errors"
|
||||
: "incomplete completed-match payload";
|
||||
|
||||
// No 502 but data genuinely missing → save anyway, log warning
|
||||
if (saved && missingParts.length > 0) {
|
||||
this.logger.warn(
|
||||
`[${matchId}] Saved with ${reason}. Missing: [${missingParts.join(", ")}]. Scheduled for retry.`,
|
||||
`[${matchId}] Saved but data genuinely missing (no 502): [${missingParts.join(", ")}]`,
|
||||
);
|
||||
return { success: false, retryable: true };
|
||||
}
|
||||
|
||||
return { success: saved, retryable: !saved };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user