33 Commits

Author SHA1 Message Date
fahricansecer f3362f266c gg 2026-05-10 22:52:05 +03:00
fahricansecer c525b12dfd gg 2026-05-10 10:37:45 +03:00
fahricansecer 4f7090e2d9 feat(ai-engine): value sniper thresholds and logic relaxed 2026-05-06 17:44:45 +03:00
fahricansecer 5b5f83c8cf fix(ai-engine): remove target leakage from training data extraction
Deploy Iddaai Backend / build-and-deploy (push) Successful in 6s
- goals_form now uses avg of last 5 historical matches instead of current match goals
- squad_quality removes current match goals/assists, uses only pre-match known data
- adds temporal filtering via match_id -> mst_utc mapping
2026-05-05 22:35:04 +03:00
fahricansecer bfddcaca7d gg
Deploy Iddaai Backend / build-and-deploy (push) Successful in 6s
2026-05-05 21:27:06 +03:00
fahricansecer 56d560af08 Update single_match_orchestrator.py
Deploy Iddaai Backend / build-and-deploy (push) Successful in 8s
2026-05-05 20:59:59 +03:00
fahricansecer 4bc51cfa99 fix(ai-engine): hoist ms_edge before score prediction branch to prevent UnboundLocalError
Deploy Iddaai Backend / build-and-deploy (push) Successful in 5s
2026-05-05 20:34:14 +03:00
fahricansecer fdb8a5d0f0 fix(ai-engine): sync FEATURE_COLS with trained models (82→102 features)
Deploy Iddaai Backend / build-and-deploy (push) Successful in 6s
- Load feature columns dynamically from feature_cols.json
- Add 20 missing odds_*_present boolean flags to fallback list
- Fixes LightGBM 'features in data (82) != training data (102)' crash
2026-05-05 20:29:55 +03:00
fahricansecer 22596e69f2 fix(predictions): circuit breaker resilience + graceful degradation
Deploy Iddaai Backend / build-and-deploy (push) Successful in 27s
- Reset consecutiveFailures on cooldown expiry (half-open state)
  so a single retry failure doesn't immediately re-open the circuit
- Exclude AI Engine app-level 500s from circuit breaker count
  (only network/infra errors: timeout, 502, 503, 504, 429)
- Return null gracefully instead of throwing 503 when no cache exists
- Add DB fallback for non-cooldown AI Engine failures
- Remove blocking wait-and-retry that held requests for up to 20s
2026-05-05 20:19:25 +03:00
fahricansecer f32badbd8f fix(predictions): cooldown fallback cascade + circuit breaker tuning
Deploy Iddaai Backend / build-and-deploy (push) Successful in 27s
- Add 4-level fallback when AI circuit breaker fires cooldown:
  1) In-memory cache (10min TTL)
  2) DB stored prediction (no TTL filter)
  3) DB cached prediction (with model version check)
  4) Wait out cooldown + retry once (max 20s wait)
- Raise circuit breaker threshold from 3 to 5 consecutive failures
- Reduce cooldown duration from 30s to 15s for faster recovery
- Add extractCooldownMs helper to parse remaining ms from error detail
2026-05-05 20:11:19 +03:00
fahricansecer 5645b38f20 main
Deploy Iddaai Backend / build-and-deploy (push) Successful in 32s
2026-05-05 17:09:11 +03:00
fahricansecer 244d8f5366 feat(ai): expand training to 68K+ matches, add score model, backfill implied odds
Deploy Iddaai Backend / build-and-deploy (push) Successful in 6s
- extract_training_data.py: switch from top_leagues.json (23) to qualified_leagues.json (265)
- update_implied_odds.py: new script to backfill implied odds from real market data
- train_score_model.py: rewrite with v25 102-feature set + temporal split
- single_match_orchestrator.py: integrate ML score model with heuristic fallback
2026-05-05 16:04:00 +03:00
fahricansecer 9bb8f39bca gg
Deploy Iddaai Backend / build-and-deploy (push) Successful in 2m45s
2026-05-05 14:06:20 +03:00
fahricansecer 7a1cf14e2f Update matches.service.ts
Deploy Iddaai Backend / build-and-deploy (push) Successful in 28s
2026-05-05 10:47:00 +03:00
fahricansecer 62c797d299 Update matches.service.ts
Deploy Iddaai Backend / build-and-deploy (push) Successful in 29s
2026-05-05 10:13:23 +03:00
fahricansecer 34cc4a6cbb Update matches.service.ts
Deploy Iddaai Backend / build-and-deploy (push) Successful in 30s
2026-05-05 01:04:56 +03:00
fahricansecer 27e96da31d main
Deploy Iddaai Backend / build-and-deploy (push) Successful in 29s
2026-05-04 18:00:40 +03:00
fahricansecer 145a8b336b fix(feeder): preserve pre-match odds when match goes live
Deploy Iddaai Backend / build-and-deploy (push) Successful in 29s
Live odds have missing selections (e.g. '1' key removed from Maç Sonucu
after kickoff), causing the AI model to produce wildly incorrect predictions
(e.g. 3.5% home win for Bristol City). Two guards added:

1. fetchOddsForMatches: Exclude live/finished matches from odds fetch query
2. processMatchOdds: Skip odds/lineups/sidelined overwrite if match already
   has pre-match odds and is live/finished
2026-05-02 16:32:42 +03:00
fahricansecer 7a8960edb8 chore: remove debug checkpoint logs and temp SQL files
Deploy Iddaai Backend / build-and-deploy (push) Successful in 37s
2026-04-26 17:09:22 +03:00
fahricansecer 691c52f610 perf: replace Prisma relation queries with raw SQL for getExistingMatchIds and getMissingScopes - fixes Pi hang
Deploy Iddaai Backend / build-and-deploy (push) Successful in 39s
2026-04-26 17:07:19 +03:00
fahricansecer bc461429f6 debug: add checkpoint timestamps to processDate for hang diagnosis
Deploy Iddaai Backend / build-and-deploy (push) Successful in 46s
2026-04-26 17:04:46 +03:00
fahricansecer a338d02244 main
Deploy Iddaai Backend / build-and-deploy (push) Successful in 2m42s
2026-04-26 03:07:18 +03:00
fahricansecer 1623432039 fix: watchdog force-kill with SIGKILL fallback when process.exit is blocked 2026-04-26 02:27:51 +03:00
fahricansecer 4c7930e9d2 feat: add watchdog timer to detect and recover from hung API requests
Deploy Iddaai Backend / build-and-deploy (push) Successful in 27s
2026-04-25 11:20:30 +03:00
fahricansecer ec463cb927 fix: make canvas import optional for ARM64 compatibility 2026-04-25 02:41:53 +03:00
fahricansecer eab95c4e5c Update feeder.service.ts
Deploy Iddaai Backend / build-and-deploy (push) Successful in 30s
2026-04-25 02:23:38 +03:00
fahricansecer 9027cc9900 v28
Deploy Iddaai Backend / build-and-deploy (push) Successful in 3m21s
2026-04-24 23:46:28 +03:00
fahricansecer 3875f2a512 Create v28-pro-max-architecture.md
Deploy Iddaai Backend / build-and-deploy (push) Successful in 27s
2026-04-24 02:30:26 +03:00
fahricansecer 300dceeb4b Merge branch 'main' of https://gitea.bilgich.com/fahricansecer/iddaai-be
Deploy Iddaai Backend / build-and-deploy (push) Successful in 27s
2026-04-24 02:10:48 +03:00
fahricansecer ad01976fb9 fix: lineup data normalization + tomorrow match sync + player field mapping 2026-04-24 02:09:58 +03:00
fahricansecer 6880eb92f5 Merge pull request 'v26-shadow' (#4) from v26-shadow into main
Deploy Iddaai Backend / build-and-deploy (push) Successful in 27s
Reviewed-on: #4
2026-04-24 01:15:54 +03:00
fahricansecer 9e2edd590c Merge branch 'main' into v26-shadow 2026-04-24 01:15:18 +03:00
fahricansecer b5c2edf346 gg 2026-04-24 01:15:05 +03:00
135 changed files with 13769 additions and 435818 deletions
+6 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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.
-871
View File
@@ -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 iter Logloss
2 0 0.6924099937
3 1 0.6916660956
4 2 0.691108145
5 3 0.6904585078
6 4 0.689812816
7 5 0.689192261
8 6 0.6886032715
9 7 0.6880706742
10 8 0.6876192378
11 9 0.6870868859
12 10 0.6865493528
13 11 0.686105086
14 12 0.6856345086
15 13 0.6852027185
16 14 0.6848238481
17 15 0.6844045699
18 16 0.6840077621
19 17 0.6836197496
20 18 0.6832475033
21 19 0.6829012069
22 20 0.6825880966
23 21 0.6822424968
24 22 0.6819180513
25 23 0.6816384467
26 24 0.6813262593
27 25 0.6810353411
28 26 0.6808138172
29 27 0.6805550049
30 28 0.680347991
31 29 0.680089679
32 30 0.6798451919
33 31 0.6796090443
34 32 0.6793890865
35 33 0.6791683772
36 34 0.6789766369
37 35 0.6787930242
38 36 0.6786087714
39 37 0.6784161299
40 38 0.6782227897
41 39 0.6780242369
42 40 0.6778499631
43 41 0.6776975784
44 42 0.6775231674
45 43 0.6773582124
46 44 0.6772234666
47 45 0.6770659843
48 46 0.6769049529
49 47 0.6767664194
50 48 0.6766584917
51 49 0.6765507257
52 50 0.6764489911
53 51 0.6763947956
54 52 0.6762778712
55 53 0.6761865366
56 54 0.6760679685
57 55 0.6759774874
58 56 0.6758500622
59 57 0.6757625065
60 58 0.6756876412
61 59 0.6756151069
62 60 0.6755303655
63 61 0.6754565036
64 62 0.6753738983
65 63 0.6752897299
66 64 0.6752115539
67 65 0.6751595431
68 66 0.6750764658
69 67 0.6750179194
70 68 0.6749408803
71 69 0.6748795802
72 70 0.674790372
73 71 0.6747239773
74 72 0.6746701254
75 73 0.6746120937
76 74 0.6745550085
77 75 0.6744855074
78 76 0.6744264172
79 77 0.674381715
80 78 0.6743331681
81 79 0.67428564
82 80 0.6742202413
83 81 0.6741620971
84 82 0.6741109453
85 83 0.6740556003
86 84 0.6740146772
87 85 0.673983295
88 86 0.6739595301
89 87 0.6739336659
90 88 0.673890361
91 89 0.673863586
92 90 0.6738190616
93 91 0.6737799295
94 92 0.6737364374
95 93 0.6737093719
96 94 0.6736630475
97 95 0.67364367
98 96 0.6735998081
99 97 0.6735526984
100 98 0.6735012924
101 99 0.6734818024
102 100 0.6734379341
103 101 0.6734059869
104 102 0.6733740852
105 103 0.6733330971
106 104 0.6733060254
107 105 0.6732755898
108 106 0.6732294722
109 107 0.6732035176
110 108 0.673196437
111 109 0.6731652709
112 110 0.673138808
113 111 0.6731062725
114 112 0.6730726625
115 113 0.6730285927
116 114 0.6729872702
117 115 0.6729721425
118 116 0.6729564624
119 117 0.6729312424
120 118 0.6729354345
121 119 0.6729085401
122 120 0.6728898322
123 121 0.6728773638
124 122 0.6728618874
125 123 0.6728540413
126 124 0.6728441291
127 125 0.672815631
128 126 0.6728082021
129 127 0.6727900064
130 128 0.6727649552
131 129 0.6727467657
132 130 0.6727396032
133 131 0.6727245271
134 132 0.6726955143
135 133 0.67269209
136 134 0.672677932
137 135 0.6726540285
138 136 0.6726288583
139 137 0.6725863431
140 138 0.6725837967
141 139 0.6725772977
142 140 0.6725685594
143 141 0.6725553829
144 142 0.6725484347
145 143 0.6725306172
146 144 0.672543149
147 145 0.6725196247
148 146 0.6725226452
149 147 0.6725056913
150 148 0.6724771476
151 149 0.6724439435
152 150 0.672442532
153 151 0.6724303064
154 152 0.6724235788
155 153 0.6724294499
156 154 0.6724285935
157 155 0.6724172017
158 156 0.6724130745
159 157 0.6723860878
160 158 0.6723707604
161 159 0.6723566111
162 160 0.6723469906
163 161 0.6723287161
164 162 0.6723155898
165 163 0.6722970834
166 164 0.6722872244
167 165 0.6722800481
168 166 0.6722550973
169 167 0.6722394313
170 168 0.6722204135
171 169 0.6721982148
172 170 0.6721971176
173 171 0.6721880705
174 172 0.672179176
175 173 0.6721769709
176 174 0.6721693215
177 175 0.6721581386
178 176 0.6721638661
179 177 0.6721598475
180 178 0.6721433342
181 179 0.6721335599
182 180 0.6721300594
183 181 0.6721153533
184 182 0.6721076397
185 183 0.6721009911
186 184 0.6720999252
187 185 0.6720953028
188 186 0.6720942505
189 187 0.6720856237
190 188 0.6720876136
191 189 0.6720880182
192 190 0.6720743856
193 191 0.6720598415
194 192 0.6720563492
195 193 0.6720389527
196 194 0.6720317324
197 195 0.672000736
198 196 0.6719895017
199 197 0.6719725302
200 198 0.6719770493
201 199 0.6719667172
202 200 0.6719511616
203 201 0.6719427289
204 202 0.6719299116
205 203 0.6719106583
206 204 0.6718967065
207 205 0.671890967
208 206 0.6718896293
209 207 0.6718883534
210 208 0.6718827289
211 209 0.6718763224
212 210 0.67187262
213 211 0.6718590402
214 212 0.6718455115
215 213 0.6718253747
216 214 0.671794877
217 215 0.6717873786
218 216 0.6717765089
219 217 0.6717616726
220 218 0.6717499215
221 219 0.6717326052
222 220 0.6717161937
223 221 0.6717056951
224 222 0.6717021438
225 223 0.6716868488
226 224 0.6716751909
227 225 0.671670116
228 226 0.6716558757
229 227 0.6716559962
230 228 0.6716487875
231 229 0.6716427451
232 230 0.6716323255
233 231 0.6716303547
234 232 0.6716309509
235 233 0.6716215401
236 234 0.6716162103
237 235 0.6716135097
238 236 0.6716156696
239 237 0.6716020054
240 238 0.6715921704
241 239 0.6715804466
242 240 0.6715882966
243 241 0.6715753942
244 242 0.6715752261
245 243 0.6715625509
246 244 0.6715628214
247 245 0.6715601629
248 246 0.6715576255
249 247 0.6715550274
250 248 0.6715448645
251 249 0.6715308166
252 250 0.671519334
253 251 0.6715184071
254 252 0.6715163019
255 253 0.6715096094
256 254 0.6714992963
257 255 0.6714917256
258 256 0.671477406
259 257 0.6714741542
260 258 0.6714576155
261 259 0.6714473645
262 260 0.6714427232
263 261 0.6714364275
264 262 0.6714339587
265 263 0.6714336287
266 264 0.6714283568
267 265 0.6714271895
268 266 0.671413471
269 267 0.6714072396
270 268 0.6714002677
271 269 0.6714001163
272 270 0.6713933952
273 271 0.6713926761
274 272 0.6713836619
275 273 0.6713772112
276 274 0.6713603715
277 275 0.6713560246
278 276 0.6713837913
279 277 0.6713684274
280 278 0.6713619356
281 279 0.6713584836
282 280 0.6713673572
283 281 0.6713625568
284 282 0.6713542652
285 283 0.6713512017
286 284 0.671342038
287 285 0.6713279798
288 286 0.6713123285
289 287 0.6713035326
290 288 0.6713022203
291 289 0.671296041
292 290 0.6712829551
293 291 0.6712769751
294 292 0.6712702915
295 293 0.6712379343
296 294 0.6712192006
297 295 0.6712074061
298 296 0.6711953324
299 297 0.6711891001
300 298 0.6711870526
301 299 0.6711812809
302 300 0.6711768946
303 301 0.6711845012
304 302 0.6711869636
305 303 0.671186884
306 304 0.6711890401
307 305 0.6711868603
308 306 0.6711900892
309 307 0.6711884242
310 308 0.6711837119
311 309 0.6711766645
312 310 0.671172959
313 311 0.6711740433
314 312 0.6711715069
315 313 0.6711589843
316 314 0.6711446402
317 315 0.6711415366
318 316 0.6711359351
319 317 0.671143361
320 318 0.6711353638
321 319 0.6711444387
322 320 0.6711487352
323 321 0.67114436
324 322 0.6711444722
325 323 0.6711325635
326 324 0.6711269403
327 325 0.6711154078
328 326 0.6711203043
329 327 0.6711241333
330 328 0.6711213497
331 329 0.6711231641
332 330 0.6711049215
333 331 0.6711031963
334 332 0.6710996314
335 333 0.6710867309
336 334 0.6710914578
337 335 0.6710929585
338 336 0.6710984779
339 337 0.6710923199
340 338 0.6710893917
341 339 0.6710923306
342 340 0.6710927901
343 341 0.6711092802
344 342 0.6711012995
345 343 0.6711015305
346 344 0.6710975574
347 345 0.6710899474
348 346 0.671085152
349 347 0.6710814533
350 348 0.6710701892
351 349 0.67105503
352 350 0.6710527861
353 351 0.6710508715
354 352 0.6710560803
355 353 0.6710465693
356 354 0.6710440741
357 355 0.6710496913
358 356 0.6710404659
359 357 0.6710293986
360 358 0.6710353817
361 359 0.6710271815
362 360 0.6710288077
363 361 0.6710169894
364 362 0.6710119848
365 363 0.6710114775
366 364 0.6710013614
367 365 0.6709985657
368 366 0.6709948954
369 367 0.6709970591
370 368 0.6709739289
371 369 0.6709754911
372 370 0.6709717066
373 371 0.67096845
374 372 0.6709739445
375 373 0.6709728881
376 374 0.6709694284
377 375 0.6709604166
378 376 0.6709605025
379 377 0.6709603727
380 378 0.670944339
381 379 0.6709447187
382 380 0.6709538679
383 381 0.6709640912
384 382 0.6709534847
385 383 0.6709471555
386 384 0.6709506783
387 385 0.6709546729
388 386 0.670930774
389 387 0.6709287322
390 388 0.6709198643
391 389 0.6709220389
392 390 0.6709230923
393 391 0.670930414
394 392 0.6709354296
395 393 0.6709351544
396 394 0.6709414935
397 395 0.6709445943
398 396 0.6709475685
399 397 0.6709533591
400 398 0.6709592222
401 399 0.6709508704
402 400 0.6709479912
403 401 0.6709417519
404 402 0.6709476082
405 403 0.6709480979
406 404 0.6709448724
407 405 0.6709421934
408 406 0.6709386261
409 407 0.6709461564
410 408 0.670934384
411 409 0.6709312987
412 410 0.670931806
413 411 0.6709286111
414 412 0.6709224729
415 413 0.6709236504
416 414 0.6709245901
417 415 0.6709463437
418 416 0.6709567049
419 417 0.670945606
420 418 0.6709479298
421 419 0.6709464351
422 420 0.6709414048
423 421 0.6709414427
424 422 0.6709296343
425 423 0.670924721
426 424 0.670906284
427 425 0.6708996826
428 426 0.6708987677
429 427 0.670909526
430 428 0.6709033226
431 429 0.6708750209
432 430 0.6708752079
433 431 0.6708776566
434 432 0.6708736133
435 433 0.6708754298
436 434 0.6708751084
437 435 0.6708642042
438 436 0.6708610465
439 437 0.6708574768
440 438 0.6708557953
441 439 0.670871378
442 440 0.6708640187
443 441 0.6708700565
444 442 0.6708667534
445 443 0.6708675383
446 444 0.6708740175
447 445 0.6708774523
448 446 0.6708697231
449 447 0.6708614971
450 448 0.6708607946
451 449 0.6708740865
452 450 0.6708729562
453 451 0.6708674017
454 452 0.6708693088
455 453 0.6708712037
456 454 0.6708703905
457 455 0.6708577595
458 456 0.6708493546
459 457 0.6708523777
460 458 0.6708454134
461 459 0.6708404483
462 460 0.6708274771
463 461 0.6708244992
464 462 0.6708344314
465 463 0.6708279081
466 464 0.6708258106
467 465 0.6708049714
468 466 0.670810989
469 467 0.6708212237
470 468 0.6708221741
471 469 0.6708259658
472 470 0.6708159692
473 471 0.6708136212
474 472 0.6708224942
475 473 0.6708363084
476 474 0.670850875
477 475 0.6708527236
478 476 0.6708453401
479 477 0.6708413844
480 478 0.6708364569
481 479 0.6708251774
482 480 0.6708154393
483 481 0.6708111613
484 482 0.6708102339
485 483 0.6707929623
486 484 0.6707900226
487 485 0.6707832384
488 486 0.6707739118
489 487 0.6707737538
490 488 0.6707730234
491 489 0.6707796291
492 490 0.670791408
493 491 0.6707944906
494 492 0.6707835635
495 493 0.6707908928
496 494 0.670796262
497 495 0.6707877825
498 496 0.6707854132
499 497 0.6707756206
500 498 0.6707707899
501 499 0.6707704386
502 500 0.6707621465
503 501 0.6707661931
504 502 0.6707651988
505 503 0.6707607827
506 504 0.670760242
507 505 0.6707506008
508 506 0.6707452886
509 507 0.6707355189
510 508 0.6707312551
511 509 0.6707199485
512 510 0.6707131947
513 511 0.6707154112
514 512 0.6706982346
515 513 0.6706988941
516 514 0.6706989098
517 515 0.670693306
518 516 0.6706944515
519 517 0.6706899688
520 518 0.6706909374
521 519 0.6706855074
522 520 0.6706787779
523 521 0.6706737082
524 522 0.6706761225
525 523 0.670685455
526 524 0.6706693855
527 525 0.6706647216
528 526 0.6706569188
529 527 0.6706549134
530 528 0.6706547978
531 529 0.6706564214
532 530 0.6706559196
533 531 0.6706515072
534 532 0.6706474616
535 533 0.6706424204
536 534 0.6706520008
537 535 0.6706448306
538 536 0.6706415789
539 537 0.6706305359
540 538 0.6706152774
541 539 0.670616585
542 540 0.6705963243
543 541 0.6706027368
544 542 0.6706003522
545 543 0.6706044301
546 544 0.6706047241
547 545 0.6706038235
548 546 0.6706026913
549 547 0.6705845786
550 548 0.6705873967
551 549 0.6705755426
552 550 0.6705715731
553 551 0.6705757153
554 552 0.6705516814
555 553 0.6705530864
556 554 0.6705552479
557 555 0.6705563336
558 556 0.6705718544
559 557 0.6705688384
560 558 0.6705641528
561 559 0.6705628467
562 560 0.670558488
563 561 0.6705544404
564 562 0.6705617451
565 563 0.6705631717
566 564 0.6705636201
567 565 0.6705537522
568 566 0.670555083
569 567 0.6705524541
570 568 0.6705503132
571 569 0.6705354602
572 570 0.6705387012
573 571 0.6705411923
574 572 0.6705390018
575 573 0.6705354939
576 574 0.670531296
577 575 0.6705377163
578 576 0.6705248875
579 577 0.6705252902
580 578 0.6705181562
581 579 0.6705123446
582 580 0.6705128345
583 581 0.6705173712
584 582 0.670541941
585 583 0.6705463243
586 584 0.6705513215
587 585 0.6705455889
588 586 0.6705408087
589 587 0.6705510193
590 588 0.6705456751
591 589 0.6705402427
592 590 0.6705443402
593 591 0.67054441
594 592 0.6705441955
595 593 0.6705319356
596 594 0.6705358843
597 595 0.6705334396
598 596 0.6705320462
599 597 0.6705332043
600 598 0.6705328363
601 599 0.6705315638
602 600 0.6705274435
603 601 0.670509808
604 602 0.6705077789
605 603 0.6705212132
606 604 0.6705098442
607 605 0.6705061509
608 606 0.6705003071
609 607 0.6705045031
610 608 0.6705083194
611 609 0.6705329997
612 610 0.6705269987
613 611 0.6705315607
614 612 0.6705142835
615 613 0.6705165015
616 614 0.6705001061
617 615 0.6705013916
618 616 0.6705037253
619 617 0.67049647
620 618 0.6705005632
621 619 0.6704957943
622 620 0.6704955333
623 621 0.6704961207
624 622 0.6704921459
625 623 0.6704751713
626 624 0.6704753101
627 625 0.6704620888
628 626 0.6704604282
629 627 0.6704663192
630 628 0.6704680085
631 629 0.670453228
632 630 0.6704577785
633 631 0.67046675
634 632 0.6704731863
635 633 0.6704811116
636 634 0.6704839644
637 635 0.6704854798
638 636 0.6704835837
639 637 0.6704736198
640 638 0.6704640242
641 639 0.670465663
642 640 0.6704646829
643 641 0.6704600961
644 642 0.6704643207
645 643 0.6704600533
646 644 0.6704614691
647 645 0.6704728212
648 646 0.6704758731
649 647 0.6704833026
650 648 0.6704767664
651 649 0.6704702727
652 650 0.6704671372
653 651 0.6704699936
654 652 0.6704587989
655 653 0.6704637668
656 654 0.6704653717
657 655 0.6704598273
658 656 0.6704522865
659 657 0.6704558586
660 658 0.6704466331
661 659 0.6704405886
662 660 0.6704463767
663 661 0.6704475216
664 662 0.6704572386
665 663 0.6704658153
666 664 0.6704600945
667 665 0.6704561998
668 666 0.6704535154
669 667 0.6704413781
670 668 0.6704450013
671 669 0.6704422199
672 670 0.67044342
673 671 0.6704415341
674 672 0.6704439539
675 673 0.6704498197
676 674 0.6704452194
677 675 0.6704366524
678 676 0.6704427124
679 677 0.6704395579
680 678 0.6704401246
681 679 0.6704415621
682 680 0.6704341343
683 681 0.6704369615
684 682 0.6704357425
685 683 0.6704294622
686 684 0.6704289794
687 685 0.6704272409
688 686 0.6704101162
689 687 0.6704069439
690 688 0.6704100747
691 689 0.6704122261
692 690 0.6704137826
693 691 0.6704207952
694 692 0.6704154834
695 693 0.6704253514
696 694 0.6704155636
697 695 0.6704141298
698 696 0.6704207635
699 697 0.6704268341
700 698 0.6704243126
701 699 0.6704235165
702 700 0.6704257736
703 701 0.6704247758
704 702 0.6704331799
705 703 0.6704252722
706 704 0.6704146644
707 705 0.6704164122
708 706 0.6704118954
709 707 0.6704043129
710 708 0.6703978198
711 709 0.6703935976
712 710 0.6703839683
713 711 0.6703843723
714 712 0.6703879502
715 713 0.6703895978
716 714 0.6703894359
717 715 0.6703928777
718 716 0.6703933128
719 717 0.6703844355
720 718 0.6703825151
721 719 0.6703983542
722 720 0.670399556
723 721 0.6703931808
724 722 0.6703886918
725 723 0.6703847574
726 724 0.6703885941
727 725 0.6703788615
728 726 0.6703799906
729 727 0.6703774518
730 728 0.6703783496
731 729 0.6703648854
732 730 0.6703716654
733 731 0.6703550938
734 732 0.6703467057
735 733 0.6703484503
736 734 0.6703549183
737 735 0.6703501504
738 736 0.6703672622
739 737 0.6703560249
740 738 0.6703547155
741 739 0.6703593236
742 740 0.6703606827
743 741 0.6703511404
744 742 0.6703431646
745 743 0.6703475116
746 744 0.6703483634
747 745 0.6703475713
748 746 0.670360457
749 747 0.6703664352
750 748 0.6703617612
751 749 0.6703669926
752 750 0.6703670837
753 751 0.6703706628
754 752 0.670369618
755 753 0.6703692351
756 754 0.6703624433
757 755 0.6703686285
758 756 0.6703598432
759 757 0.6703618766
760 758 0.6703694148
761 759 0.6703683652
762 760 0.6703604855
763 761 0.6703758987
764 762 0.6703773302
765 763 0.6703641028
766 764 0.6703649602
767 765 0.6703567811
768 766 0.6703544688
769 767 0.6703611821
770 768 0.6703527821
771 769 0.6703523616
772 770 0.6703616298
773 771 0.6703603551
774 772 0.6703675655
775 773 0.6703582411
776 774 0.6703581437
777 775 0.6703551885
778 776 0.6703608491
779 777 0.6703674554
780 778 0.6703679619
781 779 0.6703701757
782 780 0.6703603462
783 781 0.670359801
784 782 0.6703523669
785 783 0.6703365674
786 784 0.6703486118
787 785 0.6703450011
788 786 0.6703473135
789 787 0.670350998
790 788 0.6703417767
791 789 0.6703349821
792 790 0.6703457717
793 791 0.6703506266
794 792 0.6703596395
795 793 0.6703799895
796 794 0.6703687687
797 795 0.6703780675
798 796 0.670374835
799 797 0.6703831387
800 798 0.670377656
801 799 0.6703689741
802 800 0.6703709756
803 801 0.6703737517
804 802 0.6703818964
805 803 0.6703812173
806 804 0.6703960068
807 805 0.6703976729
808 806 0.6704024604
809 807 0.6704085008
810 808 0.6704076633
811 809 0.6704111719
812 810 0.6704220803
813 811 0.6704265011
814 812 0.6704251162
815 813 0.6704375472
816 814 0.6704319336
817 815 0.670437614
818 816 0.6704554331
819 817 0.6704603317
820 818 0.6704548042
821 819 0.6704502654
822 820 0.6704512072
823 821 0.6704433481
824 822 0.6704095112
825 823 0.6704086019
826 824 0.6703987131
827 825 0.6704019708
828 826 0.6704046556
829 827 0.6704091961
830 828 0.6704103204
831 829 0.6704074257
832 830 0.6704115335
833 831 0.6704041275
834 832 0.6704004556
835 833 0.6704037097
836 834 0.6704035477
837 835 0.6704025281
838 836 0.6704024549
839 837 0.670405721
840 838 0.6703983189
841 839 0.6704035256
842 840 0.6704047613
843 841 0.6704139617
844 842 0.6704066193
845 843 0.670402892
846 844 0.6704081961
847 845 0.6704029862
848 846 0.6704014281
849 847 0.6704036115
850 848 0.6704016777
851 849 0.6704085392
852 850 0.6704042952
853 851 0.670394789
854 852 0.6703885644
855 853 0.6703946813
856 854 0.6704042137
857 855 0.6704077517
858 856 0.6704118698
859 857 0.6704114259
860 858 0.6704157567
861 859 0.6703837005
862 860 0.6703829482
863 861 0.6703818491
864 862 0.6703826129
865 863 0.6703834487
866 864 0.6703868275
867 865 0.6703853357
868 866 0.670450644
869 867 0.6704556991
870 868 0.6704535742
871 869 0.6704495915
+69
View File
@@ -1,7 +1,9 @@
import os
import json
import yaml
from typing import Dict, Any, Optional
class EnsembleConfig:
_instance: Optional['EnsembleConfig'] = None
_config: Dict[str, Any] = {}
@@ -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')}")
+115
View File
@@ -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
}
}
+38 -11
View File
@@ -18,10 +18,15 @@ 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
@@ -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%
+16 -16
View File
@@ -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
+11 -5
View File
@@ -14,10 +14,13 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel
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()
if HAS_BASKETBALL:
basketball_predictor = get_basketball_v25_predictor()
basketball_readiness = basketball_predictor.readiness_summary()
ready = bool(basketball_readiness["fully_loaded"])
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",
+413
View File
@@ -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
+676
View File
@@ -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
+343
View File
@@ -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
-902
View File
@@ -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,,,,,,,,,,,,
1 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
2 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
3 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
4 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
5 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
6 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
7 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
8 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
9 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
10 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
11 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
12 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
13 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
14 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
15 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
16 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
17 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
18 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
19 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
20 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
21 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
22 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
23 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
24 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
25 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
26 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
27 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
28 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
29 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
30 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
31 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
32 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
33 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
34 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
35 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
}
}
}
}
-206
View File
@@ -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()
-240
View File
@@ -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()
-191
View File
@@ -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()
+146
View File
@@ -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")
-145
View File
@@ -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()
-215
View File
@@ -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")
-223
View File
@@ -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()
-231
View File
@@ -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()
-164
View File
@@ -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()
-162
View File
@@ -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()
-94
View File
@@ -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()
-230
View File
@@ -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())
-147
View File
@@ -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()
-153
View File
@@ -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()
-136
View File
@@ -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()
-141
View File
@@ -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()
-159
View File
@@ -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()
-182
View File
@@ -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()
+23 -7
View File
@@ -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,
))
+459
View File
@@ -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()
+254 -24
View File
@@ -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
@@ -120,6 +121,14 @@ FEATURE_COLS = [
"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",
"ht_score_home", "ht_score_away", "ht_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,12 +648,20 @@ 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)
@@ -528,32 +688,37 @@ class FeatureExtractor:
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:
# ── 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(
@@ -829,6 +994,20 @@ class FeatureExtractor:
"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,
"score_away": sa,
@@ -854,6 +1033,57 @@ 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)
# -------------------------------------------------------------------------
+225 -137
View File
@@ -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"]
# 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
# 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
return df
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,
)
print(f" Training set: {len(X_train)} matches")
print(f" Test set: {len(X_test)} matches")
preds = model.predict(X_test)
# --- 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)
mae = mean_absolute_error(y_test, preds)
rmse = np.sqrt(mean_squared_error(y_test, preds))
r2 = r2_score(y_test, preds)
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}")
print(f" MAE: {mae:.4f} goals")
print(f" RMSE: {rmse:.4f}")
print(f" R²: {r2:.4f}")
# --- 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)
return model, {"mae": mae, "rmse": rmse, "r2": r2}
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)
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):")
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")
# FT Score
ft_h_preds = models["ft_home"].predict(X_test)
ft_a_preds = models["ft_away"].predict(X_test)
# --- 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
)
xgb_ht_away.fit(X_train, y_ht_a_train, eval_set=[(X_test, y_ht_a_test)], verbose=False)
y_ft_h = y_test_dict["score_home"].values
y_ft_a = y_test_dict["score_away"].values
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")
exact = 0
close = 0
result_correct = 0
total = len(X_test)
# --- 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_ft_h, y_ft_a, ft_h_preds, ft_a_preds):
hp = max(0, round(h_pred))
ap = max(0, round(a_pred))
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:
# 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()
+553
View File
@@ -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()
+118 -21
View File
@@ -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!")
+307
View File
@@ -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()
+505
View File
@@ -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
+151 -24
View File
@@ -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(
+367
View File
@@ -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
-7
View File
@@ -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())
-56
View File
@@ -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()
-282
View File
@@ -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()
-110
View File
@@ -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()
-142
View File
@@ -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)
-286
View File
@@ -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()
+27 -14
View File
@@ -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,20 +44,25 @@ src/modules/social-poster/
### 4.1 SocialPosterService
**Cron:** Her 10 dakikada bir çalışır. 2540 dakika içinde başlayacak maçları `top_leagues.json` filtresiyle bulur.
**Cron:** Her 15 dakikada bir çalışır. Varsayılan olarak 2545 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) |
@@ -68,7 +73,7 @@ axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, { timeout: 30000 }
**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 |
@@ -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
@@ -119,7 +126,7 @@ 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ş |
@@ -130,12 +137,18 @@ 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 |
| `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 | ❌ | — | Twitter paylaşım 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 |
---
@@ -166,7 +179,7 @@ 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ı) |
@@ -183,7 +196,7 @@ 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 |
File diff suppressed because one or more lines are too long
+370
View File
@@ -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ı
```
+15 -39
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -24,7 +24,7 @@ async function main() {
firstName: 'Super',
lastName: 'Admin',
role: UserRole.superadmin,
subscriptionStatus: SubscriptionStatus.active,
subscriptionStatus: SubscriptionStatus.free,
isActive: true,
},
});
+267
View File
@@ -0,0 +1,267 @@
[
"3iwftmprsznl6yribr11a8l9m",
"cegl2ivkc25blcatxp4jmk1ec",
"1zp1du9n4rj36p1ss9zbxtqfb",
"bockl24qpr7ryjl8b6obukga",
"byu00jvt1j6csyv4y1lkt2fm2",
"degxm4y6gmvp011ccyrev6z5p",
"c7b8o53flg36wbuevfzy3lb10",
"7ntvbsyq31jnzoqoa8850b9b8",
"581t4mywybx21wcpmpykhyzr3",
"3frp1zxrqulrlrnk503n6l4l",
"287tckirbfj9nb8ar2k9r60vn",
"bgen5kjer2ytfp7lo9949t72g",
"ac112osli9fvox1epcg4ld3t6",
"3is4bkgf3loxv9qfg3hm8zfqb",
"c1d9p6b2e9zr5tqlzx3ktjplg",
"5zr0b05eyx25km7z1k03ca9jx",
"5z8v4mj6cjs9ex6hdrpourjzh",
"scf9p4y91yjvqvg5jndxzhxj",
"3p81ltz6845appgkbgkzxueii",
"b5udgm9vakjqz8dcmy5b2g0xt",
"b1rveez5u792gess9w3e7v5le",
"2ty8ihceabty8yddmu31iuuej",
"8ey0ww2zsosdmwr8ehsorh6t7",
"2nttcoriwf5co73vmz1vr8frm",
"1r097lpxe0xn03ihb7wi98kao",
"2kwbbcootiqqgmrzs6o5inle5",
"907l7wtxdvugdo9i2249wcmr0",
"8o5tv5viv4hy1qg9jp94k7ayb",
"4nidzmunvpvxk1ir9b6m8mpay",
"dkarmrybx9vx10rg7cywumth0",
"a9vrdkelbgif0gtu3wxsr75xo",
"4w7x0s5gfs5abasphlha5de8k",
"8dn0w8zh7nbn2i904603eigwf",
"1gwajyt0pk2jm5fx5mu36v114",
"2o9svokc5s7diish3ycrzk7jm",
"7hl0svs2hg225i2zud0g3xzp2",
"89ovpy1rarewwzqvi30bfdr8b",
"2hsidwomhjsaaytdy9u5niyi4",
"34pl8szyvrbwcmfkuocjm3r6t",
"8r98daokeuzsamu5fmjtblqx5",
"akmkihra9ruad09ljapsm84b3",
"722fdbecxzcq9788l6jqclzlw",
"663a54fmymndjeev47qm7d3nf",
"4zwgbb66rif2spcoeeol2motx",
"9chuiarcjofld1dkj9kysehmb",
"5y0z0l2epprzbscvzsgldw8vu",
"2wolc27r8z03itcvwp43e38c5",
"alpfd99yd3lfv7bhjo0biuq7b",
"ea0h6cf3bhl698hkxhpulh2zz",
"8sdpk4aerruf515yh76ezo7vi",
"6by3h89i2eykc341oz7lv1ddd",
"7r1f93t6ddrsa5n8v1nq6qlzm",
"8yi6ejjd1zudcqtbn07haahg6",
"ein4fkggto3pdh5msp8huafiq",
"b60nisd3qn427jm0hrg9kvmab",
"1qd0wvt30rlswa4g6nu4na660",
"b73zounsynk9d3u1p9nvpu7i2",
"civf31q1inxohs4a03y8reetf",
"bu1l7ckihyr0errxw61p0m05",
"a7247po5qs29o3zsfmt222ydu",
"6lwpjhktjhl9g7x2w7njmzva6",
"4c1nfi2j1m731hcay25fcgndq",
"3ww12jab49q8q8mk9avdwjqgk",
"8y29fg2s85ppcb8uugm5ee8s4",
"82jkgccg7phfjpd0mltdl3pat",
"46b141eaqq9q7o4gz5gtdpikk",
"482ofyysbdbeoxauk19yg7tdt",
"4oogyu6o156iphvdvphwpck10",
"2y8bntiif3a9y6gtmauv30gt",
"e21cf135btr8t3upw0vl6n6x0",
"c0yqkbilbbg70ij2473xymmqv",
"5dycj9wdhxh3n33qubw18ohlk",
"1eruend45vd20g9hbrpiggs5u",
"e1kxdivp5g4cpldgpwvnzl1vv",
"ddyrh5latwfhesgfh4w401n92",
"af79lqrc0ntom74zq13ccjslo",
"3ab1uwtoyjopdj1y1fynyy9jg",
"c0r21rtokgnbtc0o2rldjmkxu",
"e0lck99w8meo9qoalfrxgo33o",
"yv73ms6v1995b5wny16jcfi3",
"5aw6uyw4pz2bpj24t5z8aacim",
"75i269i1ak43magshljadydrh",
"8k1xcsyvxapl4jlsluh3eomre",
"jznihqxle06xych9ygwiwnsa",
"6wubmo7di3kdpflluf6s8c7vs",
"7cwemnr3vi40znjq451zxkus6",
"6ifaeunfdelecgticvxanikzu",
"913mb508il6jzwtlj28fl892h",
"29actv1ohj8r10kd9hu0jnb0n",
"3btdfgw79qiz3jmyfudovtbu2",
"5cwsxtx37les6m10xj71htkgf",
"9nbpdi9q3ywcm4q0j5u0ekwcq",
"dm5ka0os1e3dxcp3vh05kmp33",
"beqqnubkv05mamuwvimeum015",
"57nu0wygurzkp6fuy5hhrtaa2",
"du6jsenbjql5e8f3yk880ox4g",
"cesdwwnxbc5fmajgroc0hqzy2",
"3w1hkk9k9gr8fwssyn4icvdfo",
"65ggsqdi6drpa4m8y3gkll25k",
"4yzidekywejmxxp77gqmdgopg",
"avs3xposm3t9x1x2vzsoxzcbu",
"75434tz9rc14xkkvudex742ui",
"aho73e5udydy96iun3tkzdzsi",
"4qehj8hfxmy6o2ohp4fxinnzo",
"ae1wva3zrzcp2zd15gpvsntg6",
"4d5d3sf6805n5u6jdoa0hdlog",
"3l29w00m506ex93t5bbh9cg2a",
"zs18qaehvhg3w1208874zvfa",
"4mbfidy8zum5u0aqjqo0vuqs2",
"8v97rcbthsxmzqk4ufxws9mug",
"c76z5d6j7dpi1e79tm8fpm39z",
"47s2kt0e8m444ftqvsrqa3bvq",
"9ikchyu9fb8bvx0s673jofj6s",
"6ihotpaocgiovlxw18e9r9prx",
"32n2r9bl6x90psj0wa7bfs6vq",
"zilopfej2h0n3vpan5tcynpo",
"7nmz249q89qg5ezcvzlheljji",
"ajxs0e0g6ryg5ol8qvw3evrcz",
"477yyajzheg2z8u7uick0e13e",
"8t2o4huu2e48ij23dxnl9w5qx",
"1wwro3z1eb3fl601dju6inlc6",
"4yngyfinzd6bb1k7anqtqs0wt",
"1b70m6qtxrp75b4vtk8hxh8c3",
"7af85xa75vozt2l4hzi6ryts7",
"117yqo02rs8dykkxpm274w3bd",
"725gd73msyt08xm76v7gkxj7u",
"f4jc2cc5nq7flaoptpi5ua4k4",
"xwnjb1az11zffwty3m6vn8y6",
"dr2xk7muj8aqcjdz2b3li1c0k",
"1mpjd0vbxbtu9zw89yj09xk3z",
"3428tckxcirwwh3o3jgc1m8ji",
"6sxm2iln2w45ux498pty9miw8",
"6321dlqv4ziuwqte4xpohijtw",
"5c96g1zm7vo5ons9c42uy2w3r",
"ili150pwfuf39f7yfdch9lhw",
"7swf4kpu3v38i2it4h94c5s9k",
"iu1vi94p4p28oozl1h9bvplr",
"5k620c7y6dlbmcm88dt3eb7t",
"f39uq10c8xhg5e6rwwcf6lhgc",
"6lkj3o21cr4g7bql6tb3fk222",
"9ynnnx1qmkizq1o3qr3v0nsuk",
"8usjlmziv3p2re0r2wwzezki9",
"4zwjlzdszduqmxzusysvzymms",
"7mxwwunvot2pi69pj1yr1kh8i",
"5taraea6mqjjldg9zxswo825y",
"9fuwphq8kvugrlc3ckm7k8wes",
"dvstmwnvw0mt5p38twn9yttyb",
"2xg0qvif1rh7du6wmk2eleku3",
"8x3sbh85gc8qir50utw39jl04",
"59tpnfrwnvhnhzmnvfyug68hj",
"1fedahp0rws09tj451onten8r",
"esrunz7rjb0td98mx9e5cedoy",
"2hj3286pqov1g1g59k2t2qcgm",
"55hcphd1ccc6eai1ms77460on",
"40yjcbx2sq6oq736iqqqczwt1",
"eog6knrkfei68si736fpquyzc",
"f47f3717z2vtpxfxrpdd4jl1x",
"3oa9e03e7w9nr8kqwqc3tlqz9",
"apdwh753fupxheygs8seahh7x",
"486rhdgz7yc0sygziht7hje65",
"erpufio3qaujd9gkszcqvb0bf",
"cu0rmpyff5692eo06ltddjo8a",
"eg6s9f1jj7jr6stmbosn0g6c8",
"9p3nnxhdjahfn8qswpzy8oyc3",
"cse5oqqt2pzfcy8uz6yz3tkbj",
"cfesxhzb83yl8b779uv3revz1",
"4rls982p5uzil6x30mhyhv9f3",
"eitf7hulqfv1clb7toewkil24",
"byhmntnl1b4lxw0zz21im3zkd",
"gfskxsdituog2kqp9yiu7bzi",
"ejunkmfhjz9weugd2bqrkgobb",
"bdtat25m14jy85y484z3e6lf",
"ax1yf4nlzqpcji4j8epdgx3zl",
"1j4ehtrbry9depwt6oghaq3lu",
"xaouuwuk8qyhv1libkeexwjh",
"1q4ab2bpg5e8jl1g2udnakrju",
"81txfenlgw75nq3u2nfdkj92o",
"19q13y6ruzo0o84ipblcuouzs",
"3n9mk5b2mxmq831wfmv6pu86i",
"3n5046abeu3x482ds3jwda238",
"2aso72utuctat2ecs6nahjss6",
"2bmwykmdlcc2u1c40ytoc39vy",
"bx57cmq1edfq53ckfk791supi",
"bly7ema5au6j40i0grhl0pnub",
"er5745q30wnr8jv9nr863omzg",
"by5nibd18nkt40t0j8a0j5yzx",
"1ncmha8yglhyyhg6gtaujymqf",
"agpweohvn9tugnyl6ry4rhivp",
"8ztsv3pzrsyq5w1r3a0nfk1y5",
"4davonpqws4a4ejl1awu98zdg",
"6vq8j5p3av14nr3iuyi4okhjt",
"bbajzna018c79opa1kl5kmkqo",
"eu2g5j36zzxiazpd729osx0wm",
"595nsvo7ykvoe690b1e4u5n56",
"1gxlzw2ezkyeykhcaa5x8ozkk",
"2z7257m7hj58zuxcjrsg4erzc",
"392slbmf1kdqlr6sd1ckt71rs",
"6g8hw3acenrw828la7gwx4mvs",
"d9eaigzyfnfiraqc3ius757tl",
"3aa4mumjl6zyetg6o9hwd5hhx",
"6hlw7rhrpe9garwmfoxu4lebc",
"e6vzdkz6l236s9p288mharefy",
"dvtl8sf1262pd2aqgu641qa7u",
"5pq4dbinkmt8ujoepyqzih7iw",
"6qitd9h242qkvjenaytfdnsf2",
"cbdbziaqczfuyuwqsylqi26zd",
"3ymqchdzk8tt6lfphf26xfvh0",
"2rdrisk4vlglfjxwu0precyqd",
"1cnx2c8g3hhp8ssxnwwli0mjb",
"65q4uwm6ol1rkf5dp89m8omny",
"8kt53kt3mfo29gldhkl05u25b",
"5jd0k2txwnq69frs79eulba8j",
"8x62utr2uti3i7kk14isbnip6",
"b3ufcd24wfnnd5j98ped6irfu",
"61fzfjogstjuukzcehighq7mu",
"50ap4sua1xyut3mpu7ehesp63",
"6694fff47wqxl10lrd9tb91f8",
"macko16888165594668885588",
"3e40pestup9xzagsu2o6c0i8u",
"9oqeqyj7swpnl86ytafjwavvo",
"1qt9bfl6dhydf4tpano6n1p7s",
"29lni33vxqrl1tqhadrnfid6t",
"2db0aw1duj2my9l5iey5gm6nq",
"1vyghvhuy6abu4htoemdi79bd",
"4vksk0d2q4c5w0itdl52lzek6",
"193wqkyb0v5jnsblhvd2ocmyo",
"a3egqgf45jqft6y0uoyvw3mbj",
"5liafywveaf56s2nod8hg9nca",
"3a0j0giz3c3ajw9h59evv7lqt",
"2mdmx668tyhy4u4z9zszwjv5v",
"19mr0xdp7li6nkz87oxh53xed",
"8u5w0g8jimye1cu5albkcb3qs",
"2kuyfkulm5lsgjxynrgh3vz70",
"8cit3whr514nnd4zkaovsnqn",
"9mr92dlx7ryaxhi07sgt90ish",
"1dajh9qrda3enawmlt7ogt05w",
"10x5pvhifwo4y7hs3fz9hf245",
"dc4k1xh2984zbypbnunk7ncic",
"e6rl4hongahbihxd3tpudespd",
"2r1hqz453bn9ljzt53kdr2lwb",
"86wrztni4x8tnvq9cr1cetvfu",
"5em08hhvd7komnfdsb1yagpas",
"326jpj7749ojwqhu3ap27zl77",
"bqvy41un7sf86rbse9tv810x7",
"93i7thp7zi0ympyt6l8aa1r2i",
"ahl3vljaignq9ebaos4uqkrvo",
"68zplepppndhl8bfdvgy9vgu1",
"df1o8phtfy4dwhv6n7mmeedvw",
"cj30195079sdep2imeyt7y47p",
"3z6xfyd3ovi5x09orlo4rmskx",
"1n990e5dpi9xwruwf6uslknkq",
"etta63x1t7tnkn4jheisjwk4p",
"2xv6qkye2rsnwram454x8i8f1",
"8c93rclta164ypkno054nkfyt",
"89v3ukjpui1gashsz3i1vphfa",
"8tddm56zbasf57jkkay4kbf11",
"dcgbs1vkp9y3y31li7s95i51f",
"dlf90uty1axvtr1vn2aaw9vqh",
"9gvvndi7vk9fzvpe65pv5x2ir",
"7siumtnmgqfap6nalpu8xcwb6",
"7zsbjmlmhzn0y7923lw4zquud",
"8dxsd8xnjm9n1ogo37yomgl3p",
"arrfx02rdlstdfwdyikwqtwgl",
"afp674ll89oqsbbrqt17xfxlh",
"22euhl6zy56cp651ipq99rooq"
]
-109
View File
@@ -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()
-58
View File
@@ -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')
-95
View File
@@ -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)
-65
View File
@@ -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')
-22
View File
@@ -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!");
});
});
});
+5
View File
@@ -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,
+38 -6
View File
@@ -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) {
// 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;
+1 -4
View File
@@ -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")}`;
}
+19 -1
View File
@@ -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,
+8 -1
View File
@@ -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"
}
+9 -1
View File
@@ -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"
}
+8 -1
View File
@@ -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ı"
}
+9 -1
View File
@@ -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"
}
+1
View File
@@ -51,6 +51,7 @@ async function bootstrap() {
"https://suggestbet.bilgich.com",
"https://iddaai.com",
"https://www.iddaai.com",
"http://localhost:6195",
]
: true,
credentials: true,
+93 -30
View File
@@ -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(),
+2
View File
@@ -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"],
});
}
}
+17
View File
@@ -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 {}
+98
View File
@@ -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),
);
}
}
+25 -4
View File
@@ -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 };
+7 -8
View File
@@ -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;
+11 -1
View File
@@ -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,
+2 -5
View File
@@ -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) =>
+72 -3
View File
@@ -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(
-1
View File
@@ -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,7 +154,8 @@ export class SmartCouponService {
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
let prediction: SingleMatchPredictionPackage;
try {
const response = await this.aiEngineClient.post<SingleMatchPredictionPackage>(
const response =
await this.aiEngineClient.post<SingleMatchPredictionPackage>(
`/v20plus/analyze/${matchId}`,
);
prediction = response.data;
@@ -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> {
+128 -44
View File
@@ -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