41 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
fahricansecer bf7473c1e7 Merge pull request 'fix: update version tags to v28 and temporarily disable cache for predictions' (#3) from v26-shadow into main
Deploy Iddaai Backend / build-and-deploy (push) Successful in 31s
Reviewed-on: #3
2026-04-24 00:30:55 +03:00
fahricansecer 1f26a5bf2f fix: update version tags to v28 and temporarily disable cache for predictions 2026-04-24 00:11:00 +03:00
fahricansecer fb53fdf1df Merge pull request 'v26-shadow' (#2) from v26-shadow into main
Deploy Iddaai Backend / build-and-deploy (push) Successful in 2m51s
Reviewed-on: #2
2026-04-23 22:29:23 +03:00
fahricansecer 634204acf0 v28 2026-04-23 22:22:59 +03:00
fahricansecer df428ed1e8 gg 2026-04-22 02:17:02 +03:00
fahricansecer 2ccd6831eb gg 2026-04-21 16:53:56 +03:00
fahricansecer 1346924387 gg 2026-04-19 13:23:00 +03:00
fahricansecer e4c74025e5 Merge pull request 'cron' (#1) from cron into main
Deploy Iddaai Backend / build-and-deploy (push) Successful in 2m48s
Reviewed-on: #1
2026-04-16 17:22:36 +03:00
148 changed files with 25126 additions and 7781 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
@@ -0,0 +1,874 @@
{
"meta":{"test_sets":["test"],"test_metrics":[{"best_value":"Min","name":"Logloss"}],"learn_metrics":[{"best_value":"Min","name":"Logloss"}],"launch_mode":"Train","parameters":"","iteration_count":2000,"learn_sets":["learn"],"name":"experiment"},
"iterations":[
{"learn":[0.692389481],"iteration":0,"passed_time":0.04679785798,"remaining_time":93.54891809,"test":[0.6924099937]},
{"learn":[0.6916338586],"iteration":1,"passed_time":0.08350330552,"remaining_time":83.41980222,"test":[0.6916660956]},
{"learn":[0.6910159214],"iteration":2,"passed_time":0.132821758,"remaining_time":88.41501689,"test":[0.691108145]},
{"learn":[0.6903417151],"iteration":3,"passed_time":0.162826233,"remaining_time":81.25029026,"test":[0.6904585078]},
{"learn":[0.6896961461],"iteration":4,"passed_time":0.1969265393,"remaining_time":78.57368918,"test":[0.689812816]},
{"learn":[0.6890979366],"iteration":5,"passed_time":0.2309352918,"remaining_time":76.74749531,"test":[0.689192261]},
{"learn":[0.6884946167],"iteration":6,"passed_time":0.2693987513,"remaining_time":76.70167304,"test":[0.6886032715]},
{"learn":[0.6879503686],"iteration":7,"passed_time":0.3199759681,"remaining_time":79.67401607,"test":[0.6880706742]},
{"learn":[0.6874528094],"iteration":8,"passed_time":0.3645802206,"remaining_time":80.65324659,"test":[0.6876192378]},
{"learn":[0.6869036785],"iteration":9,"passed_time":0.4116507506,"remaining_time":81.91849936,"test":[0.6870868859]},
{"learn":[0.6863761921],"iteration":10,"passed_time":0.4562469316,"remaining_time":82.49774064,"test":[0.6865493528]},
{"learn":[0.6859038678],"iteration":11,"passed_time":0.491541699,"remaining_time":81.43207481,"test":[0.686105086]},
{"learn":[0.685410175],"iteration":12,"passed_time":0.5221556769,"remaining_time":79.80948692,"test":[0.6856345086]},
{"learn":[0.6849483392],"iteration":13,"passed_time":0.5553110353,"remaining_time":78.77483686,"test":[0.6852027185]},
{"learn":[0.6845417792],"iteration":14,"passed_time":0.5952927147,"remaining_time":78.77706925,"test":[0.6848238481]},
{"learn":[0.6841038875],"iteration":15,"passed_time":0.6300274185,"remaining_time":78.12339989,"test":[0.6844045699]},
{"learn":[0.6836957422],"iteration":16,"passed_time":0.662600544,"remaining_time":77.29040464,"test":[0.6840077621]},
{"learn":[0.6832947461],"iteration":17,"passed_time":0.7004221698,"remaining_time":77.12426337,"test":[0.6836197496]},
{"learn":[0.6829014105],"iteration":18,"passed_time":0.7300844347,"remaining_time":76.12090869,"test":[0.6832475033]},
{"learn":[0.6825264546],"iteration":19,"passed_time":0.7641559459,"remaining_time":75.65143865,"test":[0.6829012069]},
{"learn":[0.6822106577],"iteration":20,"passed_time":0.8040792063,"remaining_time":75.77489282,"test":[0.6825880966]},
{"learn":[0.6818649349],"iteration":21,"passed_time":0.8356039756,"remaining_time":75.12839381,"test":[0.6822424968]},
{"learn":[0.6815467855],"iteration":22,"passed_time":0.8861440327,"remaining_time":76.16985881,"test":[0.6819180513]},
{"learn":[0.6812293319],"iteration":23,"passed_time":0.920219319,"remaining_time":75.76472393,"test":[0.6816384467]},
{"learn":[0.6808837443],"iteration":24,"passed_time":0.960164738,"remaining_time":75.8530143,"test":[0.6813262593]},
{"learn":[0.6805816494],"iteration":25,"passed_time":0.9895547925,"remaining_time":75.13004463,"test":[0.6810353411]},
{"learn":[0.6803209634],"iteration":26,"passed_time":1.025550161,"remaining_time":74.94112844,"test":[0.6808138172]},
{"learn":[0.6800350862],"iteration":27,"passed_time":1.060852064,"remaining_time":74.71429535,"test":[0.6805550049]},
{"learn":[0.6797703947],"iteration":28,"passed_time":1.10467538,"remaining_time":75.07983357,"test":[0.680347991]},
{"learn":[0.6794926675],"iteration":29,"passed_time":1.141766834,"remaining_time":74.97602208,"test":[0.680089679]},
{"learn":[0.6792251865],"iteration":30,"passed_time":1.180421588,"remaining_time":74.9758099,"test":[0.6798451919]},
{"learn":[0.6789670166],"iteration":31,"passed_time":1.213674604,"remaining_time":74.64098814,"test":[0.6796090443]},
{"learn":[0.678722402],"iteration":32,"passed_time":1.245848393,"remaining_time":74.26011482,"test":[0.6793890865]},
{"learn":[0.678476935],"iteration":33,"passed_time":1.287262512,"remaining_time":74.43406171,"test":[0.6791683772]},
{"learn":[0.6782297335],"iteration":34,"passed_time":1.327473991,"remaining_time":74.52818262,"test":[0.6789766369]},
{"learn":[0.6780226701],"iteration":35,"passed_time":1.3760549,"remaining_time":75.07143955,"test":[0.6787930242]},
{"learn":[0.6778291026],"iteration":36,"passed_time":1.427620019,"remaining_time":75.74102965,"test":[0.6786087714]},
{"learn":[0.6776045324],"iteration":37,"passed_time":1.468182407,"remaining_time":75.80457587,"test":[0.6784161299]},
{"learn":[0.6773969079],"iteration":38,"passed_time":1.508647379,"remaining_time":75.85788487,"test":[0.6782227897]},
{"learn":[0.6771819602],"iteration":39,"passed_time":1.549435187,"remaining_time":75.92232419,"test":[0.6780242369]},
{"learn":[0.6769816736],"iteration":40,"passed_time":1.586036608,"remaining_time":75.78160282,"test":[0.6778499631]},
{"learn":[0.6767984027],"iteration":41,"passed_time":1.621458864,"remaining_time":75.59086802,"test":[0.6776975784]},
{"learn":[0.6766201184],"iteration":42,"passed_time":1.663424818,"remaining_time":75.70517136,"test":[0.6775231674]},
{"learn":[0.6764394377],"iteration":43,"passed_time":1.70110089,"remaining_time":75.62166686,"test":[0.6773582124]},
{"learn":[0.6762698797],"iteration":44,"passed_time":1.739954496,"remaining_time":75.59135644,"test":[0.6772234666]},
{"learn":[0.6760974263],"iteration":45,"passed_time":1.776461223,"remaining_time":75.46098325,"test":[0.6770659843]},
{"learn":[0.6759245179],"iteration":46,"passed_time":1.819761638,"remaining_time":75.61690381,"test":[0.6769049529]},
{"learn":[0.6757673909],"iteration":47,"passed_time":1.869479807,"remaining_time":76.02551217,"test":[0.6767664194]},
{"learn":[0.6756172628],"iteration":48,"passed_time":1.916010121,"remaining_time":76.28848462,"test":[0.6766584917]},
{"learn":[0.675474531],"iteration":49,"passed_time":1.953635244,"remaining_time":76.19177452,"test":[0.6765507257]},
{"learn":[0.6753286933],"iteration":50,"passed_time":1.993876686,"remaining_time":76.19736591,"test":[0.6764489911]},
{"learn":[0.6751900513],"iteration":51,"passed_time":2.038943041,"remaining_time":76.38194316,"test":[0.6763947956]},
{"learn":[0.6750574835],"iteration":52,"passed_time":2.080276765,"remaining_time":76.42073325,"test":[0.6762778712]},
{"learn":[0.6749329567],"iteration":53,"passed_time":2.158576742,"remaining_time":77.78871001,"test":[0.6761865366]},
{"learn":[0.6748033265],"iteration":54,"passed_time":2.220619687,"remaining_time":78.52918711,"test":[0.6760679685]},
{"learn":[0.6746797823],"iteration":55,"passed_time":2.286959228,"remaining_time":79.39015604,"test":[0.6759774874]},
{"learn":[0.674535525],"iteration":56,"passed_time":2.328472096,"remaining_time":79.3723032,"test":[0.6758500622]},
{"learn":[0.6744256514],"iteration":57,"passed_time":2.367031568,"remaining_time":79.25474665,"test":[0.6757625065]},
{"learn":[0.674310819],"iteration":58,"passed_time":2.409161286,"remaining_time":79.25732298,"test":[0.6756876412]},
{"learn":[0.6741967947],"iteration":59,"passed_time":2.444825903,"remaining_time":79.04937087,"test":[0.6756151069]},
{"learn":[0.6740879654],"iteration":60,"passed_time":2.48484996,"remaining_time":78.98564055,"test":[0.6755303655]},
{"learn":[0.6739772476],"iteration":61,"passed_time":2.521603395,"remaining_time":78.8204416,"test":[0.6754565036]},
{"learn":[0.67388281],"iteration":62,"passed_time":2.554102332,"remaining_time":78.5285114,"test":[0.6753738983]},
{"learn":[0.6737789726],"iteration":63,"passed_time":2.593937938,"remaining_time":78.46662263,"test":[0.6752897299]},
{"learn":[0.6736812332],"iteration":64,"passed_time":2.623889155,"remaining_time":78.11116175,"test":[0.6752115539]},
{"learn":[0.6735930009],"iteration":65,"passed_time":2.660795108,"remaining_time":77.96935967,"test":[0.6751595431]},
{"learn":[0.6734947116],"iteration":66,"passed_time":2.695822592,"remaining_time":77.77649358,"test":[0.6750764658]},
{"learn":[0.6733961481],"iteration":67,"passed_time":2.725876686,"remaining_time":77.44696703,"test":[0.6750179194]},
{"learn":[0.6732990195],"iteration":68,"passed_time":2.761848366,"remaining_time":77.29172746,"test":[0.6749408803]},
{"learn":[0.6732133575],"iteration":69,"passed_time":2.791847449,"remaining_time":76.97522253,"test":[0.6748795802]},
{"learn":[0.673111539],"iteration":70,"passed_time":2.824541003,"remaining_time":76.73999429,"test":[0.674790372]},
{"learn":[0.6730080451],"iteration":71,"passed_time":2.861023716,"remaining_time":76.61185729,"test":[0.6747239773]},
{"learn":[0.6729157861],"iteration":72,"passed_time":2.897136588,"remaining_time":76.47646857,"test":[0.6746701254]},
{"learn":[0.6728347949],"iteration":73,"passed_time":2.935718661,"remaining_time":76.40802894,"test":[0.6746120937]},
{"learn":[0.6727640693],"iteration":74,"passed_time":3.040023476,"remaining_time":78.02726921,"test":[0.6745550085]},
{"learn":[0.6726808811],"iteration":75,"passed_time":3.097341794,"remaining_time":78.41165279,"test":[0.6744855074]},
{"learn":[0.6726029645],"iteration":76,"passed_time":3.152948955,"remaining_time":78.74182909,"test":[0.6744264172]},
{"learn":[0.6725356026],"iteration":77,"passed_time":3.216126808,"remaining_time":79.24866314,"test":[0.674381715]},
{"learn":[0.6724606887],"iteration":78,"passed_time":3.256861302,"remaining_time":79.19532355,"test":[0.6743331681]},
{"learn":[0.6723849561],"iteration":79,"passed_time":3.305679851,"remaining_time":79.33631641,"test":[0.67428564]},
{"learn":[0.6723050519],"iteration":80,"passed_time":3.348083566,"remaining_time":79.32064647,"test":[0.6742202413]},
{"learn":[0.6722508802],"iteration":81,"passed_time":3.38129387,"remaining_time":79.08928832,"test":[0.6741620971]},
{"learn":[0.6721773904],"iteration":82,"passed_time":3.41660066,"remaining_time":78.91112609,"test":[0.6741109453]},
{"learn":[0.6721007598],"iteration":83,"passed_time":3.48099347,"remaining_time":79.39980344,"test":[0.6740556003]},
{"learn":[0.6720353564],"iteration":84,"passed_time":3.535359896,"remaining_time":79.64957884,"test":[0.6740146772]},
{"learn":[0.6719790902],"iteration":85,"passed_time":3.581806996,"remaining_time":79.71603012,"test":[0.673983295]},
{"learn":[0.6719140024],"iteration":86,"passed_time":3.612293661,"remaining_time":79.42893993,"test":[0.6739595301]},
{"learn":[0.6718573633],"iteration":87,"passed_time":3.644530261,"remaining_time":79.18570293,"test":[0.6739336659]},
{"learn":[0.671795602],"iteration":88,"passed_time":3.67809653,"remaining_time":78.97575809,"test":[0.673890361]},
{"learn":[0.6717369134],"iteration":89,"passed_time":3.712417516,"remaining_time":78.78574951,"test":[0.673863586]},
{"learn":[0.6716711079],"iteration":90,"passed_time":3.743502971,"remaining_time":78.53128759,"test":[0.6738190616]},
{"learn":[0.6716070843],"iteration":91,"passed_time":3.775351679,"remaining_time":78.2975109,"test":[0.6737799295]},
{"learn":[0.6715517232],"iteration":92,"passed_time":3.806186247,"remaining_time":78.04728142,"test":[0.6737364374]},
{"learn":[0.6714957378],"iteration":93,"passed_time":3.83798807,"remaining_time":77.82133257,"test":[0.6737093719]},
{"learn":[0.6714364567],"iteration":94,"passed_time":3.871278973,"remaining_time":77.62933099,"test":[0.6736630475]},
{"learn":[0.6713881758],"iteration":95,"passed_time":3.913531039,"remaining_time":77.6183656,"test":[0.67364367]},
{"learn":[0.6713336502],"iteration":96,"passed_time":3.945433866,"remaining_time":77.40371802,"test":[0.6735998081]},
{"learn":[0.6712700267],"iteration":97,"passed_time":3.989716281,"remaining_time":77.43306496,"test":[0.6735526984]},
{"learn":[0.6712154424],"iteration":98,"passed_time":4.020621946,"remaining_time":77.20406384,"test":[0.6735012924]},
{"learn":[0.6711600413],"iteration":99,"passed_time":4.053732144,"remaining_time":77.02091074,"test":[0.6734818024]},
{"learn":[0.6711060533],"iteration":100,"passed_time":4.084124711,"remaining_time":76.78963194,"test":[0.6734379341]},
{"learn":[0.6710494943],"iteration":101,"passed_time":4.116434744,"remaining_time":76.59797199,"test":[0.6734059869]},
{"learn":[0.6709936897],"iteration":102,"passed_time":4.148330356,"remaining_time":76.40177365,"test":[0.6733740852]},
{"learn":[0.6709472183],"iteration":103,"passed_time":4.176511193,"remaining_time":76.14101176,"test":[0.6733330971]},
{"learn":[0.6708914508],"iteration":104,"passed_time":4.2025065,"remaining_time":75.84523636,"test":[0.6733060254]},
{"learn":[0.6708388195],"iteration":105,"passed_time":4.232975206,"remaining_time":75.63448151,"test":[0.6732755898]},
{"learn":[0.6707885854],"iteration":106,"passed_time":4.261364958,"remaining_time":75.39031649,"test":[0.6732294722]},
{"learn":[0.6707454167],"iteration":107,"passed_time":4.290824713,"remaining_time":75.1688922,"test":[0.6732035176]},
{"learn":[0.6706973013],"iteration":108,"passed_time":4.324192493,"remaining_time":75.01878903,"test":[0.673196437]},
{"learn":[0.6706577031],"iteration":109,"passed_time":4.351512102,"remaining_time":74.76688976,"test":[0.6731652709]},
{"learn":[0.67061108],"iteration":110,"passed_time":4.38641502,"remaining_time":74.64808984,"test":[0.673138808]},
{"learn":[0.6705625485],"iteration":111,"passed_time":4.424063991,"remaining_time":74.57707871,"test":[0.6731062725]},
{"learn":[0.6705146484],"iteration":112,"passed_time":4.45863849,"remaining_time":74.45531709,"test":[0.6730726625]},
{"learn":[0.6704704423],"iteration":113,"passed_time":4.497153675,"remaining_time":74.40027922,"test":[0.6730285927]},
{"learn":[0.6704155922],"iteration":114,"passed_time":4.533368584,"remaining_time":74.30782417,"test":[0.6729872702]},
{"learn":[0.6703687117],"iteration":115,"passed_time":4.564651269,"remaining_time":74.13623268,"test":[0.6729721425]},
{"learn":[0.6703324232],"iteration":116,"passed_time":4.596824343,"remaining_time":73.98136956,"test":[0.6729564624]},
{"learn":[0.6702884624],"iteration":117,"passed_time":4.628377967,"remaining_time":73.81870623,"test":[0.6729312424]},
{"learn":[0.670253478],"iteration":118,"passed_time":4.668052254,"remaining_time":73.78660748,"test":[0.6729354345]},
{"learn":[0.6702140804],"iteration":119,"passed_time":4.692108266,"remaining_time":73.50969617,"test":[0.6729085401]},
{"learn":[0.6701682529],"iteration":120,"passed_time":4.723741667,"remaining_time":73.354633,"test":[0.6728898322]},
{"learn":[0.6701320588],"iteration":121,"passed_time":4.756626425,"remaining_time":73.22085595,"test":[0.6728773638]},
{"learn":[0.6700939824],"iteration":122,"passed_time":4.788008428,"remaining_time":73.06578714,"test":[0.6728618874]},
{"learn":[0.6700655902],"iteration":123,"passed_time":4.815546648,"remaining_time":72.85456058,"test":[0.6728540413]},
{"learn":[0.6700190743],"iteration":124,"passed_time":4.843186806,"remaining_time":72.64780209,"test":[0.6728441291]},
{"learn":[0.6699792296],"iteration":125,"passed_time":4.875548614,"remaining_time":72.51411192,"test":[0.672815631]},
{"learn":[0.6699379404],"iteration":126,"passed_time":4.916953662,"remaining_time":72.51538748,"test":[0.6728082021]},
{"learn":[0.669895454],"iteration":127,"passed_time":4.952918369,"remaining_time":72.43643115,"test":[0.6727900064]},
{"learn":[0.6698563938],"iteration":128,"passed_time":4.991585558,"remaining_time":72.39733782,"test":[0.6727649552]},
{"learn":[0.6698215571],"iteration":129,"passed_time":5.028084166,"remaining_time":72.32705685,"test":[0.6727467657]},
{"learn":[0.6697857067],"iteration":130,"passed_time":5.059198996,"remaining_time":72.18048033,"test":[0.6727396032]},
{"learn":[0.6697449303],"iteration":131,"passed_time":5.096035515,"remaining_time":72.1166238,"test":[0.6727245271]},
{"learn":[0.6697052425],"iteration":132,"passed_time":5.125282589,"remaining_time":71.94663604,"test":[0.6726955143]},
{"learn":[0.6696695553],"iteration":133,"passed_time":5.156392608,"remaining_time":71.80469109,"test":[0.67269209]},
{"learn":[0.6696269265],"iteration":134,"passed_time":5.190402292,"remaining_time":71.70444647,"test":[0.672677932]},
{"learn":[0.6695969271],"iteration":135,"passed_time":5.221466142,"remaining_time":71.56480065,"test":[0.6726540285]},
{"learn":[0.6695489786],"iteration":136,"passed_time":5.251144663,"remaining_time":71.40790151,"test":[0.6726288583]},
{"learn":[0.6695173859],"iteration":137,"passed_time":5.274361693,"remaining_time":71.16566285,"test":[0.6725863431]},
{"learn":[0.6694811164],"iteration":138,"passed_time":5.309398952,"remaining_time":71.08483058,"test":[0.6725837967]},
{"learn":[0.6694477439],"iteration":139,"passed_time":5.344693175,"remaining_time":71.00806646,"test":[0.6725772977]},
{"learn":[0.6694082161],"iteration":140,"passed_time":5.377737126,"remaining_time":70.90222211,"test":[0.6725685594]},
{"learn":[0.6693679185],"iteration":141,"passed_time":5.416087925,"remaining_time":70.8668406,"test":[0.6725553829]},
{"learn":[0.6693341916],"iteration":142,"passed_time":5.452286939,"remaining_time":70.80347444,"test":[0.6725484347]},
{"learn":[0.6692933159],"iteration":143,"passed_time":5.490006789,"remaining_time":70.7600875,"test":[0.6725306172]},
{"learn":[0.6692619696],"iteration":144,"passed_time":5.521869859,"remaining_time":70.64185233,"test":[0.672543149]},
{"learn":[0.6692229289],"iteration":145,"passed_time":5.553520721,"remaining_time":70.5221056,"test":[0.6725196247]},
{"learn":[0.6691840164],"iteration":146,"passed_time":5.582178524,"remaining_time":70.3658286,"test":[0.6725226452]},
{"learn":[0.6691581406],"iteration":147,"passed_time":5.611368671,"remaining_time":70.21793769,"test":[0.6725056913]},
{"learn":[0.6691177196],"iteration":148,"passed_time":5.636941079,"remaining_time":70.02669757,"test":[0.6724771476]},
{"learn":[0.6690851126],"iteration":149,"passed_time":5.673704689,"remaining_time":69.97569117,"test":[0.6724439435]},
{"learn":[0.6690518144],"iteration":150,"passed_time":5.706346207,"remaining_time":69.87439826,"test":[0.672442532]},
{"learn":[0.6690149711],"iteration":151,"passed_time":5.738210991,"remaining_time":69.76456521,"test":[0.6724303064]},
{"learn":[0.668993877],"iteration":152,"passed_time":5.765951318,"remaining_time":69.60596133,"test":[0.6724235788]},
{"learn":[0.6689596579],"iteration":153,"passed_time":5.795573467,"remaining_time":69.47161442,"test":[0.6724294499]},
{"learn":[0.6689372651],"iteration":154,"passed_time":5.81744896,"remaining_time":69.24640858,"test":[0.6724285935]},
{"learn":[0.6689003045],"iteration":155,"passed_time":5.853529431,"remaining_time":69.19171968,"test":[0.6724172017]},
{"learn":[0.6688680182],"iteration":156,"passed_time":5.888380392,"remaining_time":69.12283479,"test":[0.6724130745]},
{"learn":[0.6688348164],"iteration":157,"passed_time":5.924601775,"remaining_time":69.07035741,"test":[0.6723860878]},
{"learn":[0.6687947046],"iteration":158,"passed_time":5.964531924,"remaining_time":69.06102687,"test":[0.6723707604]},
{"learn":[0.6687605251],"iteration":159,"passed_time":5.996805452,"remaining_time":68.9632627,"test":[0.6723566111]},
{"learn":[0.668726253],"iteration":160,"passed_time":6.022341459,"remaining_time":68.78935368,"test":[0.6723469906]},
{"learn":[0.6686862718],"iteration":161,"passed_time":6.05082584,"remaining_time":68.65072774,"test":[0.6723287161]},
{"learn":[0.668663478],"iteration":162,"passed_time":6.079027554,"remaining_time":68.51026759,"test":[0.6723155898]},
{"learn":[0.6686399521],"iteration":163,"passed_time":6.108511297,"remaining_time":68.38552891,"test":[0.6722970834]},
{"learn":[0.6686058279],"iteration":164,"passed_time":6.140719309,"remaining_time":68.29224202,"test":[0.6722872244]},
{"learn":[0.6685761282],"iteration":165,"passed_time":6.169540017,"remaining_time":68.16226742,"test":[0.6722800481]},
{"learn":[0.6685469327],"iteration":166,"passed_time":6.2020892,"remaining_time":68.07442817,"test":[0.6722550973]},
{"learn":[0.6685157003],"iteration":167,"passed_time":6.231576547,"remaining_time":67.95385854,"test":[0.6722394313]},
{"learn":[0.6684805143],"iteration":168,"passed_time":6.263261652,"remaining_time":67.85817802,"test":[0.6722204135]},
{"learn":[0.6684485765],"iteration":169,"passed_time":6.295102833,"remaining_time":67.7649305,"test":[0.6721982148]},
{"learn":[0.6684144429],"iteration":170,"passed_time":6.325415964,"remaining_time":67.65605729,"test":[0.6721971176]},
{"learn":[0.6683849752],"iteration":171,"passed_time":6.35697084,"remaining_time":67.56129474,"test":[0.6721880705]},
{"learn":[0.6683568537],"iteration":172,"passed_time":6.395913563,"remaining_time":67.5452837,"test":[0.672179176]},
{"learn":[0.6683266628],"iteration":173,"passed_time":6.437330522,"remaining_time":67.55497433,"test":[0.6721769709]},
{"learn":[0.6682937842],"iteration":174,"passed_time":6.472195712,"remaining_time":67.49575528,"test":[0.6721693215]},
{"learn":[0.6682657097],"iteration":175,"passed_time":6.503044842,"remaining_time":67.395192,"test":[0.6721581386]},
{"learn":[0.6682301443],"iteration":176,"passed_time":6.533528251,"remaining_time":67.29164972,"test":[0.6721638661]},
{"learn":[0.6681995916],"iteration":177,"passed_time":6.562589882,"remaining_time":67.17437509,"test":[0.6721598475]},
{"learn":[0.6681658267],"iteration":178,"passed_time":6.590816982,"remaining_time":67.04959623,"test":[0.6721433342]},
{"learn":[0.6681422687],"iteration":179,"passed_time":6.624646227,"remaining_time":66.98253407,"test":[0.6721335599]},
{"learn":[0.6681216601],"iteration":180,"passed_time":6.655147334,"remaining_time":66.88239227,"test":[0.6721300594]},
{"learn":[0.6680899019],"iteration":181,"passed_time":6.687788902,"remaining_time":66.80439684,"test":[0.6721153533]},
{"learn":[0.6680676394],"iteration":182,"passed_time":6.718057043,"remaining_time":66.7033314,"test":[0.6721076397]},
{"learn":[0.6680413672],"iteration":183,"passed_time":6.751300957,"remaining_time":66.6324051,"test":[0.6721009911]},
{"learn":[0.6680088406],"iteration":184,"passed_time":6.784288393,"remaining_time":66.55936991,"test":[0.6720999252]},
{"learn":[0.6679873982],"iteration":185,"passed_time":6.810905309,"remaining_time":66.42463565,"test":[0.6720953028]},
{"learn":[0.6679663544],"iteration":186,"passed_time":6.832974292,"remaining_time":66.24696466,"test":[0.6720942505]},
{"learn":[0.6679417375],"iteration":187,"passed_time":6.867184511,"remaining_time":66.18796986,"test":[0.6720856237]},
{"learn":[0.6679100197],"iteration":188,"passed_time":6.918652024,"remaining_time":66.29459691,"test":[0.6720876136]},
{"learn":[0.667881208],"iteration":189,"passed_time":6.96948149,"remaining_time":66.39348156,"test":[0.6720880182]},
{"learn":[0.6678475427],"iteration":190,"passed_time":7.018176318,"remaining_time":66.47058094,"test":[0.6720743856]},
{"learn":[0.6678310341],"iteration":191,"passed_time":7.074099623,"remaining_time":66.61443812,"test":[0.6720598415]},
{"learn":[0.6678060257],"iteration":192,"passed_time":7.117099742,"remaining_time":66.63522919,"test":[0.6720563492]},
{"learn":[0.6677789336],"iteration":193,"passed_time":7.191058554,"remaining_time":66.94356571,"test":[0.6720389527]},
{"learn":[0.6677478773],"iteration":194,"passed_time":7.2421897,"remaining_time":67.03667902,"test":[0.6720317324]},
{"learn":[0.6677212408],"iteration":195,"passed_time":7.282401129,"remaining_time":67.02781447,"test":[0.672000736]},
{"learn":[0.667704316],"iteration":196,"passed_time":7.317019235,"remaining_time":66.96744,"test":[0.6719895017]},
{"learn":[0.6676819639],"iteration":197,"passed_time":7.351194179,"remaining_time":66.90329248,"test":[0.6719725302]},
{"learn":[0.6676554448],"iteration":198,"passed_time":7.389840926,"remaining_time":66.87991712,"test":[0.6719770493]},
{"learn":[0.6676318346],"iteration":199,"passed_time":7.432994652,"remaining_time":66.89695187,"test":[0.6719667172]},
{"learn":[0.6676074705],"iteration":200,"passed_time":7.471295231,"remaining_time":66.86995085,"test":[0.6719511616]},
{"learn":[0.6675849784],"iteration":201,"passed_time":7.506377837,"remaining_time":66.8141948,"test":[0.6719427289]},
{"learn":[0.6675631744],"iteration":202,"passed_time":7.540821494,"remaining_time":66.75298633,"test":[0.6719299116]},
{"learn":[0.6675397619],"iteration":203,"passed_time":7.56808212,"remaining_time":66.62880141,"test":[0.6719106583]},
{"learn":[0.6675169086],"iteration":204,"passed_time":7.605676901,"remaining_time":66.59604896,"test":[0.6718967065]},
{"learn":[0.6674864762],"iteration":205,"passed_time":7.638300222,"remaining_time":66.51995436,"test":[0.671890967]},
{"learn":[0.6674670714],"iteration":206,"passed_time":7.665554951,"remaining_time":66.39777791,"test":[0.6718896293]},
{"learn":[0.6674375599],"iteration":207,"passed_time":7.700277678,"remaining_time":66.34085384,"test":[0.6718883534]},
{"learn":[0.6674148457],"iteration":208,"passed_time":7.734145802,"remaining_time":66.27681881,"test":[0.6718827289]},
{"learn":[0.6673974446],"iteration":209,"passed_time":7.766232144,"remaining_time":66.19788351,"test":[0.6718763224]},
{"learn":[0.6673812139],"iteration":210,"passed_time":7.796801222,"remaining_time":66.1065279,"test":[0.67187262]},
{"learn":[0.6673515687],"iteration":211,"passed_time":7.831891449,"remaining_time":66.05387693,"test":[0.6718590402]},
{"learn":[0.6673197956],"iteration":212,"passed_time":7.871259964,"remaining_time":66.0372843,"test":[0.6718455115]},
{"learn":[0.6672900754],"iteration":213,"passed_time":7.910110502,"remaining_time":66.01615587,"test":[0.6718253747]},
{"learn":[0.6672550009],"iteration":214,"passed_time":7.951342226,"remaining_time":66.01463197,"test":[0.671794877]},
{"learn":[0.6672271563],"iteration":215,"passed_time":7.989001461,"remaining_time":65.98323429,"test":[0.6717873786]},
{"learn":[0.667204521],"iteration":216,"passed_time":8.025973631,"remaining_time":65.94613357,"test":[0.6717765089]},
{"learn":[0.667181968],"iteration":217,"passed_time":8.058434478,"remaining_time":65.87215707,"test":[0.6717616726]},
{"learn":[0.6671640023],"iteration":218,"passed_time":8.087145957,"remaining_time":65.76806826,"test":[0.6717499215]},
{"learn":[0.66714351],"iteration":219,"passed_time":8.112590578,"remaining_time":65.63823286,"test":[0.6717326052]},
{"learn":[0.6671167156],"iteration":220,"passed_time":8.148644349,"remaining_time":65.59474342,"test":[0.6717161937]},
{"learn":[0.6670915937],"iteration":221,"passed_time":8.197662625,"remaining_time":65.65515382,"test":[0.6717056951]},
{"learn":[0.6670595279],"iteration":222,"passed_time":8.239228431,"remaining_time":65.65519696,"test":[0.6717021438]},
{"learn":[0.667033994],"iteration":223,"passed_time":8.268371203,"remaining_time":65.55637168,"test":[0.6716868488]},
{"learn":[0.6670008246],"iteration":224,"passed_time":8.298555216,"remaining_time":65.46638004,"test":[0.6716751909]},
{"learn":[0.6669858319],"iteration":225,"passed_time":8.327401394,"remaining_time":65.36641625,"test":[0.671670116]},
{"learn":[0.6669553964],"iteration":226,"passed_time":8.357648377,"remaining_time":65.27802014,"test":[0.6716558757]},
{"learn":[0.6669274683],"iteration":227,"passed_time":8.384989701,"remaining_time":65.16755154,"test":[0.6716559962]},
{"learn":[0.666896348],"iteration":228,"passed_time":8.418297538,"remaining_time":65.1039517,"test":[0.6716487875]},
{"learn":[0.6668698686],"iteration":229,"passed_time":8.453919972,"remaining_time":65.05842761,"test":[0.6716427451]},
{"learn":[0.6668513411],"iteration":230,"passed_time":8.49049033,"remaining_time":65.02024846,"test":[0.6716323255]},
{"learn":[0.6668309985],"iteration":231,"passed_time":8.523986676,"remaining_time":64.95865708,"test":[0.6716303547]},
{"learn":[0.6668058585],"iteration":232,"passed_time":8.550998228,"remaining_time":64.84812819,"test":[0.6716309509]},
{"learn":[0.6667845908],"iteration":233,"passed_time":8.575382398,"remaining_time":64.71848425,"test":[0.6716215401]},
{"learn":[0.6667582863],"iteration":234,"passed_time":8.607602961,"remaining_time":64.64859245,"test":[0.6716162103]},
{"learn":[0.6667332943],"iteration":235,"passed_time":8.6353786,"remaining_time":64.54579597,"test":[0.6716135097]},
{"learn":[0.6667070085],"iteration":236,"passed_time":8.66085309,"remaining_time":64.42651476,"test":[0.6716156696]},
{"learn":[0.6666907315],"iteration":237,"passed_time":8.691362456,"remaining_time":64.34529684,"test":[0.6716020054]},
{"learn":[0.6666633028],"iteration":238,"passed_time":8.719983169,"remaining_time":64.25058728,"test":[0.6715921704]},
{"learn":[0.6666406707],"iteration":239,"passed_time":8.746012652,"remaining_time":64.13742611,"test":[0.6715804466]},
{"learn":[0.6666134624],"iteration":240,"passed_time":8.773898765,"remaining_time":64.03853912,"test":[0.6715882966]},
{"learn":[0.6665850522],"iteration":241,"passed_time":8.803292064,"remaining_time":63.9511878,"test":[0.6715753942]},
{"learn":[0.6665631193],"iteration":242,"passed_time":8.833976809,"remaining_time":63.87365125,"test":[0.6715752261]},
{"learn":[0.6665412643],"iteration":243,"passed_time":8.862338006,"remaining_time":63.7797768,"test":[0.6715625509]},
{"learn":[0.6665168385],"iteration":244,"passed_time":8.892424073,"remaining_time":63.69879285,"test":[0.6715628214]},
{"learn":[0.6664904845],"iteration":245,"passed_time":8.932383667,"remaining_time":63.68862175,"test":[0.6715601629]},
{"learn":[0.6664678274],"iteration":246,"passed_time":8.962911123,"remaining_time":63.61126801,"test":[0.6715576255]},
{"learn":[0.6664539777],"iteration":247,"passed_time":8.991624872,"remaining_time":63.52147894,"test":[0.6715550274]},
{"learn":[0.6664334121],"iteration":248,"passed_time":9.021847081,"remaining_time":63.44278811,"test":[0.6715448645]},
{"learn":[0.6664121724],"iteration":249,"passed_time":9.05121341,"remaining_time":63.35849387,"test":[0.6715308166]},
{"learn":[0.666392034],"iteration":250,"passed_time":9.085113431,"remaining_time":63.30622865,"test":[0.671519334]},
{"learn":[0.666366899],"iteration":251,"passed_time":9.110250512,"remaining_time":63.19332498,"test":[0.6715184071]},
{"learn":[0.6663414098],"iteration":252,"passed_time":9.137253573,"remaining_time":63.09399997,"test":[0.6715163019]},
{"learn":[0.6663157816],"iteration":253,"passed_time":9.174559864,"remaining_time":63.06606899,"test":[0.6715096094]},
{"learn":[0.6662989799],"iteration":254,"passed_time":9.196898204,"remaining_time":62.93563673,"test":[0.6714992963]},
{"learn":[0.6662696102],"iteration":255,"passed_time":9.238149902,"remaining_time":62.9348962,"test":[0.6714917256]},
{"learn":[0.6662479711],"iteration":256,"passed_time":9.267818291,"remaining_time":62.85528125,"test":[0.671477406]},
{"learn":[0.6662231874],"iteration":257,"passed_time":9.297538986,"remaining_time":62.77640665,"test":[0.6714741542]},
{"learn":[0.6661947927],"iteration":258,"passed_time":9.324772701,"remaining_time":62.68119411,"test":[0.6714576155]},
{"learn":[0.6661669951],"iteration":259,"passed_time":9.357824574,"remaining_time":62.62544138,"test":[0.6714473645]},
{"learn":[0.6661426137],"iteration":260,"passed_time":9.388345461,"remaining_time":62.55299907,"test":[0.6714427232]},
{"learn":[0.6661216749],"iteration":261,"passed_time":9.427290804,"remaining_time":62.53676114,"test":[0.6714364275]},
{"learn":[0.6660983123],"iteration":262,"passed_time":9.461913185,"remaining_time":62.49179925,"test":[0.6714339587]},
{"learn":[0.6660803402],"iteration":263,"passed_time":9.496090562,"remaining_time":62.44398945,"test":[0.6714336287]},
{"learn":[0.6660617842],"iteration":264,"passed_time":9.524189317,"remaining_time":62.35648477,"test":[0.6714283568]},
{"learn":[0.6660443878],"iteration":265,"passed_time":9.55372419,"remaining_time":62.27878852,"test":[0.6714271895]},
{"learn":[0.6660176079],"iteration":266,"passed_time":9.590356068,"remaining_time":62.2475171,"test":[0.671413471]},
{"learn":[0.6659967546],"iteration":267,"passed_time":9.620235131,"remaining_time":62.17256436,"test":[0.6714072396]},
{"learn":[0.6659751467],"iteration":268,"passed_time":9.645948482,"remaining_time":62.0711406,"test":[0.6714002677]},
{"learn":[0.6659539329],"iteration":269,"passed_time":9.682675077,"remaining_time":62.04084401,"test":[0.6714001163]},
{"learn":[0.6659263951],"iteration":270,"passed_time":9.711914203,"remaining_time":61.96272936,"test":[0.6713933952]},
{"learn":[0.6659038921],"iteration":271,"passed_time":9.739142426,"remaining_time":61.87219894,"test":[0.6713926761]},
{"learn":[0.6658767418],"iteration":272,"passed_time":9.768751964,"remaining_time":61.79719649,"test":[0.6713836619]},
{"learn":[0.6658510507],"iteration":273,"passed_time":9.804576737,"remaining_time":61.76167682,"test":[0.6713772112]},
{"learn":[0.6658210119],"iteration":274,"passed_time":9.848653906,"remaining_time":61.77791996,"test":[0.6713603715]},
{"learn":[0.6657963011],"iteration":275,"passed_time":9.88663261,"remaining_time":61.75563268,"test":[0.6713560246]},
{"learn":[0.6657748552],"iteration":276,"passed_time":9.925808942,"remaining_time":61.74068161,"test":[0.6713837913]},
{"learn":[0.6657490013],"iteration":277,"passed_time":9.965409489,"remaining_time":61.72818396,"test":[0.6713684274]},
{"learn":[0.665732402],"iteration":278,"passed_time":9.99537326,"remaining_time":61.65604796,"test":[0.6713619356]},
{"learn":[0.6657118786],"iteration":279,"passed_time":10.02216777,"remaining_time":61.5647449,"test":[0.6713584836]},
{"learn":[0.665684467],"iteration":280,"passed_time":10.05593393,"remaining_time":61.51654955,"test":[0.6713673572]},
{"learn":[0.6656584634],"iteration":281,"passed_time":10.08025153,"remaining_time":61.41089406,"test":[0.6713625568]},
{"learn":[0.6656309991],"iteration":282,"passed_time":10.11102202,"remaining_time":61.34496401,"test":[0.6713542652]},
{"learn":[0.6656073482],"iteration":283,"passed_time":10.14714598,"remaining_time":61.31162855,"test":[0.6713512017]},
{"learn":[0.6655890957],"iteration":284,"passed_time":10.17528061,"remaining_time":61.23019734,"test":[0.671342038]},
{"learn":[0.6655665563],"iteration":285,"passed_time":10.2021403,"remaining_time":61.14149818,"test":[0.6713279798]},
{"learn":[0.6655452454],"iteration":286,"passed_time":10.23423432,"remaining_time":61.08447174,"test":[0.6713123285]},
{"learn":[0.6655255286],"iteration":287,"passed_time":10.26481698,"remaining_time":61.0186343,"test":[0.6713035326]},
{"learn":[0.6655053548],"iteration":288,"passed_time":10.29945844,"remaining_time":60.97707056,"test":[0.6713022203]},
{"learn":[0.6654893396],"iteration":289,"passed_time":10.32366496,"remaining_time":60.87402441,"test":[0.671296041]},
{"learn":[0.6654648912],"iteration":290,"passed_time":10.35344703,"remaining_time":60.80426453,"test":[0.6712829551]},
{"learn":[0.6654442759],"iteration":291,"passed_time":10.3949915,"remaining_time":60.8035804,"test":[0.6712769751]},
{"learn":[0.6654173127],"iteration":292,"passed_time":10.43148765,"remaining_time":60.77320621,"test":[0.6712702915]},
{"learn":[0.6653914518],"iteration":293,"passed_time":10.47162738,"remaining_time":60.76393303,"test":[0.6712379343]},
{"learn":[0.6653648946],"iteration":294,"passed_time":10.50360107,"remaining_time":60.70725362,"test":[0.6712192006]},
{"learn":[0.665344141],"iteration":295,"passed_time":10.53460819,"remaining_time":60.64517686,"test":[0.6712074061]},
{"learn":[0.6653140817],"iteration":296,"passed_time":10.57659448,"remaining_time":60.64626395,"test":[0.6711953324]},
{"learn":[0.665295365],"iteration":297,"passed_time":10.61260262,"remaining_time":60.61291829,"test":[0.6711891001]},
{"learn":[0.6652787488],"iteration":298,"passed_time":10.63910358,"remaining_time":60.52546889,"test":[0.6711870526]},
{"learn":[0.6652502991],"iteration":299,"passed_time":10.6681867,"remaining_time":60.45305797,"test":[0.6711812809]},
{"learn":[0.665231168],"iteration":300,"passed_time":10.70260503,"remaining_time":60.41104967,"test":[0.6711768946]},
{"learn":[0.6652136682],"iteration":301,"passed_time":10.72952096,"remaining_time":60.32690925,"test":[0.6711845012]},
{"learn":[0.6651903001],"iteration":302,"passed_time":10.76489952,"remaining_time":60.29054288,"test":[0.6711869636]},
{"learn":[0.6651697153],"iteration":303,"passed_time":10.80197155,"remaining_time":60.26363073,"test":[0.671186884]},
{"learn":[0.6651525958],"iteration":304,"passed_time":10.82922271,"remaining_time":60.18207375,"test":[0.6711890401]},
{"learn":[0.6651322685],"iteration":305,"passed_time":10.8578399,"remaining_time":60.10843394,"test":[0.6711868603]},
{"learn":[0.6651113828],"iteration":306,"passed_time":10.89228879,"remaining_time":60.06724727,"test":[0.6711900892]},
{"learn":[0.6650886807],"iteration":307,"passed_time":10.93056436,"remaining_time":60.04712628,"test":[0.6711884242]},
{"learn":[0.6650622251],"iteration":308,"passed_time":10.97231236,"remaining_time":60.04589061,"test":[0.6711837119]},
{"learn":[0.6650429987],"iteration":309,"passed_time":11.00296848,"remaining_time":59.98392494,"test":[0.6711766645]},
{"learn":[0.665015513],"iteration":310,"passed_time":11.03002276,"remaining_time":59.90259947,"test":[0.671172959]},
{"learn":[0.6650019022],"iteration":311,"passed_time":11.05828865,"remaining_time":59.82817707,"test":[0.6711740433]},
{"learn":[0.664979951],"iteration":312,"passed_time":11.09287745,"remaining_time":59.78812863,"test":[0.6711715069]},
{"learn":[0.6649549638],"iteration":313,"passed_time":11.1177757,"remaining_time":59.69608229,"test":[0.6711589843]},
{"learn":[0.6649340455],"iteration":314,"passed_time":11.14959087,"remaining_time":59.64146228,"test":[0.6711446402]},
{"learn":[0.6649162445],"iteration":315,"passed_time":11.18718772,"remaining_time":59.61779784,"test":[0.6711415366]},
{"learn":[0.6649048119],"iteration":316,"passed_time":11.21179073,"remaining_time":59.52505932,"test":[0.6711359351]},
{"learn":[0.6648796463],"iteration":317,"passed_time":11.24311165,"remaining_time":59.46828238,"test":[0.671143361]},
{"learn":[0.6648605481],"iteration":318,"passed_time":11.27486028,"remaining_time":59.41391889,"test":[0.6711353638]},
{"learn":[0.6648429084],"iteration":319,"passed_time":11.30400807,"remaining_time":59.34604237,"test":[0.6711444387]},
{"learn":[0.6648238121],"iteration":320,"passed_time":11.33488419,"remaining_time":59.28744721,"test":[0.6711487352]},
{"learn":[0.6647969527],"iteration":321,"passed_time":11.36208838,"remaining_time":59.20988915,"test":[0.67114436]},
{"learn":[0.6647854723],"iteration":322,"passed_time":11.39429642,"remaining_time":59.15862259,"test":[0.6711444722]},
{"learn":[0.6647589304],"iteration":323,"passed_time":11.4363998,"remaining_time":59.15866068,"test":[0.6711325635]},
{"learn":[0.6647429024],"iteration":324,"passed_time":11.47751019,"remaining_time":59.15332173,"test":[0.6711269403]},
{"learn":[0.6647237508],"iteration":325,"passed_time":11.5136833,"remaining_time":59.12241054,"test":[0.6711154078]},
{"learn":[0.6647059396],"iteration":326,"passed_time":11.54795566,"remaining_time":59.08174257,"test":[0.6711203043]},
{"learn":[0.664686288],"iteration":327,"passed_time":11.57245915,"remaining_time":58.99131613,"test":[0.6711241333]},
{"learn":[0.6646532527],"iteration":328,"passed_time":11.60790333,"remaining_time":58.95685857,"test":[0.6711213497]},
{"learn":[0.6646306438],"iteration":329,"passed_time":11.63787346,"remaining_time":58.89469298,"test":[0.6711231641]},
{"learn":[0.6646098516],"iteration":330,"passed_time":11.66805718,"remaining_time":58.83379887,"test":[0.6711049215]},
{"learn":[0.6645858284],"iteration":331,"passed_time":11.70070223,"remaining_time":58.78545579,"test":[0.6711031963]},
{"learn":[0.6645707188],"iteration":332,"passed_time":11.724753,"remaining_time":58.69418391,"test":[0.6710996314]},
{"learn":[0.6645485788],"iteration":333,"passed_time":11.75795297,"remaining_time":58.64895104,"test":[0.6710867309]},
{"learn":[0.6645305696],"iteration":334,"passed_time":11.78053066,"remaining_time":58.55099567,"test":[0.6710914578]},
{"learn":[0.6645108881],"iteration":335,"passed_time":11.81570271,"remaining_time":58.51586106,"test":[0.6710929585]},
{"learn":[0.6644923286],"iteration":336,"passed_time":11.8448851,"remaining_time":58.45116888,"test":[0.6710984779]},
{"learn":[0.6644805222],"iteration":337,"passed_time":11.86964023,"remaining_time":58.36491734,"test":[0.6710923199]},
{"learn":[0.6644572776],"iteration":338,"passed_time":11.90591446,"remaining_time":58.33546879,"test":[0.6710893917]},
{"learn":[0.6644320741],"iteration":339,"passed_time":11.94145444,"remaining_time":58.30239521,"test":[0.6710923306]},
{"learn":[0.6644115048],"iteration":340,"passed_time":11.98658051,"remaining_time":58.31594449,"test":[0.6710927901]},
{"learn":[0.6643949013],"iteration":341,"passed_time":12.02038848,"remaining_time":58.27428098,"test":[0.6711092802]},
{"learn":[0.6643619789],"iteration":342,"passed_time":12.06653941,"remaining_time":58.29229096,"test":[0.6711012995]},
{"learn":[0.6643389502],"iteration":343,"passed_time":12.12283646,"remaining_time":58.35877087,"test":[0.6711015305]},
{"learn":[0.6643088915],"iteration":344,"passed_time":12.17733618,"remaining_time":58.41591705,"test":[0.6710975574]},
{"learn":[0.664286972],"iteration":345,"passed_time":12.22133732,"remaining_time":58.42223099,"test":[0.6710899474]},
{"learn":[0.664274149],"iteration":346,"passed_time":12.2642467,"remaining_time":58.42305415,"test":[0.671085152]},
{"learn":[0.6642536926],"iteration":347,"passed_time":12.30091895,"remaining_time":58.39401755,"test":[0.6710814533]},
{"learn":[0.6642357634],"iteration":348,"passed_time":12.32484094,"remaining_time":58.30462002,"test":[0.6710701892]},
{"learn":[0.664207914],"iteration":349,"passed_time":12.35469303,"remaining_time":58.24355287,"test":[0.67105503]},
{"learn":[0.6641853097],"iteration":350,"passed_time":12.40148755,"remaining_time":58.26225919,"test":[0.6710527861]},
{"learn":[0.6641654917],"iteration":351,"passed_time":12.43803877,"remaining_time":58.23263605,"test":[0.6710508715]},
{"learn":[0.664143804],"iteration":352,"passed_time":12.47995438,"remaining_time":58.22800245,"test":[0.6710560803]},
{"learn":[0.6641290647],"iteration":353,"passed_time":12.51241326,"remaining_time":58.17918707,"test":[0.6710465693]},
{"learn":[0.6641117244],"iteration":354,"passed_time":12.5417829,"remaining_time":58.11614893,"test":[0.6710440741]},
{"learn":[0.6640880219],"iteration":355,"passed_time":12.5692936,"remaining_time":58.0447154,"test":[0.6710496913]},
{"learn":[0.6640669415],"iteration":356,"passed_time":12.5976392,"remaining_time":57.97737034,"test":[0.6710404659]},
{"learn":[0.6640462999],"iteration":357,"passed_time":12.62815847,"remaining_time":57.92021287,"test":[0.6710293986]},
{"learn":[0.664030296],"iteration":358,"passed_time":12.65342509,"remaining_time":57.8391938,"test":[0.6710353817]},
{"learn":[0.6640028542],"iteration":359,"passed_time":12.68233453,"remaining_time":57.77507954,"test":[0.6710271815]},
{"learn":[0.6639813347],"iteration":360,"passed_time":12.72037964,"remaining_time":57.75263774,"test":[0.6710288077]},
{"learn":[0.6639597941],"iteration":361,"passed_time":12.744473,"remaining_time":57.66698004,"test":[0.6710169894]},
{"learn":[0.6639429832],"iteration":362,"passed_time":12.77086568,"remaining_time":57.59203063,"test":[0.6710119848]},
{"learn":[0.6639222708],"iteration":363,"passed_time":12.81194554,"remaining_time":57.58335961,"test":[0.6710114775]},
{"learn":[0.6639065546],"iteration":364,"passed_time":12.84133287,"remaining_time":57.52213492,"test":[0.6710013614]},
{"learn":[0.6638823236],"iteration":365,"passed_time":12.87057337,"remaining_time":57.46042866,"test":[0.6709985657]},
{"learn":[0.6638648195],"iteration":366,"passed_time":12.8971183,"remaining_time":57.38690512,"test":[0.6709948954]},
{"learn":[0.6638436235],"iteration":367,"passed_time":12.93825161,"remaining_time":57.37833324,"test":[0.6709970591]},
{"learn":[0.6638208732],"iteration":368,"passed_time":12.97444296,"remaining_time":57.3477411,"test":[0.6709739289]},
{"learn":[0.6637956357],"iteration":369,"passed_time":13.00974924,"remaining_time":57.31321963,"test":[0.6709754911]},
{"learn":[0.6637718453],"iteration":370,"passed_time":13.03832239,"remaining_time":57.24912984,"test":[0.6709717066]},
{"learn":[0.663756918],"iteration":371,"passed_time":13.07843077,"remaining_time":57.23571316,"test":[0.67096845]},
{"learn":[0.6637353525],"iteration":372,"passed_time":13.11729124,"remaining_time":57.21671005,"test":[0.6709739445]},
{"learn":[0.6637143112],"iteration":373,"passed_time":13.14745329,"remaining_time":57.15978354,"test":[0.6709728881]},
{"learn":[0.6636956547],"iteration":374,"passed_time":13.18118022,"remaining_time":57.11844761,"test":[0.6709694284]},
{"learn":[0.663680995],"iteration":375,"passed_time":13.20539229,"remaining_time":57.03605604,"test":[0.6709604166]},
{"learn":[0.66366728],"iteration":376,"passed_time":13.23563977,"remaining_time":56.97995583,"test":[0.6709605025]},
{"learn":[0.6636487567],"iteration":377,"passed_time":13.27428255,"remaining_time":56.96001665,"test":[0.6709603727]},
{"learn":[0.6636266904],"iteration":378,"passed_time":13.30625754,"remaining_time":56.91146033,"test":[0.670944339]},
{"learn":[0.6636116064],"iteration":379,"passed_time":13.33327871,"remaining_time":56.84187241,"test":[0.6709447187]},
{"learn":[0.6635902746],"iteration":380,"passed_time":13.36632239,"remaining_time":56.79809961,"test":[0.6709538679]},
{"learn":[0.6635654896],"iteration":381,"passed_time":13.39639051,"remaining_time":56.74177969,"test":[0.6709640912]},
{"learn":[0.6635393029],"iteration":382,"passed_time":13.42189438,"remaining_time":56.66632694,"test":[0.6709534847]},
{"learn":[0.6635171734],"iteration":383,"passed_time":13.46730432,"remaining_time":56.6749057,"test":[0.6709471555]},
{"learn":[0.663500789],"iteration":384,"passed_time":13.50832777,"remaining_time":56.66480351,"test":[0.6709506783]},
{"learn":[0.663477743],"iteration":385,"passed_time":13.54029627,"remaining_time":56.61667921,"test":[0.6709546729]},
{"learn":[0.6634584806],"iteration":386,"passed_time":13.56996301,"remaining_time":56.5590448,"test":[0.670930774]},
{"learn":[0.6634337499],"iteration":387,"passed_time":13.59835745,"remaining_time":56.4962686,"test":[0.6709287322]},
{"learn":[0.6634135584],"iteration":388,"passed_time":13.6279617,"remaining_time":56.43867943,"test":[0.6709198643]},
{"learn":[0.6633868455],"iteration":389,"passed_time":13.65633448,"remaining_time":56.37615005,"test":[0.6709220389]},
{"learn":[0.6633755323],"iteration":390,"passed_time":13.68565529,"remaining_time":56.31769658,"test":[0.6709230923]},
{"learn":[0.663356103],"iteration":391,"passed_time":13.71789303,"remaining_time":56.27135714,"test":[0.670930414]},
{"learn":[0.6633337631],"iteration":392,"passed_time":13.75060752,"remaining_time":56.2270389,"test":[0.6709354296]},
{"learn":[0.663319422],"iteration":393,"passed_time":13.77167974,"remaining_time":56.13532403,"test":[0.6709351544]},
{"learn":[0.6632911566],"iteration":394,"passed_time":13.80416242,"remaining_time":56.09033084,"test":[0.6709414935]},
{"learn":[0.6632687875],"iteration":395,"passed_time":13.82525369,"remaining_time":55.9992599,"test":[0.6709445943]},
{"learn":[0.6632431997],"iteration":396,"passed_time":13.85836516,"remaining_time":55.95707646,"test":[0.6709475685]},
{"learn":[0.6632189331],"iteration":397,"passed_time":13.88898168,"remaining_time":55.90489613,"test":[0.6709533591]},
{"learn":[0.663201035],"iteration":398,"passed_time":13.91726355,"remaining_time":55.84345598,"test":[0.6709592222]},
{"learn":[0.6631898553],"iteration":399,"passed_time":13.95316828,"remaining_time":55.81267311,"test":[0.6709508704]},
{"learn":[0.6631712482],"iteration":400,"passed_time":13.99418497,"remaining_time":55.80224881,"test":[0.6709479912]},
{"learn":[0.663143025],"iteration":401,"passed_time":14.0253575,"remaining_time":55.75254052,"test":[0.6709417519]},
{"learn":[0.663121538],"iteration":402,"passed_time":14.04844239,"remaining_time":55.67087467,"test":[0.6709476082]},
{"learn":[0.6631087792],"iteration":403,"passed_time":14.0761289,"remaining_time":55.60767753,"test":[0.6709480979]},
{"learn":[0.6630859067],"iteration":404,"passed_time":14.10555105,"remaining_time":55.55149118,"test":[0.6709448724]},
{"learn":[0.663066483],"iteration":405,"passed_time":14.1427661,"remaining_time":55.52603242,"test":[0.6709421934]},
{"learn":[0.6630443652],"iteration":406,"passed_time":14.18285552,"remaining_time":55.51176619,"test":[0.6709386261]},
{"learn":[0.6630250376],"iteration":407,"passed_time":14.21458769,"remaining_time":55.46476372,"test":[0.6709461564]},
{"learn":[0.6630007822],"iteration":408,"passed_time":14.24035708,"remaining_time":55.39464088,"test":[0.670934384]},
{"learn":[0.6629768728],"iteration":409,"passed_time":14.26711915,"remaining_time":55.32858403,"test":[0.6709312987]},
{"learn":[0.6629528093],"iteration":410,"passed_time":14.29943785,"remaining_time":55.28420133,"test":[0.670931806]},
{"learn":[0.6629260936],"iteration":411,"passed_time":14.32489173,"remaining_time":55.21341763,"test":[0.6709286111]},
{"learn":[0.6629102182],"iteration":412,"passed_time":14.35119075,"remaining_time":55.14610101,"test":[0.6709224729]},
{"learn":[0.6628863488],"iteration":413,"passed_time":14.37946054,"remaining_time":55.08653242,"test":[0.6709236504]},
{"learn":[0.6628648972],"iteration":414,"passed_time":14.41005914,"remaining_time":55.03600899,"test":[0.6709245901]},
{"learn":[0.6628454339],"iteration":415,"passed_time":14.45103793,"remaining_time":55.02510598,"test":[0.6709463437]},
{"learn":[0.6628200274],"iteration":416,"passed_time":14.48428995,"remaining_time":54.98472661,"test":[0.6709567049]},
{"learn":[0.6627942591],"iteration":417,"passed_time":14.5135184,"remaining_time":54.92915339,"test":[0.670945606]},
{"learn":[0.6627744647],"iteration":418,"passed_time":14.53698524,"remaining_time":54.85196578,"test":[0.6709479298]},
{"learn":[0.662765485],"iteration":419,"passed_time":14.56542473,"remaining_time":54.79374067,"test":[0.6709464351]},
{"learn":[0.6627503257],"iteration":420,"passed_time":14.58728594,"remaining_time":54.71098455,"test":[0.6709414048]},
{"learn":[0.6627323029],"iteration":421,"passed_time":14.61501375,"remaining_time":54.65045425,"test":[0.6709414427]},
{"learn":[0.6627111509],"iteration":422,"passed_time":14.64231614,"remaining_time":54.58849302,"test":[0.6709296343]},
{"learn":[0.6626785863],"iteration":423,"passed_time":14.66665432,"remaining_time":54.51567739,"test":[0.670924721]},
{"learn":[0.6626576561],"iteration":424,"passed_time":14.69050441,"remaining_time":54.44128104,"test":[0.670906284]},
{"learn":[0.6626363113],"iteration":425,"passed_time":14.71910475,"remaining_time":54.38467341,"test":[0.6708996826]},
{"learn":[0.6626181065],"iteration":426,"passed_time":14.73941058,"remaining_time":54.2976413,"test":[0.6708987677]},
{"learn":[0.66259794],"iteration":427,"passed_time":14.77242451,"remaining_time":54.25759657,"test":[0.670909526]},
{"learn":[0.6625765658],"iteration":428,"passed_time":14.79088688,"remaining_time":54.1642967,"test":[0.6709033226]},
{"learn":[0.6625526572],"iteration":429,"passed_time":14.82430966,"remaining_time":54.12596783,"test":[0.6708750209]},
{"learn":[0.66253135],"iteration":430,"passed_time":14.84439175,"remaining_time":54.03909666,"test":[0.6708752079]},
{"learn":[0.6625035695],"iteration":431,"passed_time":14.8764415,"remaining_time":53.99597284,"test":[0.6708776566]},
{"learn":[0.662480212],"iteration":432,"passed_time":14.90666075,"remaining_time":53.94627573,"test":[0.6708736133]},
{"learn":[0.6624611632],"iteration":433,"passed_time":14.93845927,"remaining_time":53.90236684,"test":[0.6708754298]},
{"learn":[0.6624332625],"iteration":434,"passed_time":14.98024104,"remaining_time":53.89443041,"test":[0.6708751084]},
{"learn":[0.6624120584],"iteration":435,"passed_time":15.00605075,"remaining_time":53.82904442,"test":[0.6708642042]},
{"learn":[0.6623941719],"iteration":436,"passed_time":15.03384083,"remaining_time":53.77092268,"test":[0.6708610465]},
{"learn":[0.6623766304],"iteration":437,"passed_time":15.05972545,"remaining_time":53.70614417,"test":[0.6708574768]},
{"learn":[0.6623623329],"iteration":438,"passed_time":15.08505889,"remaining_time":53.63958297,"test":[0.6708557953]},
{"learn":[0.6623442925],"iteration":439,"passed_time":15.11080547,"remaining_time":53.57467393,"test":[0.670871378]},
{"learn":[0.6623212715],"iteration":440,"passed_time":15.13466304,"remaining_time":53.50326458,"test":[0.6708640187]},
{"learn":[0.6623025941],"iteration":441,"passed_time":15.16037021,"remaining_time":53.43859001,"test":[0.6708700565]},
{"learn":[0.6622749791],"iteration":442,"passed_time":15.18471062,"remaining_time":53.36928767,"test":[0.6708667534]},
{"learn":[0.6622534499],"iteration":443,"passed_time":15.21140556,"remaining_time":53.30843931,"test":[0.6708675383]},
{"learn":[0.6622305473],"iteration":444,"passed_time":15.23498219,"remaining_time":53.23684787,"test":[0.6708740175]},
{"learn":[0.6622059333],"iteration":445,"passed_time":15.26647355,"remaining_time":53.19304911,"test":[0.6708774523]},
{"learn":[0.6621871707],"iteration":446,"passed_time":15.28793136,"remaining_time":53.11444609,"test":[0.6708697231]},
{"learn":[0.6621638454],"iteration":447,"passed_time":15.31613827,"remaining_time":53.05947899,"test":[0.6708614971]},
{"learn":[0.6621511296],"iteration":448,"passed_time":15.33689091,"remaining_time":52.9788815,"test":[0.6708607946]},
{"learn":[0.6621349978],"iteration":449,"passed_time":15.36674634,"remaining_time":52.92990406,"test":[0.6708740865]},
{"learn":[0.6621120424],"iteration":450,"passed_time":15.393642,"remaining_time":52.87084582,"test":[0.6708729562]},
{"learn":[0.6620958271],"iteration":451,"passed_time":15.42984657,"remaining_time":52.84381082,"test":[0.6708674017]},
{"learn":[0.6620793528],"iteration":452,"passed_time":15.46956188,"remaining_time":52.82872456,"test":[0.6708693088]},
{"learn":[0.6620572713],"iteration":453,"passed_time":15.49032259,"remaining_time":52.74898396,"test":[0.6708712037]},
{"learn":[0.6620395025],"iteration":454,"passed_time":15.52379393,"remaining_time":52.71266289,"test":[0.6708703905]},
{"learn":[0.6620188044],"iteration":455,"passed_time":15.55053135,"remaining_time":52.65355352,"test":[0.6708577595]},
{"learn":[0.6620017347],"iteration":456,"passed_time":15.57735398,"remaining_time":52.59487352,"test":[0.6708493546]},
{"learn":[0.6619811454],"iteration":457,"passed_time":15.60434803,"remaining_time":52.53690973,"test":[0.6708523777]},
{"learn":[0.6619695569],"iteration":458,"passed_time":15.63056555,"remaining_time":52.47647387,"test":[0.6708454134]},
{"learn":[0.661952377],"iteration":459,"passed_time":15.656355,"remaining_time":52.41475368,"test":[0.6708404483]},
{"learn":[0.6619237442],"iteration":460,"passed_time":15.68232112,"remaining_time":52.35377918,"test":[0.6708274771]},
{"learn":[0.6619089407],"iteration":461,"passed_time":15.71164945,"remaining_time":52.30414904,"test":[0.6708244992]},
{"learn":[0.6618886168],"iteration":462,"passed_time":15.7361944,"remaining_time":52.23872743,"test":[0.6708344314]},
{"learn":[0.6618831383],"iteration":463,"passed_time":15.76527735,"remaining_time":52.18850433,"test":[0.6708279081]},
{"learn":[0.6618690774],"iteration":464,"passed_time":15.78652262,"remaining_time":52.11249942,"test":[0.6708258106]},
{"learn":[0.661845878],"iteration":465,"passed_time":15.81756836,"remaining_time":52.06899113,"test":[0.6708049714]},
{"learn":[0.6618290213],"iteration":466,"passed_time":15.83979966,"remaining_time":51.99660146,"test":[0.670810989]},
{"learn":[0.6618050064],"iteration":467,"passed_time":15.87342473,"remaining_time":51.9617237,"test":[0.6708212237]},
{"learn":[0.6617832833],"iteration":468,"passed_time":15.90381555,"remaining_time":51.9162934,"test":[0.6708221741]},
{"learn":[0.6617652311],"iteration":469,"passed_time":15.93502938,"remaining_time":51.87360627,"test":[0.6708259658]},
{"learn":[0.6617443144],"iteration":470,"passed_time":15.96919221,"remaining_time":51.84054117,"test":[0.6708159692]},
{"learn":[0.6617202619],"iteration":471,"passed_time":15.99477329,"remaining_time":51.77968981,"test":[0.6708136212]},
{"learn":[0.6617005831],"iteration":472,"passed_time":16.02279091,"remaining_time":51.72685354,"test":[0.6708224942]},
{"learn":[0.6616824419],"iteration":473,"passed_time":16.04763422,"remaining_time":51.66390258,"test":[0.6708363084]},
{"learn":[0.6616538226],"iteration":474,"passed_time":16.07374645,"remaining_time":51.60518598,"test":[0.670850875]},
{"learn":[0.6616314155],"iteration":475,"passed_time":16.09993591,"remaining_time":51.54685363,"test":[0.6708527236]},
{"learn":[0.6616127861],"iteration":476,"passed_time":16.12811357,"remaining_time":51.49500411,"test":[0.6708453401]},
{"learn":[0.6616029072],"iteration":477,"passed_time":16.15264086,"remaining_time":51.43163051,"test":[0.6708413844]},
{"learn":[0.6615843751],"iteration":478,"passed_time":16.17696751,"remaining_time":51.36778201,"test":[0.6708364569]},
{"learn":[0.661563216],"iteration":479,"passed_time":16.20551145,"remaining_time":51.31745293,"test":[0.6708251774]},
{"learn":[0.6615432257],"iteration":480,"passed_time":16.22860577,"remaining_time":51.2500045,"test":[0.6708154393]},
{"learn":[0.6615263324],"iteration":481,"passed_time":16.25544093,"remaining_time":51.19452144,"test":[0.6708111613]},
{"learn":[0.6615033259],"iteration":482,"passed_time":16.27729221,"remaining_time":51.12350369,"test":[0.6708102339]},
{"learn":[0.661484293],"iteration":483,"passed_time":16.30502335,"remaining_time":51.07110619,"test":[0.6707929623]},
{"learn":[0.6614678231],"iteration":484,"passed_time":16.32842702,"remaining_time":51.00529266,"test":[0.6707900226]},
{"learn":[0.6614463024],"iteration":485,"passed_time":16.36272839,"remaining_time":50.97360242,"test":[0.6707832384]},
{"learn":[0.6614155436],"iteration":486,"passed_time":16.39272506,"remaining_time":50.92852776,"test":[0.6707739118]},
{"learn":[0.6613958945],"iteration":487,"passed_time":16.42636604,"remaining_time":50.89480625,"test":[0.6707737538]},
{"learn":[0.661380611],"iteration":488,"passed_time":16.4597142,"remaining_time":50.86018027,"test":[0.6707730234]},
{"learn":[0.6613677802],"iteration":489,"passed_time":16.48056007,"remaining_time":50.78703206,"test":[0.6707796291]},
{"learn":[0.6613530086],"iteration":490,"passed_time":16.51091177,"remaining_time":50.74331132,"test":[0.670791408]},
{"learn":[0.6613248211],"iteration":491,"passed_time":16.53097438,"remaining_time":50.66810846,"test":[0.6707944906]},
{"learn":[0.6613059359],"iteration":492,"passed_time":16.56161402,"remaining_time":50.62546112,"test":[0.6707835635]},
{"learn":[0.6612729965],"iteration":493,"passed_time":16.5854633,"remaining_time":50.56216139,"test":[0.6707908928]},
{"learn":[0.6612624948],"iteration":494,"passed_time":16.61302735,"remaining_time":50.51031547,"test":[0.670796262]},
{"learn":[0.6612401679],"iteration":495,"passed_time":16.63896978,"remaining_time":50.45365029,"test":[0.6707877825]},
{"learn":[0.6612191637],"iteration":496,"passed_time":16.663707,"remaining_time":50.39346403,"test":[0.6707854132]},
{"learn":[0.6611912219],"iteration":497,"passed_time":16.69040179,"remaining_time":50.33932428,"test":[0.6707756206]},
{"learn":[0.6611773017],"iteration":498,"passed_time":16.71612789,"remaining_time":50.28238068,"test":[0.6707707899]},
{"learn":[0.6611638216],"iteration":499,"passed_time":16.74072553,"remaining_time":50.2221766,"test":[0.6707704386]},
{"learn":[0.6611450533],"iteration":500,"passed_time":16.77346538,"remaining_time":50.18647626,"test":[0.6707621465]},
{"learn":[0.6611179111],"iteration":501,"passed_time":16.80230735,"remaining_time":50.13915621,"test":[0.6707661931]},
{"learn":[0.6610959069],"iteration":502,"passed_time":16.83637769,"remaining_time":50.10747,"test":[0.6707651988]},
{"learn":[0.6610728788],"iteration":503,"passed_time":16.87382128,"remaining_time":50.08578697,"test":[0.6707607827]},
{"learn":[0.6610436668],"iteration":504,"passed_time":16.92151611,"remaining_time":50.09438927,"test":[0.670760242]},
{"learn":[0.6610188976],"iteration":505,"passed_time":16.9898618,"remaining_time":50.16374216,"test":[0.6707506008]},
{"learn":[0.6610030555],"iteration":506,"passed_time":17.03818668,"remaining_time":50.17359509,"test":[0.6707452886]},
{"learn":[0.6609831174],"iteration":507,"passed_time":17.06933058,"remaining_time":50.13275833,"test":[0.6707355189]},
{"learn":[0.6609586562],"iteration":508,"passed_time":17.1106164,"remaining_time":50.12166807,"test":[0.6707312551]},
{"learn":[0.660935882],"iteration":509,"passed_time":17.14537899,"remaining_time":50.09140137,"test":[0.6707199485]},
{"learn":[0.6609202024],"iteration":510,"passed_time":17.19066307,"remaining_time":50.09177556,"test":[0.6707131947]},
{"learn":[0.6609011137],"iteration":511,"passed_time":17.21958034,"remaining_time":50.04440537,"test":[0.6707154112]},
{"learn":[0.6608726737],"iteration":512,"passed_time":17.24756917,"remaining_time":49.99441591,"test":[0.6706982346]},
{"learn":[0.6608608849],"iteration":513,"passed_time":17.27150822,"remaining_time":49.93280391,"test":[0.6706988941]},
{"learn":[0.6608387256],"iteration":514,"passed_time":17.29800365,"remaining_time":49.87870957,"test":[0.6706989098]},
{"learn":[0.6608136063],"iteration":515,"passed_time":17.34332283,"remaining_time":49.87885868,"test":[0.670693306]},
{"learn":[0.6607946343],"iteration":516,"passed_time":17.37393636,"remaining_time":49.83664916,"test":[0.6706944515]},
{"learn":[0.6607703935],"iteration":517,"passed_time":17.4173655,"remaining_time":49.83114994,"test":[0.6706899688]},
{"learn":[0.6607509625],"iteration":518,"passed_time":17.46008645,"remaining_time":49.82348368,"test":[0.6706909374]},
{"learn":[0.6607238109],"iteration":519,"passed_time":17.4906988,"remaining_time":49.78121967,"test":[0.6706855074]},
{"learn":[0.6606999858],"iteration":520,"passed_time":17.5186435,"remaining_time":49.7314275,"test":[0.6706787779]},
{"learn":[0.6606813873],"iteration":521,"passed_time":17.54613056,"remaining_time":49.6804233,"test":[0.6706737082]},
{"learn":[0.6606610372],"iteration":522,"passed_time":17.57100039,"remaining_time":49.62211774,"test":[0.6706761225]},
{"learn":[0.660638456],"iteration":523,"passed_time":17.60084283,"remaining_time":49.5779466,"test":[0.670685455]},
{"learn":[0.6606156483],"iteration":524,"passed_time":17.62599925,"remaining_time":49.52066456,"test":[0.6706693855]},
{"learn":[0.6605968623],"iteration":525,"passed_time":17.65519625,"remaining_time":49.47482751,"test":[0.6706647216]},
{"learn":[0.6605735776],"iteration":526,"passed_time":17.67910836,"remaining_time":49.41428199,"test":[0.6706569188]},
{"learn":[0.6605517294],"iteration":527,"passed_time":17.70744827,"remaining_time":49.36621942,"test":[0.6706549134]},
{"learn":[0.6605309239],"iteration":528,"passed_time":17.72943083,"remaining_time":49.3005534,"test":[0.6706547978]},
{"learn":[0.6605086434],"iteration":529,"passed_time":17.75830336,"remaining_time":49.25416215,"test":[0.6706564214]},
{"learn":[0.6604803349],"iteration":530,"passed_time":17.78141858,"remaining_time":49.19190939,"test":[0.6706559196]},
{"learn":[0.6604566326],"iteration":531,"passed_time":17.80870208,"remaining_time":49.14130574,"test":[0.6706515072]},
{"learn":[0.6604430839],"iteration":532,"passed_time":17.82904188,"remaining_time":49.07167811,"test":[0.6706474616]},
{"learn":[0.6604273738],"iteration":533,"passed_time":17.86246645,"remaining_time":49.03815696,"test":[0.6706424204]},
{"learn":[0.6604048016],"iteration":534,"passed_time":17.90552779,"remaining_time":49.03102469,"test":[0.6706520008]},
{"learn":[0.6603845173],"iteration":535,"passed_time":18.02843143,"remaining_time":49.24183511,"test":[0.6706448306]},
{"learn":[0.6603669212],"iteration":536,"passed_time":18.07245966,"remaining_time":49.23651485,"test":[0.6706415789]},
{"learn":[0.6603488983],"iteration":537,"passed_time":18.10631942,"remaining_time":49.20341819,"test":[0.6706305359]},
{"learn":[0.6603176881],"iteration":538,"passed_time":18.13531438,"remaining_time":49.1571323,"test":[0.6706152774]},
{"learn":[0.6602953862],"iteration":539,"passed_time":18.16575265,"remaining_time":49.11481272,"test":[0.670616585]},
{"learn":[0.6602672025],"iteration":540,"passed_time":18.20025584,"remaining_time":49.08349958,"test":[0.6705963243]},
{"learn":[0.6602568636],"iteration":541,"passed_time":18.22381751,"remaining_time":49.02274158,"test":[0.6706027368]},
{"learn":[0.660235705],"iteration":542,"passed_time":18.25438575,"remaining_time":48.98092088,"test":[0.6706003522]},
{"learn":[0.6602152295],"iteration":543,"passed_time":18.28070524,"remaining_time":48.9277699,"test":[0.6706044301]},
{"learn":[0.6601897709],"iteration":544,"passed_time":18.30768805,"remaining_time":48.87648827,"test":[0.6706047241]},
{"learn":[0.6601683731],"iteration":545,"passed_time":18.33807201,"remaining_time":48.83435294,"test":[0.6706038235]},
{"learn":[0.6601472267],"iteration":546,"passed_time":18.36776304,"remaining_time":48.79041993,"test":[0.6706026913]},
{"learn":[0.6601262337],"iteration":547,"passed_time":18.41134623,"remaining_time":48.78334803,"test":[0.6705845786]},
{"learn":[0.6601119991],"iteration":548,"passed_time":18.44405381,"remaining_time":48.74739905,"test":[0.6705873967]},
{"learn":[0.6600869973],"iteration":549,"passed_time":18.47010718,"remaining_time":48.69391893,"test":[0.6705755426]},
{"learn":[0.6600667497],"iteration":550,"passed_time":18.5036553,"remaining_time":48.66024779,"test":[0.6705715731]},
{"learn":[0.6600397508],"iteration":551,"passed_time":18.53164471,"remaining_time":48.61199556,"test":[0.6705757153]},
{"learn":[0.660016863],"iteration":552,"passed_time":18.5577607,"remaining_time":48.55891452,"test":[0.6705516814]},
{"learn":[0.6599933158],"iteration":553,"passed_time":18.58492994,"remaining_time":48.50867995,"test":[0.6705530864]},
{"learn":[0.6599632649],"iteration":554,"passed_time":18.62562092,"remaining_time":48.49373376,"test":[0.6705552479]},
{"learn":[0.6599446007],"iteration":555,"passed_time":18.65010209,"remaining_time":48.43659608,"test":[0.6705563336]},
{"learn":[0.6599138126],"iteration":556,"passed_time":18.67796421,"remaining_time":48.38833458,"test":[0.6705718544]},
{"learn":[0.6598965504],"iteration":557,"passed_time":18.70319381,"remaining_time":48.33334314,"test":[0.6705688384]},
{"learn":[0.6598785723],"iteration":558,"passed_time":18.72995694,"remaining_time":48.28241136,"test":[0.6705641528]},
{"learn":[0.659860838],"iteration":559,"passed_time":18.75657945,"remaining_time":48.23120429,"test":[0.6705628467]},
{"learn":[0.6598408724],"iteration":560,"passed_time":18.78181322,"remaining_time":48.17652269,"test":[0.670558488]},
{"learn":[0.6598244857],"iteration":561,"passed_time":18.80867415,"remaining_time":48.12610931,"test":[0.6705544404]},
{"learn":[0.6598082469],"iteration":562,"passed_time":18.83488797,"remaining_time":48.0741279,"test":[0.6705617451]},
{"learn":[0.6597851673],"iteration":563,"passed_time":18.86939449,"remaining_time":48.04335193,"test":[0.6705631717]},
{"learn":[0.6597683521],"iteration":564,"passed_time":18.90235988,"remaining_time":48.00864854,"test":[0.6705636201]},
{"learn":[0.6597479006],"iteration":565,"passed_time":18.93001053,"remaining_time":47.96048604,"test":[0.6705537522]},
{"learn":[0.6597310938],"iteration":566,"passed_time":18.95858079,"remaining_time":47.91472006,"test":[0.670555083]},
{"learn":[0.6597096581],"iteration":567,"passed_time":18.9833487,"remaining_time":47.85942842,"test":[0.6705524541]},
{"learn":[0.6596862311],"iteration":568,"passed_time":19.0162481,"remaining_time":47.82469425,"test":[0.6705503132]},
{"learn":[0.6596574779],"iteration":569,"passed_time":19.03781666,"remaining_time":47.76154004,"test":[0.6705354602]},
{"learn":[0.6596385418],"iteration":570,"passed_time":19.0681355,"remaining_time":47.72043018,"test":[0.6705387012]},
{"learn":[0.6596189903],"iteration":571,"passed_time":19.09073714,"remaining_time":47.66009201,"test":[0.6705411923]},
{"learn":[0.65959275],"iteration":572,"passed_time":19.11146842,"remaining_time":47.59522765,"test":[0.6705390018]},
{"learn":[0.6595730662],"iteration":573,"passed_time":19.141368,"remaining_time":47.55329403,"test":[0.6705354939]},
{"learn":[0.6595566809],"iteration":574,"passed_time":19.16428373,"remaining_time":47.49409447,"test":[0.670531296]},
{"learn":[0.6595365076],"iteration":575,"passed_time":19.19652276,"remaining_time":47.45807015,"test":[0.6705377163]},
{"learn":[0.6595163446],"iteration":576,"passed_time":19.21727405,"remaining_time":47.39372785,"test":[0.6705248875]},
{"learn":[0.6594816637],"iteration":577,"passed_time":19.24969594,"remaining_time":47.35824848,"test":[0.6705252902]},
{"learn":[0.6594570142],"iteration":578,"passed_time":19.27445137,"remaining_time":47.30396442,"test":[0.6705181562]},
{"learn":[0.6594353055],"iteration":579,"passed_time":19.29822455,"remaining_time":47.24737734,"test":[0.6705123446]},
{"learn":[0.6594162362],"iteration":580,"passed_time":19.32403522,"remaining_time":47.19587948,"test":[0.6705128345]},
{"learn":[0.659395036],"iteration":581,"passed_time":19.35739555,"remaining_time":47.16286408,"test":[0.6705173712]},
{"learn":[0.6593798831],"iteration":582,"passed_time":19.39112791,"remaining_time":47.13075172,"test":[0.670541941]},
{"learn":[0.6593556719],"iteration":583,"passed_time":19.42704318,"remaining_time":47.1039266,"test":[0.6705463243]},
{"learn":[0.6593292627],"iteration":584,"passed_time":19.46022169,"remaining_time":47.07045077,"test":[0.6705513215]},
{"learn":[0.6592976737],"iteration":585,"passed_time":19.48332075,"remaining_time":47.01265452,"test":[0.6705455889]},
{"learn":[0.6592754841],"iteration":586,"passed_time":19.5115578,"remaining_time":46.9673444,"test":[0.6705408087]},
{"learn":[0.6592510441],"iteration":587,"passed_time":19.54275193,"remaining_time":46.92919341,"test":[0.6705510193]},
{"learn":[0.6592290326],"iteration":588,"passed_time":19.56411389,"remaining_time":46.86751222,"test":[0.6705456751]},
{"learn":[0.6592097404],"iteration":589,"passed_time":19.59700884,"remaining_time":46.8335296,"test":[0.6705402427]},
{"learn":[0.6591876204],"iteration":590,"passed_time":19.62169623,"remaining_time":46.77998306,"test":[0.6705443402]},
{"learn":[0.6591705995],"iteration":591,"passed_time":19.64747626,"remaining_time":46.72913272,"test":[0.67054441]},
{"learn":[0.6591456195],"iteration":592,"passed_time":19.67090184,"remaining_time":46.67278059,"test":[0.6705441955]},
{"learn":[0.6591107122],"iteration":593,"passed_time":19.69910949,"remaining_time":46.62785848,"test":[0.6705319356]},
{"learn":[0.6590819533],"iteration":594,"passed_time":19.72694709,"remaining_time":46.58211876,"test":[0.6705358843]},
{"learn":[0.6590551327],"iteration":595,"passed_time":19.7530808,"remaining_time":46.53242523,"test":[0.6705334396]},
{"learn":[0.6590373916],"iteration":596,"passed_time":19.77835609,"remaining_time":46.48079328,"test":[0.6705320462]},
{"learn":[0.6590177149],"iteration":597,"passed_time":19.80378809,"remaining_time":46.4296169,"test":[0.6705332043]},
{"learn":[0.6589946095],"iteration":598,"passed_time":19.83052585,"remaining_time":46.38158048,"test":[0.6705328363]},
{"learn":[0.6589697628],"iteration":599,"passed_time":19.8579153,"remaining_time":46.33513569,"test":[0.6705315638]},
{"learn":[0.6589442269],"iteration":600,"passed_time":19.89600309,"remaining_time":46.31365777,"test":[0.6705274435]},
{"learn":[0.6589182437],"iteration":601,"passed_time":19.92518872,"remaining_time":46.27145155,"test":[0.670509808]},
{"learn":[0.6588837179],"iteration":602,"passed_time":19.95754179,"remaining_time":46.23662666,"test":[0.6705077789]},
{"learn":[0.6588674101],"iteration":603,"passed_time":19.99116426,"remaining_time":46.20474388,"test":[0.6705212132]},
{"learn":[0.6588406916],"iteration":604,"passed_time":20.01900069,"remaining_time":46.15951398,"test":[0.6705098442]},
{"learn":[0.6588149945],"iteration":605,"passed_time":20.04735837,"remaining_time":46.11554053,"test":[0.6705061509]},
{"learn":[0.6587866031],"iteration":606,"passed_time":20.07232044,"remaining_time":46.06382599,"test":[0.6705003071]},
{"learn":[0.6587636648],"iteration":607,"passed_time":20.09871086,"remaining_time":46.01546959,"test":[0.6705045031]},
{"learn":[0.6587502469],"iteration":608,"passed_time":20.12348304,"remaining_time":45.96348917,"test":[0.6705083194]},
{"learn":[0.6587292784],"iteration":609,"passed_time":20.14920752,"remaining_time":45.91376797,"test":[0.6705329997]},
{"learn":[0.6587104112],"iteration":610,"passed_time":20.17662353,"remaining_time":45.86797068,"test":[0.6705269987]},
{"learn":[0.6586953782],"iteration":611,"passed_time":20.20202219,"remaining_time":45.81765818,"test":[0.6705315607]},
{"learn":[0.6586641191],"iteration":612,"passed_time":20.23050051,"remaining_time":45.77439512,"test":[0.6705142835]},
{"learn":[0.6586450136],"iteration":613,"passed_time":20.25381994,"remaining_time":45.71953492,"test":[0.6705165015]},
{"learn":[0.6586136263],"iteration":614,"passed_time":20.28518384,"remaining_time":45.68289369,"test":[0.6705001061]},
{"learn":[0.6585862768],"iteration":615,"passed_time":20.3078175,"remaining_time":45.62665489,"test":[0.6705013916]},
{"learn":[0.6585585235],"iteration":616,"passed_time":20.33878033,"remaining_time":45.5891948,"test":[0.6705037253]},
{"learn":[0.6585371631],"iteration":617,"passed_time":20.36122842,"remaining_time":45.53271469,"test":[0.67049647]},
{"learn":[0.6585092632],"iteration":618,"passed_time":20.3943397,"remaining_time":45.50013429,"test":[0.6705005632]},
{"learn":[0.6584914317],"iteration":619,"passed_time":20.42384285,"remaining_time":45.45952119,"test":[0.6704957943]},
{"learn":[0.6584662432],"iteration":620,"passed_time":20.45411533,"remaining_time":45.42065225,"test":[0.6704955333]},
{"learn":[0.6584454668],"iteration":621,"passed_time":20.488223,"remaining_time":45.39030754,"test":[0.6704961207]},
{"learn":[0.6584249408],"iteration":622,"passed_time":20.51043528,"remaining_time":45.33365872,"test":[0.6704921459]},
{"learn":[0.6583931228],"iteration":623,"passed_time":20.54384208,"remaining_time":45.30180561,"test":[0.6704751713]},
{"learn":[0.6583660767],"iteration":624,"passed_time":20.56912557,"remaining_time":45.25207624,"test":[0.6704753101]},
{"learn":[0.658354264],"iteration":625,"passed_time":20.59414123,"remaining_time":45.20183714,"test":[0.6704620888]},
{"learn":[0.6583253625],"iteration":626,"passed_time":20.61901142,"remaining_time":45.15135993,"test":[0.6704604282]},
{"learn":[0.6582968632],"iteration":627,"passed_time":20.6468542,"remaining_time":45.10745855,"test":[0.6704663192]},
{"learn":[0.6582687399],"iteration":628,"passed_time":20.67583093,"remaining_time":45.06607981,"test":[0.6704680085]},
{"learn":[0.658242535],"iteration":629,"passed_time":20.7010198,"remaining_time":45.01650336,"test":[0.670453228]},
{"learn":[0.6582199874],"iteration":630,"passed_time":20.72783977,"remaining_time":44.97054302,"test":[0.6704577785]},
{"learn":[0.6581918101],"iteration":631,"passed_time":20.75222724,"remaining_time":44.91937795,"test":[0.67046675]},
{"learn":[0.6581735218],"iteration":632,"passed_time":20.78264004,"remaining_time":44.88130954,"test":[0.6704731863]},
{"learn":[0.6581445869],"iteration":633,"passed_time":20.80459182,"remaining_time":44.82503538,"test":[0.6704811116]},
{"learn":[0.6581202427],"iteration":634,"passed_time":20.83717209,"remaining_time":44.79171637,"test":[0.6704839644]},
{"learn":[0.6580977862],"iteration":635,"passed_time":20.86231353,"remaining_time":44.74244599,"test":[0.6704854798]},
{"learn":[0.6580724179],"iteration":636,"passed_time":20.89269601,"remaining_time":44.70446572,"test":[0.6704835837]},
{"learn":[0.6580426322],"iteration":637,"passed_time":20.93117347,"remaining_time":44.68379039,"test":[0.6704736198]},
{"learn":[0.6580111256],"iteration":638,"passed_time":20.96066949,"remaining_time":44.64392985,"test":[0.6704640242]},
{"learn":[0.6579834747],"iteration":639,"passed_time":20.9941179,"remaining_time":44.61250055,"test":[0.670465663]},
{"learn":[0.6579541367],"iteration":640,"passed_time":21.0224519,"remaining_time":44.57022174,"test":[0.6704646829]},
{"learn":[0.6579254503],"iteration":641,"passed_time":21.0522529,"remaining_time":44.53108946,"test":[0.6704600961]},
{"learn":[0.657898555],"iteration":642,"passed_time":21.08260618,"remaining_time":44.49315178,"test":[0.6704643207]},
{"learn":[0.6578676875],"iteration":643,"passed_time":21.10716702,"remaining_time":44.44304112,"test":[0.6704600533]},
{"learn":[0.6578324163],"iteration":644,"passed_time":21.13594828,"remaining_time":44.40187584,"test":[0.6704614691]},
{"learn":[0.6578062223],"iteration":645,"passed_time":21.1601277,"remaining_time":44.35110357,"test":[0.6704728212]},
{"learn":[0.6577760631],"iteration":646,"passed_time":21.18552999,"remaining_time":44.30297075,"test":[0.6704758731]},
{"learn":[0.6577483474],"iteration":647,"passed_time":21.21048648,"remaining_time":44.25397797,"test":[0.6704833026]},
{"learn":[0.6577249642],"iteration":648,"passed_time":21.23686209,"remaining_time":44.20801337,"test":[0.6704767664]},
{"learn":[0.6576974966],"iteration":649,"passed_time":21.26287585,"remaining_time":44.16135753,"test":[0.6704702727]},
{"learn":[0.657675114],"iteration":650,"passed_time":21.28806218,"remaining_time":44.11305051,"test":[0.6704671372]},
{"learn":[0.6576447891],"iteration":651,"passed_time":21.31506267,"remaining_time":44.06856515,"test":[0.6704699936]},
{"learn":[0.6576102356],"iteration":652,"passed_time":21.3435081,"remaining_time":44.02711394,"test":[0.6704587989]},
{"learn":[0.6575793887],"iteration":653,"passed_time":21.37776713,"remaining_time":43.99766753,"test":[0.6704637668]},
{"learn":[0.6575543309],"iteration":654,"passed_time":21.40301154,"remaining_time":43.94969545,"test":[0.6704653717]},
{"learn":[0.6575340787],"iteration":655,"passed_time":21.44023109,"remaining_time":43.92632711,"test":[0.6704598273]},
{"learn":[0.6575061464],"iteration":656,"passed_time":21.4778965,"remaining_time":43.903828,"test":[0.6704522865]},
{"learn":[0.657476113],"iteration":657,"passed_time":21.50245582,"remaining_time":43.85455275,"test":[0.6704558586]},
{"learn":[0.6574447014],"iteration":658,"passed_time":21.53379663,"remaining_time":43.81915217,"test":[0.6704466331]},
{"learn":[0.6574247361],"iteration":659,"passed_time":21.55955041,"remaining_time":43.77242053,"test":[0.6704405886]},
{"learn":[0.6574034983],"iteration":660,"passed_time":21.58626671,"remaining_time":43.72770215,"test":[0.6704463767]},
{"learn":[0.6573783832],"iteration":661,"passed_time":21.61183918,"remaining_time":43.68072633,"test":[0.6704475216]},
{"learn":[0.657357694],"iteration":662,"passed_time":21.6373217,"remaining_time":43.63363366,"test":[0.6704572386]},
{"learn":[0.6573411592],"iteration":663,"passed_time":21.66283476,"remaining_time":43.58666753,"test":[0.6704658153]},
{"learn":[0.6573118559],"iteration":664,"passed_time":21.68841321,"remaining_time":43.5398972,"test":[0.6704600945]},
{"learn":[0.6572819076],"iteration":665,"passed_time":21.71420973,"remaining_time":43.4936273,"test":[0.6704561998]},
{"learn":[0.6572430097],"iteration":666,"passed_time":21.74213421,"remaining_time":43.45167151,"test":[0.6704535154]},
{"learn":[0.6572160391],"iteration":667,"passed_time":21.77174463,"remaining_time":43.41311953,"test":[0.6704413781]},
{"learn":[0.6571931413],"iteration":668,"passed_time":21.81895309,"remaining_time":43.40960622,"test":[0.6704450013]},
{"learn":[0.6571737099],"iteration":669,"passed_time":21.84627583,"remaining_time":43.36648784,"test":[0.6704422199]},
{"learn":[0.6571532872],"iteration":670,"passed_time":21.88834724,"remaining_time":43.35262814,"test":[0.67044342]},
{"learn":[0.6571208939],"iteration":671,"passed_time":21.93403139,"remaining_time":43.34582395,"test":[0.6704415341]},
{"learn":[0.6570887673],"iteration":672,"passed_time":21.9714274,"remaining_time":43.32256191,"test":[0.6704439539]},
{"learn":[0.6570633692],"iteration":673,"passed_time":22.01942449,"remaining_time":43.32011406,"test":[0.6704498197]},
{"learn":[0.6570454361],"iteration":674,"passed_time":22.05319867,"remaining_time":43.2896122,"test":[0.6704452194]},
{"learn":[0.6570231031],"iteration":675,"passed_time":22.09079747,"remaining_time":43.26659149,"test":[0.6704366524]},
{"learn":[0.6570052089],"iteration":676,"passed_time":22.14192346,"remaining_time":43.26996269,"test":[0.6704427124]},
{"learn":[0.6569855794],"iteration":677,"passed_time":22.17624471,"remaining_time":43.24040635,"test":[0.6704395579]},
{"learn":[0.6569579709],"iteration":678,"passed_time":22.213192,"remaining_time":43.21594497,"test":[0.6704401246]},
{"learn":[0.6569333354],"iteration":679,"passed_time":22.23966403,"remaining_time":43.17111253,"test":[0.6704415621]},
{"learn":[0.6569069617],"iteration":680,"passed_time":22.27051241,"remaining_time":43.13481039,"test":[0.6704341343]},
{"learn":[0.6568931857],"iteration":681,"passed_time":22.29625075,"remaining_time":43.08864881,"test":[0.6704369615]},
{"learn":[0.6568734532],"iteration":682,"passed_time":22.32160622,"remaining_time":43.04180877,"test":[0.6704357425]},
{"learn":[0.6568435196],"iteration":683,"passed_time":22.35059872,"remaining_time":43.00202911,"test":[0.6704294622]},
{"learn":[0.6568108038],"iteration":684,"passed_time":22.37956576,"remaining_time":42.96223208,"test":[0.6704289794]},
{"learn":[0.6567811374],"iteration":685,"passed_time":22.41993338,"remaining_time":42.94430389,"test":[0.6704272409]},
{"learn":[0.6567467284],"iteration":686,"passed_time":22.45285267,"remaining_time":42.91207504,"test":[0.6704101162]},
{"learn":[0.6567172734],"iteration":687,"passed_time":22.4848431,"remaining_time":42.8780729,"test":[0.6704069439]},
{"learn":[0.6566967606],"iteration":688,"passed_time":22.51193834,"remaining_time":42.83476221,"test":[0.6704100747]},
{"learn":[0.6566720128],"iteration":689,"passed_time":22.53798671,"remaining_time":42.78951101,"test":[0.6704122261]},
{"learn":[0.6566441608],"iteration":690,"passed_time":22.57108439,"remaining_time":42.75766928,"test":[0.6704137826]},
{"learn":[0.6566172287],"iteration":691,"passed_time":22.59836588,"remaining_time":42.7148303,"test":[0.6704207952]},
{"learn":[0.6565952549],"iteration":692,"passed_time":22.62447507,"remaining_time":42.66982528,"test":[0.6704154834]},
{"learn":[0.6565702687],"iteration":693,"passed_time":22.65349415,"remaining_time":42.63035067,"test":[0.6704253514]},
{"learn":[0.6565392213],"iteration":694,"passed_time":22.68028991,"remaining_time":42.58673141,"test":[0.6704155636]},
{"learn":[0.6565157938],"iteration":695,"passed_time":22.70844406,"remaining_time":42.54570555,"test":[0.6704141298]},
{"learn":[0.6564902789],"iteration":696,"passed_time":22.73944116,"remaining_time":42.51003133,"test":[0.6704207635]},
{"learn":[0.6564644734],"iteration":697,"passed_time":22.7613976,"remaining_time":42.45750671,"test":[0.6704268341]},
{"learn":[0.6564349549],"iteration":698,"passed_time":22.79216825,"remaining_time":42.42147482,"test":[0.6704243126]},
{"learn":[0.6564046572],"iteration":699,"passed_time":22.8167121,"remaining_time":42.37389389,"test":[0.6704235165]},
{"learn":[0.6563744107],"iteration":700,"passed_time":22.84507296,"remaining_time":42.33345189,"test":[0.6704257736]},
{"learn":[0.6563525063],"iteration":701,"passed_time":22.87088832,"remaining_time":42.28833766,"test":[0.6704247758]},
{"learn":[0.6563189867],"iteration":702,"passed_time":22.90238907,"remaining_time":42.25376759,"test":[0.6704331799]},
{"learn":[0.6562939062],"iteration":703,"passed_time":22.94246813,"remaining_time":42.23499815,"test":[0.6704252722]},
{"learn":[0.6562739297],"iteration":704,"passed_time":22.97441688,"remaining_time":42.20123385,"test":[0.6704146644]},
{"learn":[0.656256438],"iteration":705,"passed_time":23.00262167,"remaining_time":42.16061253,"test":[0.6704164122]},
{"learn":[0.6562366475],"iteration":706,"passed_time":23.033437,"remaining_time":42.12480062,"test":[0.6704118954]},
{"learn":[0.6562073096],"iteration":707,"passed_time":23.0545813,"remaining_time":42.07135458,"test":[0.6704043129]},
{"learn":[0.6561864222],"iteration":708,"passed_time":23.08699831,"remaining_time":42.03852584,"test":[0.6703978198]},
{"learn":[0.6561578826],"iteration":709,"passed_time":23.11590694,"remaining_time":41.99932387,"test":[0.6703935976]},
{"learn":[0.6561208567],"iteration":710,"passed_time":23.14362702,"remaining_time":41.9579961,"test":[0.6703839683]},
{"learn":[0.6560924703],"iteration":711,"passed_time":23.16985155,"remaining_time":41.91400112,"test":[0.6703843723]},
{"learn":[0.6560656907],"iteration":712,"passed_time":23.19510285,"remaining_time":41.86829925,"test":[0.6703879502]},
{"learn":[0.6560362588],"iteration":713,"passed_time":23.23034771,"remaining_time":41.84065429,"test":[0.6703895978]},
{"learn":[0.6560124527],"iteration":714,"passed_time":23.25923754,"remaining_time":41.80156678,"test":[0.6703894359]},
{"learn":[0.6559875055],"iteration":715,"passed_time":23.28703452,"remaining_time":41.76054794,"test":[0.6703928777]},
{"learn":[0.6559547281],"iteration":716,"passed_time":23.31161175,"remaining_time":41.71380457,"test":[0.6703933128]},
{"learn":[0.6559230866],"iteration":717,"passed_time":23.34170355,"remaining_time":41.67696929,"test":[0.6703844355]},
{"learn":[0.6558924823],"iteration":718,"passed_time":23.37263658,"remaining_time":41.64165155,"test":[0.6703825151]},
{"learn":[0.6558676469],"iteration":719,"passed_time":23.40571088,"remaining_time":41.61015268,"test":[0.6703983542]},
{"learn":[0.6558459277],"iteration":720,"passed_time":23.4389719,"remaining_time":41.57898067,"test":[0.670399556]},
{"learn":[0.6558149638],"iteration":721,"passed_time":23.48304084,"remaining_time":41.56693379,"test":[0.6703931808]},
{"learn":[0.6557812248],"iteration":722,"passed_time":23.50734531,"remaining_time":41.5198893,"test":[0.6703886918]},
{"learn":[0.6557546502],"iteration":723,"passed_time":23.54055835,"remaining_time":41.48860836,"test":[0.6703847574]},
{"learn":[0.6557274948],"iteration":724,"passed_time":23.56652491,"remaining_time":41.44457829,"test":[0.6703885941]},
{"learn":[0.6557044723],"iteration":725,"passed_time":23.59580183,"remaining_time":41.40640708,"test":[0.6703788615]},
{"learn":[0.6556751811],"iteration":726,"passed_time":23.62334313,"remaining_time":41.36522119,"test":[0.6703799906]},
{"learn":[0.6556539158],"iteration":727,"passed_time":23.64879831,"remaining_time":41.32042782,"test":[0.6703774518]},
{"learn":[0.6556182915],"iteration":728,"passed_time":23.67755213,"remaining_time":41.28143862,"test":[0.6703783496]},
{"learn":[0.6555977079],"iteration":729,"passed_time":23.70012944,"remaining_time":41.23173204,"test":[0.6703648854]},
{"learn":[0.6555667903],"iteration":730,"passed_time":23.72866102,"remaining_time":41.19243615,"test":[0.6703716654]},
{"learn":[0.6555394075],"iteration":731,"passed_time":23.75226732,"remaining_time":41.14463793,"test":[0.6703550938]},
{"learn":[0.6555122742],"iteration":732,"passed_time":23.7844108,"remaining_time":41.11166233,"test":[0.6703467057]},
{"learn":[0.6554814941],"iteration":733,"passed_time":23.80747563,"remaining_time":41.06303017,"test":[0.6703484503]},
{"learn":[0.6554517373],"iteration":734,"passed_time":23.84023587,"remaining_time":41.03115425,"test":[0.6703549183]},
{"learn":[0.655429552],"iteration":735,"passed_time":23.87042124,"remaining_time":40.99485387,"test":[0.6703501504]},
{"learn":[0.655396579],"iteration":736,"passed_time":23.9087808,"remaining_time":40.97257823,"test":[0.6703672622]},
{"learn":[0.6553735864],"iteration":737,"passed_time":23.94161529,"remaining_time":40.94081097,"test":[0.6703560249]},
{"learn":[0.6553472597],"iteration":738,"passed_time":23.97478791,"remaining_time":40.90961779,"test":[0.6703547155]},
{"learn":[0.6553252832],"iteration":739,"passed_time":24.00628859,"remaining_time":40.87557247,"test":[0.6703593236]},
{"learn":[0.6552971659],"iteration":740,"passed_time":24.03623034,"remaining_time":40.83888528,"test":[0.6703606827]},
{"learn":[0.6552763852],"iteration":741,"passed_time":24.06404686,"remaining_time":40.79861313,"test":[0.6703511404]},
{"learn":[0.6552488203],"iteration":742,"passed_time":24.09270947,"remaining_time":40.75980593,"test":[0.6703431646]},
{"learn":[0.65521229],"iteration":743,"passed_time":24.12724624,"remaining_time":40.73094258,"test":[0.6703475116]},
{"learn":[0.6551949744],"iteration":744,"passed_time":24.15397955,"remaining_time":40.68891857,"test":[0.6703483634]},
{"learn":[0.6551673797],"iteration":745,"passed_time":24.17955779,"remaining_time":40.64499392,"test":[0.6703475713]},
{"learn":[0.6551421856],"iteration":746,"passed_time":24.20715317,"remaining_time":40.60450191,"test":[0.670360457]},
{"learn":[0.6551255516],"iteration":747,"passed_time":24.23336836,"remaining_time":40.5617342,"test":[0.6703664352]},
{"learn":[0.6551019608],"iteration":748,"passed_time":24.2614437,"remaining_time":40.52211759,"test":[0.6703617612]},
{"learn":[0.6550758728],"iteration":749,"passed_time":24.29512083,"remaining_time":40.49186805,"test":[0.6703669926]},
{"learn":[0.655051966],"iteration":750,"passed_time":24.31839238,"remaining_time":40.44430371,"test":[0.6703670837]},
{"learn":[0.6550351058],"iteration":751,"passed_time":24.34977118,"remaining_time":40.41025856,"test":[0.6703706628]},
{"learn":[0.6549998756],"iteration":752,"passed_time":24.3762114,"remaining_time":40.36804198,"test":[0.670369618]},
{"learn":[0.6549721212],"iteration":753,"passed_time":24.40831154,"remaining_time":40.3352204,"test":[0.6703692351]},
{"learn":[0.6549401744],"iteration":754,"passed_time":24.44267281,"remaining_time":40.30612934,"test":[0.6703624433]},
{"learn":[0.6549207325],"iteration":755,"passed_time":24.47460721,"remaining_time":40.27303091,"test":[0.6703686285]},
{"learn":[0.6548900891],"iteration":756,"passed_time":24.50826603,"remaining_time":40.24276708,"test":[0.6703598432]},
{"learn":[0.6548682731],"iteration":757,"passed_time":24.54826542,"remaining_time":40.22288345,"test":[0.6703618766]},
{"learn":[0.6548418938],"iteration":758,"passed_time":24.57546587,"remaining_time":40.18201996,"test":[0.6703694148]},
{"learn":[0.6548234717],"iteration":759,"passed_time":24.60502723,"remaining_time":40.14504442,"test":[0.6703683652]},
{"learn":[0.6547996833],"iteration":760,"passed_time":24.63261096,"remaining_time":40.10486856,"test":[0.6703604855]},
{"learn":[0.6547726174],"iteration":761,"passed_time":24.66001655,"remaining_time":40.06443634,"test":[0.6703758987]},
{"learn":[0.6547509314],"iteration":762,"passed_time":24.68929907,"remaining_time":40.02708119,"test":[0.6703773302]},
{"learn":[0.6547168175],"iteration":763,"passed_time":24.71425118,"remaining_time":39.98274144,"test":[0.6703641028]},
{"learn":[0.6546907846],"iteration":764,"passed_time":24.74589169,"remaining_time":39.94924999,"test":[0.6703649602]},
{"learn":[0.6546671611],"iteration":765,"passed_time":24.76625006,"remaining_time":39.89758822,"test":[0.6703567811]},
{"learn":[0.6546475893],"iteration":766,"passed_time":24.79734832,"remaining_time":39.86327312,"test":[0.6703544688]},
{"learn":[0.6546206223],"iteration":767,"passed_time":24.82531049,"remaining_time":39.82393558,"test":[0.6703611821]},
{"learn":[0.6545874193],"iteration":768,"passed_time":24.85435247,"remaining_time":39.78635616,"test":[0.6703527821]},
{"learn":[0.6545620629],"iteration":769,"passed_time":24.88095966,"remaining_time":39.74490958,"test":[0.6703523616]},
{"learn":[0.6545346297],"iteration":770,"passed_time":24.90935211,"remaining_time":39.70634726,"test":[0.6703616298]},
{"learn":[0.6545172316],"iteration":771,"passed_time":24.94098876,"remaining_time":39.67297175,"test":[0.6703603551]},
{"learn":[0.6544943049],"iteration":772,"passed_time":24.97035098,"remaining_time":39.6359905,"test":[0.6703675655]},
{"learn":[0.6544632323],"iteration":773,"passed_time":25.00434422,"remaining_time":39.60636436,"test":[0.6703582411]},
{"learn":[0.6544384097],"iteration":774,"passed_time":25.03067441,"remaining_time":39.56461439,"test":[0.6703581437]},
{"learn":[0.6544084745],"iteration":775,"passed_time":25.05692652,"remaining_time":39.522781,"test":[0.6703551885]},
{"learn":[0.6543765257],"iteration":776,"passed_time":25.08660163,"remaining_time":39.48637554,"test":[0.6703608491]},
{"learn":[0.6543536123],"iteration":777,"passed_time":25.10764591,"remaining_time":39.43643098,"test":[0.6703674554]},
{"learn":[0.6543303593],"iteration":778,"passed_time":25.13940138,"remaining_time":39.40334928,"test":[0.6703679619]},
{"learn":[0.6543005831],"iteration":779,"passed_time":25.15916899,"remaining_time":39.35152074,"test":[0.6703701757]},
{"learn":[0.6542678123],"iteration":780,"passed_time":25.18841105,"remaining_time":39.31456219,"test":[0.6703603462]},
{"learn":[0.6542439303],"iteration":781,"passed_time":25.21444083,"remaining_time":39.27262012,"test":[0.670359801]},
{"learn":[0.6542100401],"iteration":782,"passed_time":25.24017824,"remaining_time":39.23026426,"test":[0.6703523669]},
{"learn":[0.6541836178],"iteration":783,"passed_time":25.2660091,"remaining_time":39.18809574,"test":[0.6703365674]},
{"learn":[0.654158129],"iteration":784,"passed_time":25.28891553,"remaining_time":39.1414425,"test":[0.6703486118]},
{"learn":[0.6541343464],"iteration":785,"passed_time":25.31589904,"remaining_time":39.10114686,"test":[0.6703450011]},
{"learn":[0.6541092921],"iteration":786,"passed_time":25.34123581,"remaining_time":39.05834694,"test":[0.6703473135]},
{"learn":[0.6540812254],"iteration":787,"passed_time":25.36728606,"remaining_time":39.01668871,"test":[0.670350998]},
{"learn":[0.654060259],"iteration":788,"passed_time":25.39177931,"remaining_time":38.97268028,"test":[0.6703417767]},
{"learn":[0.6540467253],"iteration":789,"passed_time":25.41712461,"remaining_time":38.9300263,"test":[0.6703349821]},
{"learn":[0.6540306837],"iteration":790,"passed_time":25.44804125,"remaining_time":38.89593157,"test":[0.6703457717]},
{"learn":[0.6540103667],"iteration":791,"passed_time":25.48249341,"remaining_time":38.86723743,"test":[0.6703506266]},
{"learn":[0.6539821302],"iteration":792,"passed_time":25.51450657,"remaining_time":38.83481643,"test":[0.6703596395]},
{"learn":[0.6539577914],"iteration":793,"passed_time":25.54216564,"remaining_time":38.79578307,"test":[0.6703799895]},
{"learn":[0.653923724],"iteration":794,"passed_time":25.56982738,"remaining_time":38.75678238,"test":[0.6703687687]},
{"learn":[0.6539086888],"iteration":795,"passed_time":25.59539769,"remaining_time":38.71464675,"test":[0.6703780675]},
{"learn":[0.6538798424],"iteration":796,"passed_time":25.61874122,"remaining_time":38.66919157,"test":[0.670374835]},
{"learn":[0.6538566996],"iteration":797,"passed_time":25.64394874,"remaining_time":38.62659947,"test":[0.6703831387]},
{"learn":[0.6538290752],"iteration":798,"passed_time":25.66776244,"remaining_time":38.58195581,"test":[0.670377656]},
{"learn":[0.6538051255],"iteration":799,"passed_time":25.69593415,"remaining_time":38.54390122,"test":[0.6703689741]},
{"learn":[0.6537917354],"iteration":800,"passed_time":25.71651353,"remaining_time":38.49450652,"test":[0.6703709756]},
{"learn":[0.6537684302],"iteration":801,"passed_time":25.74304126,"remaining_time":38.45406912,"test":[0.6703737517]},
{"learn":[0.6537402991],"iteration":802,"passed_time":25.77084871,"remaining_time":38.41557398,"test":[0.6703818964]},
{"learn":[0.6537165427],"iteration":803,"passed_time":25.79028824,"remaining_time":38.36465763,"test":[0.6703812173]},
{"learn":[0.6536853601],"iteration":804,"passed_time":25.82203653,"remaining_time":38.3320915,"test":[0.6703960068]},
{"learn":[0.6536681479],"iteration":805,"passed_time":25.84395064,"remaining_time":38.28495914,"test":[0.6703976729]},
{"learn":[0.6536409101],"iteration":806,"passed_time":25.87390688,"remaining_time":38.24977808,"test":[0.6704024604]},
{"learn":[0.6536120189],"iteration":807,"passed_time":25.89606204,"remaining_time":38.20310143,"test":[0.6704085008]},
{"learn":[0.6535912493],"iteration":808,"passed_time":25.92585483,"remaining_time":38.16772942,"test":[0.6704076633]},
{"learn":[0.6535617421],"iteration":809,"passed_time":25.95539059,"remaining_time":38.13199358,"test":[0.6704111719]},
{"learn":[0.6535315174],"iteration":810,"passed_time":25.98822968,"remaining_time":38.10111601,"test":[0.6704220803]},
{"learn":[0.6534972927],"iteration":811,"passed_time":26.02835773,"remaining_time":38.08089777,"test":[0.6704265011]},
{"learn":[0.6534818476],"iteration":812,"passed_time":26.0558565,"remaining_time":38.04219146,"test":[0.6704251162]},
{"learn":[0.6534498323],"iteration":813,"passed_time":26.08151817,"remaining_time":38.00083606,"test":[0.6704375472]},
{"learn":[0.6534305025],"iteration":814,"passed_time":26.10848988,"remaining_time":37.96142393,"test":[0.6704319336]},
{"learn":[0.6534081059],"iteration":815,"passed_time":26.13143346,"remaining_time":37.91619757,"test":[0.670437614]},
{"learn":[0.6533765804],"iteration":816,"passed_time":26.15923661,"remaining_time":37.87806231,"test":[0.6704554331]},
{"learn":[0.6533441549],"iteration":817,"passed_time":26.18805523,"remaining_time":37.84141966,"test":[0.6704603317]},
{"learn":[0.6533053405],"iteration":818,"passed_time":26.2140726,"remaining_time":37.8007567,"test":[0.6704548042]},
{"learn":[0.6532838469],"iteration":819,"passed_time":26.24289367,"remaining_time":37.76416405,"test":[0.6704502654]},
{"learn":[0.6532604302],"iteration":820,"passed_time":26.27260776,"remaining_time":37.72887277,"test":[0.6704512072]},
{"learn":[0.6532364412],"iteration":821,"passed_time":26.29880394,"remaining_time":37.68855358,"test":[0.6704433481]},
{"learn":[0.6532100089],"iteration":822,"passed_time":26.32785215,"remaining_time":37.65234749,"test":[0.6704095112]},
{"learn":[0.6531782515],"iteration":823,"passed_time":26.35925682,"remaining_time":37.61952188,"test":[0.6704086019]},
{"learn":[0.6531449701],"iteration":824,"passed_time":26.38596096,"remaining_time":37.580005,"test":[0.6703987131]},
{"learn":[0.653115452],"iteration":825,"passed_time":26.40854839,"remaining_time":37.53466805,"test":[0.6704019708]},
{"learn":[0.6530787602],"iteration":826,"passed_time":26.44419918,"remaining_time":37.50791492,"test":[0.6704046556]},
{"learn":[0.653052397],"iteration":827,"passed_time":26.47784276,"remaining_time":37.47829917,"test":[0.6704091961]},
{"learn":[0.6530313579],"iteration":828,"passed_time":26.51701028,"remaining_time":37.45647652,"test":[0.6704103204]},
{"learn":[0.6530010363],"iteration":829,"passed_time":26.53963123,"remaining_time":37.41128739,"test":[0.6704074257]},
{"learn":[0.6529752146],"iteration":830,"passed_time":26.57362226,"remaining_time":37.38214732,"test":[0.6704115335]},
{"learn":[0.652954801],"iteration":831,"passed_time":26.59767057,"remaining_time":37.33903754,"test":[0.6704041275]},
{"learn":[0.6529330351],"iteration":832,"passed_time":26.62378941,"remaining_time":37.29887425,"test":[0.6704004556]},
{"learn":[0.6528993709],"iteration":833,"passed_time":26.65024746,"remaining_time":37.25921887,"test":[0.6704037097]},
{"learn":[0.6528665883],"iteration":834,"passed_time":26.67774911,"remaining_time":37.22105115,"test":[0.6704035477]},
{"learn":[0.6528413041],"iteration":835,"passed_time":26.70473813,"remaining_time":37.1821952,"test":[0.6704025281]},
{"learn":[0.6528217161],"iteration":836,"passed_time":26.72833235,"remaining_time":37.13865056,"test":[0.6704024549]},
{"learn":[0.6527978782],"iteration":837,"passed_time":26.76384162,"remaining_time":37.11167537,"test":[0.670405721]},
{"learn":[0.6527789461],"iteration":838,"passed_time":26.79137369,"remaining_time":37.07364106,"test":[0.6703983189]},
{"learn":[0.6527432001],"iteration":839,"passed_time":26.82295602,"remaining_time":37.04122498,"test":[0.6704035256]},
{"learn":[0.6527139767],"iteration":840,"passed_time":26.87217031,"remaining_time":37.03310985,"test":[0.6704047613]},
{"learn":[0.6526857244],"iteration":841,"passed_time":26.92488006,"remaining_time":37.0297044,"test":[0.6704139617]},
{"learn":[0.652657086],"iteration":842,"passed_time":26.98258041,"remaining_time":37.03303147,"test":[0.6704066193]},
{"learn":[0.6526355016],"iteration":843,"passed_time":27.05424841,"remaining_time":37.05534497,"test":[0.670402892]},
{"learn":[0.6526054936],"iteration":844,"passed_time":27.09765154,"remaining_time":37.03880181,"test":[0.6704081961]},
{"learn":[0.6525793707],"iteration":845,"passed_time":27.12038959,"remaining_time":36.99400661,"test":[0.6704029862]},
{"learn":[0.6525584692],"iteration":846,"passed_time":27.14691224,"remaining_time":36.95441537,"test":[0.6704014281]},
{"learn":[0.6525279747],"iteration":847,"passed_time":27.18096334,"remaining_time":36.92508227,"test":[0.6704036115]},
{"learn":[0.6525038765],"iteration":848,"passed_time":27.20686017,"remaining_time":36.88468322,"test":[0.6704016777]},
{"learn":[0.6524849104],"iteration":849,"passed_time":27.23465701,"remaining_time":36.8468889,"test":[0.6704085392]},
{"learn":[0.6524610603],"iteration":850,"passed_time":27.26094834,"remaining_time":36.80708536,"test":[0.6704042952]},
{"learn":[0.6524357337],"iteration":851,"passed_time":27.28945577,"remaining_time":36.77029957,"test":[0.670394789]},
{"learn":[0.6524082286],"iteration":852,"passed_time":27.31865398,"remaining_time":36.73446203,"test":[0.6703885644]},
{"learn":[0.65238051],"iteration":853,"passed_time":27.34791322,"remaining_time":36.69872195,"test":[0.6703946813]},
{"learn":[0.6523557826],"iteration":854,"passed_time":27.3865535,"remaining_time":36.67555995,"test":[0.6704042137]},
{"learn":[0.6523391233],"iteration":855,"passed_time":27.41370907,"remaining_time":36.63701306,"test":[0.6704077517]},
{"learn":[0.652325347],"iteration":856,"passed_time":27.43905921,"remaining_time":36.5960848,"test":[0.6704118698]},
{"learn":[0.6522924958],"iteration":857,"passed_time":27.47159295,"remaining_time":36.56475425,"test":[0.6704114259]},
{"learn":[0.6522623584],"iteration":858,"passed_time":27.50124299,"remaining_time":36.52959052,"test":[0.6704157567]},
{"learn":[0.6522343891],"iteration":859,"passed_time":27.53509105,"remaining_time":36.50000442,"test":[0.6703837005]},
{"learn":[0.6522094424],"iteration":860,"passed_time":27.57211091,"remaining_time":36.47460432,"test":[0.6703829482]},
{"learn":[0.6521841478],"iteration":861,"passed_time":27.59555719,"remaining_time":36.43125764,"test":[0.6703818491]},
{"learn":[0.6521657946],"iteration":862,"passed_time":27.6272049,"remaining_time":36.39876242,"test":[0.6703826129]},
{"learn":[0.6521304278],"iteration":863,"passed_time":27.65462267,"remaining_time":36.36070759,"test":[0.6703834487]},
{"learn":[0.6521045712],"iteration":864,"passed_time":27.68321566,"remaining_time":36.3242194,"test":[0.6703868275]},
{"learn":[0.6520753696],"iteration":865,"passed_time":27.71151671,"remaining_time":36.28736714,"test":[0.6703853357]},
{"learn":[0.6520519528],"iteration":866,"passed_time":27.73884016,"remaining_time":36.2492571,"test":[0.670450644]},
{"learn":[0.6520216555],"iteration":867,"passed_time":27.76583897,"remaining_time":36.21074851,"test":[0.6704556991]},
{"learn":[0.6519926935],"iteration":868,"passed_time":27.79498714,"remaining_time":36.17506382,"test":[0.6704535742]},
{"learn":[0.6519734186],"iteration":869,"passed_time":27.82082723,"remaining_time":36.13509744,"test":[0.6704495915]}
]}
Binary file not shown.
+871
View File
@@ -0,0 +1,871 @@
iter Logloss
0 0.692389481
1 0.6916338586
2 0.6910159214
3 0.6903417151
4 0.6896961461
5 0.6890979366
6 0.6884946167
7 0.6879503686
8 0.6874528094
9 0.6869036785
10 0.6863761921
11 0.6859038678
12 0.685410175
13 0.6849483392
14 0.6845417792
15 0.6841038875
16 0.6836957422
17 0.6832947461
18 0.6829014105
19 0.6825264546
20 0.6822106577
21 0.6818649349
22 0.6815467855
23 0.6812293319
24 0.6808837443
25 0.6805816494
26 0.6803209634
27 0.6800350862
28 0.6797703947
29 0.6794926675
30 0.6792251865
31 0.6789670166
32 0.678722402
33 0.678476935
34 0.6782297335
35 0.6780226701
36 0.6778291026
37 0.6776045324
38 0.6773969079
39 0.6771819602
40 0.6769816736
41 0.6767984027
42 0.6766201184
43 0.6764394377
44 0.6762698797
45 0.6760974263
46 0.6759245179
47 0.6757673909
48 0.6756172628
49 0.675474531
50 0.6753286933
51 0.6751900513
52 0.6750574835
53 0.6749329567
54 0.6748033265
55 0.6746797823
56 0.674535525
57 0.6744256514
58 0.674310819
59 0.6741967947
60 0.6740879654
61 0.6739772476
62 0.67388281
63 0.6737789726
64 0.6736812332
65 0.6735930009
66 0.6734947116
67 0.6733961481
68 0.6732990195
69 0.6732133575
70 0.673111539
71 0.6730080451
72 0.6729157861
73 0.6728347949
74 0.6727640693
75 0.6726808811
76 0.6726029645
77 0.6725356026
78 0.6724606887
79 0.6723849561
80 0.6723050519
81 0.6722508802
82 0.6721773904
83 0.6721007598
84 0.6720353564
85 0.6719790902
86 0.6719140024
87 0.6718573633
88 0.671795602
89 0.6717369134
90 0.6716711079
91 0.6716070843
92 0.6715517232
93 0.6714957378
94 0.6714364567
95 0.6713881758
96 0.6713336502
97 0.6712700267
98 0.6712154424
99 0.6711600413
100 0.6711060533
101 0.6710494943
102 0.6709936897
103 0.6709472183
104 0.6708914508
105 0.6708388195
106 0.6707885854
107 0.6707454167
108 0.6706973013
109 0.6706577031
110 0.67061108
111 0.6705625485
112 0.6705146484
113 0.6704704423
114 0.6704155922
115 0.6703687117
116 0.6703324232
117 0.6702884624
118 0.670253478
119 0.6702140804
120 0.6701682529
121 0.6701320588
122 0.6700939824
123 0.6700655902
124 0.6700190743
125 0.6699792296
126 0.6699379404
127 0.669895454
128 0.6698563938
129 0.6698215571
130 0.6697857067
131 0.6697449303
132 0.6697052425
133 0.6696695553
134 0.6696269265
135 0.6695969271
136 0.6695489786
137 0.6695173859
138 0.6694811164
139 0.6694477439
140 0.6694082161
141 0.6693679185
142 0.6693341916
143 0.6692933159
144 0.6692619696
145 0.6692229289
146 0.6691840164
147 0.6691581406
148 0.6691177196
149 0.6690851126
150 0.6690518144
151 0.6690149711
152 0.668993877
153 0.6689596579
154 0.6689372651
155 0.6689003045
156 0.6688680182
157 0.6688348164
158 0.6687947046
159 0.6687605251
160 0.668726253
161 0.6686862718
162 0.668663478
163 0.6686399521
164 0.6686058279
165 0.6685761282
166 0.6685469327
167 0.6685157003
168 0.6684805143
169 0.6684485765
170 0.6684144429
171 0.6683849752
172 0.6683568537
173 0.6683266628
174 0.6682937842
175 0.6682657097
176 0.6682301443
177 0.6681995916
178 0.6681658267
179 0.6681422687
180 0.6681216601
181 0.6680899019
182 0.6680676394
183 0.6680413672
184 0.6680088406
185 0.6679873982
186 0.6679663544
187 0.6679417375
188 0.6679100197
189 0.667881208
190 0.6678475427
191 0.6678310341
192 0.6678060257
193 0.6677789336
194 0.6677478773
195 0.6677212408
196 0.667704316
197 0.6676819639
198 0.6676554448
199 0.6676318346
200 0.6676074705
201 0.6675849784
202 0.6675631744
203 0.6675397619
204 0.6675169086
205 0.6674864762
206 0.6674670714
207 0.6674375599
208 0.6674148457
209 0.6673974446
210 0.6673812139
211 0.6673515687
212 0.6673197956
213 0.6672900754
214 0.6672550009
215 0.6672271563
216 0.667204521
217 0.667181968
218 0.6671640023
219 0.66714351
220 0.6671167156
221 0.6670915937
222 0.6670595279
223 0.667033994
224 0.6670008246
225 0.6669858319
226 0.6669553964
227 0.6669274683
228 0.666896348
229 0.6668698686
230 0.6668513411
231 0.6668309985
232 0.6668058585
233 0.6667845908
234 0.6667582863
235 0.6667332943
236 0.6667070085
237 0.6666907315
238 0.6666633028
239 0.6666406707
240 0.6666134624
241 0.6665850522
242 0.6665631193
243 0.6665412643
244 0.6665168385
245 0.6664904845
246 0.6664678274
247 0.6664539777
248 0.6664334121
249 0.6664121724
250 0.666392034
251 0.666366899
252 0.6663414098
253 0.6663157816
254 0.6662989799
255 0.6662696102
256 0.6662479711
257 0.6662231874
258 0.6661947927
259 0.6661669951
260 0.6661426137
261 0.6661216749
262 0.6660983123
263 0.6660803402
264 0.6660617842
265 0.6660443878
266 0.6660176079
267 0.6659967546
268 0.6659751467
269 0.6659539329
270 0.6659263951
271 0.6659038921
272 0.6658767418
273 0.6658510507
274 0.6658210119
275 0.6657963011
276 0.6657748552
277 0.6657490013
278 0.665732402
279 0.6657118786
280 0.665684467
281 0.6656584634
282 0.6656309991
283 0.6656073482
284 0.6655890957
285 0.6655665563
286 0.6655452454
287 0.6655255286
288 0.6655053548
289 0.6654893396
290 0.6654648912
291 0.6654442759
292 0.6654173127
293 0.6653914518
294 0.6653648946
295 0.665344141
296 0.6653140817
297 0.665295365
298 0.6652787488
299 0.6652502991
300 0.665231168
301 0.6652136682
302 0.6651903001
303 0.6651697153
304 0.6651525958
305 0.6651322685
306 0.6651113828
307 0.6650886807
308 0.6650622251
309 0.6650429987
310 0.665015513
311 0.6650019022
312 0.664979951
313 0.6649549638
314 0.6649340455
315 0.6649162445
316 0.6649048119
317 0.6648796463
318 0.6648605481
319 0.6648429084
320 0.6648238121
321 0.6647969527
322 0.6647854723
323 0.6647589304
324 0.6647429024
325 0.6647237508
326 0.6647059396
327 0.664686288
328 0.6646532527
329 0.6646306438
330 0.6646098516
331 0.6645858284
332 0.6645707188
333 0.6645485788
334 0.6645305696
335 0.6645108881
336 0.6644923286
337 0.6644805222
338 0.6644572776
339 0.6644320741
340 0.6644115048
341 0.6643949013
342 0.6643619789
343 0.6643389502
344 0.6643088915
345 0.664286972
346 0.664274149
347 0.6642536926
348 0.6642357634
349 0.664207914
350 0.6641853097
351 0.6641654917
352 0.664143804
353 0.6641290647
354 0.6641117244
355 0.6640880219
356 0.6640669415
357 0.6640462999
358 0.664030296
359 0.6640028542
360 0.6639813347
361 0.6639597941
362 0.6639429832
363 0.6639222708
364 0.6639065546
365 0.6638823236
366 0.6638648195
367 0.6638436235
368 0.6638208732
369 0.6637956357
370 0.6637718453
371 0.663756918
372 0.6637353525
373 0.6637143112
374 0.6636956547
375 0.663680995
376 0.66366728
377 0.6636487567
378 0.6636266904
379 0.6636116064
380 0.6635902746
381 0.6635654896
382 0.6635393029
383 0.6635171734
384 0.663500789
385 0.663477743
386 0.6634584806
387 0.6634337499
388 0.6634135584
389 0.6633868455
390 0.6633755323
391 0.663356103
392 0.6633337631
393 0.663319422
394 0.6632911566
395 0.6632687875
396 0.6632431997
397 0.6632189331
398 0.663201035
399 0.6631898553
400 0.6631712482
401 0.663143025
402 0.663121538
403 0.6631087792
404 0.6630859067
405 0.663066483
406 0.6630443652
407 0.6630250376
408 0.6630007822
409 0.6629768728
410 0.6629528093
411 0.6629260936
412 0.6629102182
413 0.6628863488
414 0.6628648972
415 0.6628454339
416 0.6628200274
417 0.6627942591
418 0.6627744647
419 0.662765485
420 0.6627503257
421 0.6627323029
422 0.6627111509
423 0.6626785863
424 0.6626576561
425 0.6626363113
426 0.6626181065
427 0.66259794
428 0.6625765658
429 0.6625526572
430 0.66253135
431 0.6625035695
432 0.662480212
433 0.6624611632
434 0.6624332625
435 0.6624120584
436 0.6623941719
437 0.6623766304
438 0.6623623329
439 0.6623442925
440 0.6623212715
441 0.6623025941
442 0.6622749791
443 0.6622534499
444 0.6622305473
445 0.6622059333
446 0.6621871707
447 0.6621638454
448 0.6621511296
449 0.6621349978
450 0.6621120424
451 0.6620958271
452 0.6620793528
453 0.6620572713
454 0.6620395025
455 0.6620188044
456 0.6620017347
457 0.6619811454
458 0.6619695569
459 0.661952377
460 0.6619237442
461 0.6619089407
462 0.6618886168
463 0.6618831383
464 0.6618690774
465 0.661845878
466 0.6618290213
467 0.6618050064
468 0.6617832833
469 0.6617652311
470 0.6617443144
471 0.6617202619
472 0.6617005831
473 0.6616824419
474 0.6616538226
475 0.6616314155
476 0.6616127861
477 0.6616029072
478 0.6615843751
479 0.661563216
480 0.6615432257
481 0.6615263324
482 0.6615033259
483 0.661484293
484 0.6614678231
485 0.6614463024
486 0.6614155436
487 0.6613958945
488 0.661380611
489 0.6613677802
490 0.6613530086
491 0.6613248211
492 0.6613059359
493 0.6612729965
494 0.6612624948
495 0.6612401679
496 0.6612191637
497 0.6611912219
498 0.6611773017
499 0.6611638216
500 0.6611450533
501 0.6611179111
502 0.6610959069
503 0.6610728788
504 0.6610436668
505 0.6610188976
506 0.6610030555
507 0.6609831174
508 0.6609586562
509 0.660935882
510 0.6609202024
511 0.6609011137
512 0.6608726737
513 0.6608608849
514 0.6608387256
515 0.6608136063
516 0.6607946343
517 0.6607703935
518 0.6607509625
519 0.6607238109
520 0.6606999858
521 0.6606813873
522 0.6606610372
523 0.660638456
524 0.6606156483
525 0.6605968623
526 0.6605735776
527 0.6605517294
528 0.6605309239
529 0.6605086434
530 0.6604803349
531 0.6604566326
532 0.6604430839
533 0.6604273738
534 0.6604048016
535 0.6603845173
536 0.6603669212
537 0.6603488983
538 0.6603176881
539 0.6602953862
540 0.6602672025
541 0.6602568636
542 0.660235705
543 0.6602152295
544 0.6601897709
545 0.6601683731
546 0.6601472267
547 0.6601262337
548 0.6601119991
549 0.6600869973
550 0.6600667497
551 0.6600397508
552 0.660016863
553 0.6599933158
554 0.6599632649
555 0.6599446007
556 0.6599138126
557 0.6598965504
558 0.6598785723
559 0.659860838
560 0.6598408724
561 0.6598244857
562 0.6598082469
563 0.6597851673
564 0.6597683521
565 0.6597479006
566 0.6597310938
567 0.6597096581
568 0.6596862311
569 0.6596574779
570 0.6596385418
571 0.6596189903
572 0.65959275
573 0.6595730662
574 0.6595566809
575 0.6595365076
576 0.6595163446
577 0.6594816637
578 0.6594570142
579 0.6594353055
580 0.6594162362
581 0.659395036
582 0.6593798831
583 0.6593556719
584 0.6593292627
585 0.6592976737
586 0.6592754841
587 0.6592510441
588 0.6592290326
589 0.6592097404
590 0.6591876204
591 0.6591705995
592 0.6591456195
593 0.6591107122
594 0.6590819533
595 0.6590551327
596 0.6590373916
597 0.6590177149
598 0.6589946095
599 0.6589697628
600 0.6589442269
601 0.6589182437
602 0.6588837179
603 0.6588674101
604 0.6588406916
605 0.6588149945
606 0.6587866031
607 0.6587636648
608 0.6587502469
609 0.6587292784
610 0.6587104112
611 0.6586953782
612 0.6586641191
613 0.6586450136
614 0.6586136263
615 0.6585862768
616 0.6585585235
617 0.6585371631
618 0.6585092632
619 0.6584914317
620 0.6584662432
621 0.6584454668
622 0.6584249408
623 0.6583931228
624 0.6583660767
625 0.658354264
626 0.6583253625
627 0.6582968632
628 0.6582687399
629 0.658242535
630 0.6582199874
631 0.6581918101
632 0.6581735218
633 0.6581445869
634 0.6581202427
635 0.6580977862
636 0.6580724179
637 0.6580426322
638 0.6580111256
639 0.6579834747
640 0.6579541367
641 0.6579254503
642 0.657898555
643 0.6578676875
644 0.6578324163
645 0.6578062223
646 0.6577760631
647 0.6577483474
648 0.6577249642
649 0.6576974966
650 0.657675114
651 0.6576447891
652 0.6576102356
653 0.6575793887
654 0.6575543309
655 0.6575340787
656 0.6575061464
657 0.657476113
658 0.6574447014
659 0.6574247361
660 0.6574034983
661 0.6573783832
662 0.657357694
663 0.6573411592
664 0.6573118559
665 0.6572819076
666 0.6572430097
667 0.6572160391
668 0.6571931413
669 0.6571737099
670 0.6571532872
671 0.6571208939
672 0.6570887673
673 0.6570633692
674 0.6570454361
675 0.6570231031
676 0.6570052089
677 0.6569855794
678 0.6569579709
679 0.6569333354
680 0.6569069617
681 0.6568931857
682 0.6568734532
683 0.6568435196
684 0.6568108038
685 0.6567811374
686 0.6567467284
687 0.6567172734
688 0.6566967606
689 0.6566720128
690 0.6566441608
691 0.6566172287
692 0.6565952549
693 0.6565702687
694 0.6565392213
695 0.6565157938
696 0.6564902789
697 0.6564644734
698 0.6564349549
699 0.6564046572
700 0.6563744107
701 0.6563525063
702 0.6563189867
703 0.6562939062
704 0.6562739297
705 0.656256438
706 0.6562366475
707 0.6562073096
708 0.6561864222
709 0.6561578826
710 0.6561208567
711 0.6560924703
712 0.6560656907
713 0.6560362588
714 0.6560124527
715 0.6559875055
716 0.6559547281
717 0.6559230866
718 0.6558924823
719 0.6558676469
720 0.6558459277
721 0.6558149638
722 0.6557812248
723 0.6557546502
724 0.6557274948
725 0.6557044723
726 0.6556751811
727 0.6556539158
728 0.6556182915
729 0.6555977079
730 0.6555667903
731 0.6555394075
732 0.6555122742
733 0.6554814941
734 0.6554517373
735 0.655429552
736 0.655396579
737 0.6553735864
738 0.6553472597
739 0.6553252832
740 0.6552971659
741 0.6552763852
742 0.6552488203
743 0.65521229
744 0.6551949744
745 0.6551673797
746 0.6551421856
747 0.6551255516
748 0.6551019608
749 0.6550758728
750 0.655051966
751 0.6550351058
752 0.6549998756
753 0.6549721212
754 0.6549401744
755 0.6549207325
756 0.6548900891
757 0.6548682731
758 0.6548418938
759 0.6548234717
760 0.6547996833
761 0.6547726174
762 0.6547509314
763 0.6547168175
764 0.6546907846
765 0.6546671611
766 0.6546475893
767 0.6546206223
768 0.6545874193
769 0.6545620629
770 0.6545346297
771 0.6545172316
772 0.6544943049
773 0.6544632323
774 0.6544384097
775 0.6544084745
776 0.6543765257
777 0.6543536123
778 0.6543303593
779 0.6543005831
780 0.6542678123
781 0.6542439303
782 0.6542100401
783 0.6541836178
784 0.654158129
785 0.6541343464
786 0.6541092921
787 0.6540812254
788 0.654060259
789 0.6540467253
790 0.6540306837
791 0.6540103667
792 0.6539821302
793 0.6539577914
794 0.653923724
795 0.6539086888
796 0.6538798424
797 0.6538566996
798 0.6538290752
799 0.6538051255
800 0.6537917354
801 0.6537684302
802 0.6537402991
803 0.6537165427
804 0.6536853601
805 0.6536681479
806 0.6536409101
807 0.6536120189
808 0.6535912493
809 0.6535617421
810 0.6535315174
811 0.6534972927
812 0.6534818476
813 0.6534498323
814 0.6534305025
815 0.6534081059
816 0.6533765804
817 0.6533441549
818 0.6533053405
819 0.6532838469
820 0.6532604302
821 0.6532364412
822 0.6532100089
823 0.6531782515
824 0.6531449701
825 0.653115452
826 0.6530787602
827 0.653052397
828 0.6530313579
829 0.6530010363
830 0.6529752146
831 0.652954801
832 0.6529330351
833 0.6528993709
834 0.6528665883
835 0.6528413041
836 0.6528217161
837 0.6527978782
838 0.6527789461
839 0.6527432001
840 0.6527139767
841 0.6526857244
842 0.652657086
843 0.6526355016
844 0.6526054936
845 0.6525793707
846 0.6525584692
847 0.6525279747
848 0.6525038765
849 0.6524849104
850 0.6524610603
851 0.6524357337
852 0.6524082286
853 0.65238051
854 0.6523557826
855 0.6523391233
856 0.652325347
857 0.6522924958
858 0.6522623584
859 0.6522343891
860 0.6522094424
861 0.6521841478
862 0.6521657946
863 0.6521304278
864 0.6521045712
865 0.6520753696
866 0.6520519528
867 0.6520216555
868 0.6519926935
869 0.6519734186
1 iter Logloss
2 0 0.692389481
3 1 0.6916338586
4 2 0.6910159214
5 3 0.6903417151
6 4 0.6896961461
7 5 0.6890979366
8 6 0.6884946167
9 7 0.6879503686
10 8 0.6874528094
11 9 0.6869036785
12 10 0.6863761921
13 11 0.6859038678
14 12 0.685410175
15 13 0.6849483392
16 14 0.6845417792
17 15 0.6841038875
18 16 0.6836957422
19 17 0.6832947461
20 18 0.6829014105
21 19 0.6825264546
22 20 0.6822106577
23 21 0.6818649349
24 22 0.6815467855
25 23 0.6812293319
26 24 0.6808837443
27 25 0.6805816494
28 26 0.6803209634
29 27 0.6800350862
30 28 0.6797703947
31 29 0.6794926675
32 30 0.6792251865
33 31 0.6789670166
34 32 0.678722402
35 33 0.678476935
36 34 0.6782297335
37 35 0.6780226701
38 36 0.6778291026
39 37 0.6776045324
40 38 0.6773969079
41 39 0.6771819602
42 40 0.6769816736
43 41 0.6767984027
44 42 0.6766201184
45 43 0.6764394377
46 44 0.6762698797
47 45 0.6760974263
48 46 0.6759245179
49 47 0.6757673909
50 48 0.6756172628
51 49 0.675474531
52 50 0.6753286933
53 51 0.6751900513
54 52 0.6750574835
55 53 0.6749329567
56 54 0.6748033265
57 55 0.6746797823
58 56 0.674535525
59 57 0.6744256514
60 58 0.674310819
61 59 0.6741967947
62 60 0.6740879654
63 61 0.6739772476
64 62 0.67388281
65 63 0.6737789726
66 64 0.6736812332
67 65 0.6735930009
68 66 0.6734947116
69 67 0.6733961481
70 68 0.6732990195
71 69 0.6732133575
72 70 0.673111539
73 71 0.6730080451
74 72 0.6729157861
75 73 0.6728347949
76 74 0.6727640693
77 75 0.6726808811
78 76 0.6726029645
79 77 0.6725356026
80 78 0.6724606887
81 79 0.6723849561
82 80 0.6723050519
83 81 0.6722508802
84 82 0.6721773904
85 83 0.6721007598
86 84 0.6720353564
87 85 0.6719790902
88 86 0.6719140024
89 87 0.6718573633
90 88 0.671795602
91 89 0.6717369134
92 90 0.6716711079
93 91 0.6716070843
94 92 0.6715517232
95 93 0.6714957378
96 94 0.6714364567
97 95 0.6713881758
98 96 0.6713336502
99 97 0.6712700267
100 98 0.6712154424
101 99 0.6711600413
102 100 0.6711060533
103 101 0.6710494943
104 102 0.6709936897
105 103 0.6709472183
106 104 0.6708914508
107 105 0.6708388195
108 106 0.6707885854
109 107 0.6707454167
110 108 0.6706973013
111 109 0.6706577031
112 110 0.67061108
113 111 0.6705625485
114 112 0.6705146484
115 113 0.6704704423
116 114 0.6704155922
117 115 0.6703687117
118 116 0.6703324232
119 117 0.6702884624
120 118 0.670253478
121 119 0.6702140804
122 120 0.6701682529
123 121 0.6701320588
124 122 0.6700939824
125 123 0.6700655902
126 124 0.6700190743
127 125 0.6699792296
128 126 0.6699379404
129 127 0.669895454
130 128 0.6698563938
131 129 0.6698215571
132 130 0.6697857067
133 131 0.6697449303
134 132 0.6697052425
135 133 0.6696695553
136 134 0.6696269265
137 135 0.6695969271
138 136 0.6695489786
139 137 0.6695173859
140 138 0.6694811164
141 139 0.6694477439
142 140 0.6694082161
143 141 0.6693679185
144 142 0.6693341916
145 143 0.6692933159
146 144 0.6692619696
147 145 0.6692229289
148 146 0.6691840164
149 147 0.6691581406
150 148 0.6691177196
151 149 0.6690851126
152 150 0.6690518144
153 151 0.6690149711
154 152 0.668993877
155 153 0.6689596579
156 154 0.6689372651
157 155 0.6689003045
158 156 0.6688680182
159 157 0.6688348164
160 158 0.6687947046
161 159 0.6687605251
162 160 0.668726253
163 161 0.6686862718
164 162 0.668663478
165 163 0.6686399521
166 164 0.6686058279
167 165 0.6685761282
168 166 0.6685469327
169 167 0.6685157003
170 168 0.6684805143
171 169 0.6684485765
172 170 0.6684144429
173 171 0.6683849752
174 172 0.6683568537
175 173 0.6683266628
176 174 0.6682937842
177 175 0.6682657097
178 176 0.6682301443
179 177 0.6681995916
180 178 0.6681658267
181 179 0.6681422687
182 180 0.6681216601
183 181 0.6680899019
184 182 0.6680676394
185 183 0.6680413672
186 184 0.6680088406
187 185 0.6679873982
188 186 0.6679663544
189 187 0.6679417375
190 188 0.6679100197
191 189 0.667881208
192 190 0.6678475427
193 191 0.6678310341
194 192 0.6678060257
195 193 0.6677789336
196 194 0.6677478773
197 195 0.6677212408
198 196 0.667704316
199 197 0.6676819639
200 198 0.6676554448
201 199 0.6676318346
202 200 0.6676074705
203 201 0.6675849784
204 202 0.6675631744
205 203 0.6675397619
206 204 0.6675169086
207 205 0.6674864762
208 206 0.6674670714
209 207 0.6674375599
210 208 0.6674148457
211 209 0.6673974446
212 210 0.6673812139
213 211 0.6673515687
214 212 0.6673197956
215 213 0.6672900754
216 214 0.6672550009
217 215 0.6672271563
218 216 0.667204521
219 217 0.667181968
220 218 0.6671640023
221 219 0.66714351
222 220 0.6671167156
223 221 0.6670915937
224 222 0.6670595279
225 223 0.667033994
226 224 0.6670008246
227 225 0.6669858319
228 226 0.6669553964
229 227 0.6669274683
230 228 0.666896348
231 229 0.6668698686
232 230 0.6668513411
233 231 0.6668309985
234 232 0.6668058585
235 233 0.6667845908
236 234 0.6667582863
237 235 0.6667332943
238 236 0.6667070085
239 237 0.6666907315
240 238 0.6666633028
241 239 0.6666406707
242 240 0.6666134624
243 241 0.6665850522
244 242 0.6665631193
245 243 0.6665412643
246 244 0.6665168385
247 245 0.6664904845
248 246 0.6664678274
249 247 0.6664539777
250 248 0.6664334121
251 249 0.6664121724
252 250 0.666392034
253 251 0.666366899
254 252 0.6663414098
255 253 0.6663157816
256 254 0.6662989799
257 255 0.6662696102
258 256 0.6662479711
259 257 0.6662231874
260 258 0.6661947927
261 259 0.6661669951
262 260 0.6661426137
263 261 0.6661216749
264 262 0.6660983123
265 263 0.6660803402
266 264 0.6660617842
267 265 0.6660443878
268 266 0.6660176079
269 267 0.6659967546
270 268 0.6659751467
271 269 0.6659539329
272 270 0.6659263951
273 271 0.6659038921
274 272 0.6658767418
275 273 0.6658510507
276 274 0.6658210119
277 275 0.6657963011
278 276 0.6657748552
279 277 0.6657490013
280 278 0.665732402
281 279 0.6657118786
282 280 0.665684467
283 281 0.6656584634
284 282 0.6656309991
285 283 0.6656073482
286 284 0.6655890957
287 285 0.6655665563
288 286 0.6655452454
289 287 0.6655255286
290 288 0.6655053548
291 289 0.6654893396
292 290 0.6654648912
293 291 0.6654442759
294 292 0.6654173127
295 293 0.6653914518
296 294 0.6653648946
297 295 0.665344141
298 296 0.6653140817
299 297 0.665295365
300 298 0.6652787488
301 299 0.6652502991
302 300 0.665231168
303 301 0.6652136682
304 302 0.6651903001
305 303 0.6651697153
306 304 0.6651525958
307 305 0.6651322685
308 306 0.6651113828
309 307 0.6650886807
310 308 0.6650622251
311 309 0.6650429987
312 310 0.665015513
313 311 0.6650019022
314 312 0.664979951
315 313 0.6649549638
316 314 0.6649340455
317 315 0.6649162445
318 316 0.6649048119
319 317 0.6648796463
320 318 0.6648605481
321 319 0.6648429084
322 320 0.6648238121
323 321 0.6647969527
324 322 0.6647854723
325 323 0.6647589304
326 324 0.6647429024
327 325 0.6647237508
328 326 0.6647059396
329 327 0.664686288
330 328 0.6646532527
331 329 0.6646306438
332 330 0.6646098516
333 331 0.6645858284
334 332 0.6645707188
335 333 0.6645485788
336 334 0.6645305696
337 335 0.6645108881
338 336 0.6644923286
339 337 0.6644805222
340 338 0.6644572776
341 339 0.6644320741
342 340 0.6644115048
343 341 0.6643949013
344 342 0.6643619789
345 343 0.6643389502
346 344 0.6643088915
347 345 0.664286972
348 346 0.664274149
349 347 0.6642536926
350 348 0.6642357634
351 349 0.664207914
352 350 0.6641853097
353 351 0.6641654917
354 352 0.664143804
355 353 0.6641290647
356 354 0.6641117244
357 355 0.6640880219
358 356 0.6640669415
359 357 0.6640462999
360 358 0.664030296
361 359 0.6640028542
362 360 0.6639813347
363 361 0.6639597941
364 362 0.6639429832
365 363 0.6639222708
366 364 0.6639065546
367 365 0.6638823236
368 366 0.6638648195
369 367 0.6638436235
370 368 0.6638208732
371 369 0.6637956357
372 370 0.6637718453
373 371 0.663756918
374 372 0.6637353525
375 373 0.6637143112
376 374 0.6636956547
377 375 0.663680995
378 376 0.66366728
379 377 0.6636487567
380 378 0.6636266904
381 379 0.6636116064
382 380 0.6635902746
383 381 0.6635654896
384 382 0.6635393029
385 383 0.6635171734
386 384 0.663500789
387 385 0.663477743
388 386 0.6634584806
389 387 0.6634337499
390 388 0.6634135584
391 389 0.6633868455
392 390 0.6633755323
393 391 0.663356103
394 392 0.6633337631
395 393 0.663319422
396 394 0.6632911566
397 395 0.6632687875
398 396 0.6632431997
399 397 0.6632189331
400 398 0.663201035
401 399 0.6631898553
402 400 0.6631712482
403 401 0.663143025
404 402 0.663121538
405 403 0.6631087792
406 404 0.6630859067
407 405 0.663066483
408 406 0.6630443652
409 407 0.6630250376
410 408 0.6630007822
411 409 0.6629768728
412 410 0.6629528093
413 411 0.6629260936
414 412 0.6629102182
415 413 0.6628863488
416 414 0.6628648972
417 415 0.6628454339
418 416 0.6628200274
419 417 0.6627942591
420 418 0.6627744647
421 419 0.662765485
422 420 0.6627503257
423 421 0.6627323029
424 422 0.6627111509
425 423 0.6626785863
426 424 0.6626576561
427 425 0.6626363113
428 426 0.6626181065
429 427 0.66259794
430 428 0.6625765658
431 429 0.6625526572
432 430 0.66253135
433 431 0.6625035695
434 432 0.662480212
435 433 0.6624611632
436 434 0.6624332625
437 435 0.6624120584
438 436 0.6623941719
439 437 0.6623766304
440 438 0.6623623329
441 439 0.6623442925
442 440 0.6623212715
443 441 0.6623025941
444 442 0.6622749791
445 443 0.6622534499
446 444 0.6622305473
447 445 0.6622059333
448 446 0.6621871707
449 447 0.6621638454
450 448 0.6621511296
451 449 0.6621349978
452 450 0.6621120424
453 451 0.6620958271
454 452 0.6620793528
455 453 0.6620572713
456 454 0.6620395025
457 455 0.6620188044
458 456 0.6620017347
459 457 0.6619811454
460 458 0.6619695569
461 459 0.661952377
462 460 0.6619237442
463 461 0.6619089407
464 462 0.6618886168
465 463 0.6618831383
466 464 0.6618690774
467 465 0.661845878
468 466 0.6618290213
469 467 0.6618050064
470 468 0.6617832833
471 469 0.6617652311
472 470 0.6617443144
473 471 0.6617202619
474 472 0.6617005831
475 473 0.6616824419
476 474 0.6616538226
477 475 0.6616314155
478 476 0.6616127861
479 477 0.6616029072
480 478 0.6615843751
481 479 0.661563216
482 480 0.6615432257
483 481 0.6615263324
484 482 0.6615033259
485 483 0.661484293
486 484 0.6614678231
487 485 0.6614463024
488 486 0.6614155436
489 487 0.6613958945
490 488 0.661380611
491 489 0.6613677802
492 490 0.6613530086
493 491 0.6613248211
494 492 0.6613059359
495 493 0.6612729965
496 494 0.6612624948
497 495 0.6612401679
498 496 0.6612191637
499 497 0.6611912219
500 498 0.6611773017
501 499 0.6611638216
502 500 0.6611450533
503 501 0.6611179111
504 502 0.6610959069
505 503 0.6610728788
506 504 0.6610436668
507 505 0.6610188976
508 506 0.6610030555
509 507 0.6609831174
510 508 0.6609586562
511 509 0.660935882
512 510 0.6609202024
513 511 0.6609011137
514 512 0.6608726737
515 513 0.6608608849
516 514 0.6608387256
517 515 0.6608136063
518 516 0.6607946343
519 517 0.6607703935
520 518 0.6607509625
521 519 0.6607238109
522 520 0.6606999858
523 521 0.6606813873
524 522 0.6606610372
525 523 0.660638456
526 524 0.6606156483
527 525 0.6605968623
528 526 0.6605735776
529 527 0.6605517294
530 528 0.6605309239
531 529 0.6605086434
532 530 0.6604803349
533 531 0.6604566326
534 532 0.6604430839
535 533 0.6604273738
536 534 0.6604048016
537 535 0.6603845173
538 536 0.6603669212
539 537 0.6603488983
540 538 0.6603176881
541 539 0.6602953862
542 540 0.6602672025
543 541 0.6602568636
544 542 0.660235705
545 543 0.6602152295
546 544 0.6601897709
547 545 0.6601683731
548 546 0.6601472267
549 547 0.6601262337
550 548 0.6601119991
551 549 0.6600869973
552 550 0.6600667497
553 551 0.6600397508
554 552 0.660016863
555 553 0.6599933158
556 554 0.6599632649
557 555 0.6599446007
558 556 0.6599138126
559 557 0.6598965504
560 558 0.6598785723
561 559 0.659860838
562 560 0.6598408724
563 561 0.6598244857
564 562 0.6598082469
565 563 0.6597851673
566 564 0.6597683521
567 565 0.6597479006
568 566 0.6597310938
569 567 0.6597096581
570 568 0.6596862311
571 569 0.6596574779
572 570 0.6596385418
573 571 0.6596189903
574 572 0.65959275
575 573 0.6595730662
576 574 0.6595566809
577 575 0.6595365076
578 576 0.6595163446
579 577 0.6594816637
580 578 0.6594570142
581 579 0.6594353055
582 580 0.6594162362
583 581 0.659395036
584 582 0.6593798831
585 583 0.6593556719
586 584 0.6593292627
587 585 0.6592976737
588 586 0.6592754841
589 587 0.6592510441
590 588 0.6592290326
591 589 0.6592097404
592 590 0.6591876204
593 591 0.6591705995
594 592 0.6591456195
595 593 0.6591107122
596 594 0.6590819533
597 595 0.6590551327
598 596 0.6590373916
599 597 0.6590177149
600 598 0.6589946095
601 599 0.6589697628
602 600 0.6589442269
603 601 0.6589182437
604 602 0.6588837179
605 603 0.6588674101
606 604 0.6588406916
607 605 0.6588149945
608 606 0.6587866031
609 607 0.6587636648
610 608 0.6587502469
611 609 0.6587292784
612 610 0.6587104112
613 611 0.6586953782
614 612 0.6586641191
615 613 0.6586450136
616 614 0.6586136263
617 615 0.6585862768
618 616 0.6585585235
619 617 0.6585371631
620 618 0.6585092632
621 619 0.6584914317
622 620 0.6584662432
623 621 0.6584454668
624 622 0.6584249408
625 623 0.6583931228
626 624 0.6583660767
627 625 0.658354264
628 626 0.6583253625
629 627 0.6582968632
630 628 0.6582687399
631 629 0.658242535
632 630 0.6582199874
633 631 0.6581918101
634 632 0.6581735218
635 633 0.6581445869
636 634 0.6581202427
637 635 0.6580977862
638 636 0.6580724179
639 637 0.6580426322
640 638 0.6580111256
641 639 0.6579834747
642 640 0.6579541367
643 641 0.6579254503
644 642 0.657898555
645 643 0.6578676875
646 644 0.6578324163
647 645 0.6578062223
648 646 0.6577760631
649 647 0.6577483474
650 648 0.6577249642
651 649 0.6576974966
652 650 0.657675114
653 651 0.6576447891
654 652 0.6576102356
655 653 0.6575793887
656 654 0.6575543309
657 655 0.6575340787
658 656 0.6575061464
659 657 0.657476113
660 658 0.6574447014
661 659 0.6574247361
662 660 0.6574034983
663 661 0.6573783832
664 662 0.657357694
665 663 0.6573411592
666 664 0.6573118559
667 665 0.6572819076
668 666 0.6572430097
669 667 0.6572160391
670 668 0.6571931413
671 669 0.6571737099
672 670 0.6571532872
673 671 0.6571208939
674 672 0.6570887673
675 673 0.6570633692
676 674 0.6570454361
677 675 0.6570231031
678 676 0.6570052089
679 677 0.6569855794
680 678 0.6569579709
681 679 0.6569333354
682 680 0.6569069617
683 681 0.6568931857
684 682 0.6568734532
685 683 0.6568435196
686 684 0.6568108038
687 685 0.6567811374
688 686 0.6567467284
689 687 0.6567172734
690 688 0.6566967606
691 689 0.6566720128
692 690 0.6566441608
693 691 0.6566172287
694 692 0.6565952549
695 693 0.6565702687
696 694 0.6565392213
697 695 0.6565157938
698 696 0.6564902789
699 697 0.6564644734
700 698 0.6564349549
701 699 0.6564046572
702 700 0.6563744107
703 701 0.6563525063
704 702 0.6563189867
705 703 0.6562939062
706 704 0.6562739297
707 705 0.656256438
708 706 0.6562366475
709 707 0.6562073096
710 708 0.6561864222
711 709 0.6561578826
712 710 0.6561208567
713 711 0.6560924703
714 712 0.6560656907
715 713 0.6560362588
716 714 0.6560124527
717 715 0.6559875055
718 716 0.6559547281
719 717 0.6559230866
720 718 0.6558924823
721 719 0.6558676469
722 720 0.6558459277
723 721 0.6558149638
724 722 0.6557812248
725 723 0.6557546502
726 724 0.6557274948
727 725 0.6557044723
728 726 0.6556751811
729 727 0.6556539158
730 728 0.6556182915
731 729 0.6555977079
732 730 0.6555667903
733 731 0.6555394075
734 732 0.6555122742
735 733 0.6554814941
736 734 0.6554517373
737 735 0.655429552
738 736 0.655396579
739 737 0.6553735864
740 738 0.6553472597
741 739 0.6553252832
742 740 0.6552971659
743 741 0.6552763852
744 742 0.6552488203
745 743 0.65521229
746 744 0.6551949744
747 745 0.6551673797
748 746 0.6551421856
749 747 0.6551255516
750 748 0.6551019608
751 749 0.6550758728
752 750 0.655051966
753 751 0.6550351058
754 752 0.6549998756
755 753 0.6549721212
756 754 0.6549401744
757 755 0.6549207325
758 756 0.6548900891
759 757 0.6548682731
760 758 0.6548418938
761 759 0.6548234717
762 760 0.6547996833
763 761 0.6547726174
764 762 0.6547509314
765 763 0.6547168175
766 764 0.6546907846
767 765 0.6546671611
768 766 0.6546475893
769 767 0.6546206223
770 768 0.6545874193
771 769 0.6545620629
772 770 0.6545346297
773 771 0.6545172316
774 772 0.6544943049
775 773 0.6544632323
776 774 0.6544384097
777 775 0.6544084745
778 776 0.6543765257
779 777 0.6543536123
780 778 0.6543303593
781 779 0.6543005831
782 780 0.6542678123
783 781 0.6542439303
784 782 0.6542100401
785 783 0.6541836178
786 784 0.654158129
787 785 0.6541343464
788 786 0.6541092921
789 787 0.6540812254
790 788 0.654060259
791 789 0.6540467253
792 790 0.6540306837
793 791 0.6540103667
794 792 0.6539821302
795 793 0.6539577914
796 794 0.653923724
797 795 0.6539086888
798 796 0.6538798424
799 797 0.6538566996
800 798 0.6538290752
801 799 0.6538051255
802 800 0.6537917354
803 801 0.6537684302
804 802 0.6537402991
805 803 0.6537165427
806 804 0.6536853601
807 805 0.6536681479
808 806 0.6536409101
809 807 0.6536120189
810 808 0.6535912493
811 809 0.6535617421
812 810 0.6535315174
813 811 0.6534972927
814 812 0.6534818476
815 813 0.6534498323
816 814 0.6534305025
817 815 0.6534081059
818 816 0.6533765804
819 817 0.6533441549
820 818 0.6533053405
821 819 0.6532838469
822 820 0.6532604302
823 821 0.6532364412
824 822 0.6532100089
825 823 0.6531782515
826 824 0.6531449701
827 825 0.653115452
828 826 0.6530787602
829 827 0.653052397
830 828 0.6530313579
831 829 0.6530010363
832 830 0.6529752146
833 831 0.652954801
834 832 0.6529330351
835 833 0.6528993709
836 834 0.6528665883
837 835 0.6528413041
838 836 0.6528217161
839 837 0.6527978782
840 838 0.6527789461
841 839 0.6527432001
842 840 0.6527139767
843 841 0.6526857244
844 842 0.652657086
845 843 0.6526355016
846 844 0.6526054936
847 845 0.6525793707
848 846 0.6525584692
849 847 0.6525279747
850 848 0.6525038765
851 849 0.6524849104
852 850 0.6524610603
853 851 0.6524357337
854 852 0.6524082286
855 853 0.65238051
856 854 0.6523557826
857 855 0.6523391233
858 856 0.652325347
859 857 0.6522924958
860 858 0.6522623584
861 859 0.6522343891
862 860 0.6522094424
863 861 0.6521841478
864 862 0.6521657946
865 863 0.6521304278
866 864 0.6521045712
867 865 0.6520753696
868 866 0.6520519528
869 867 0.6520216555
870 868 0.6519926935
871 869 0.6519734186
+871
View File
@@ -0,0 +1,871 @@
iter Passed Remaining
0 46 93548
1 83 83419
2 132 88415
3 162 81250
4 196 78573
5 230 76747
6 269 76701
7 319 79674
8 364 80653
9 411 81918
10 456 82497
11 491 81432
12 522 79809
13 555 78774
14 595 78777
15 630 78123
16 662 77290
17 700 77124
18 730 76120
19 764 75651
20 804 75774
21 835 75128
22 886 76169
23 920 75764
24 960 75853
25 989 75130
26 1025 74941
27 1060 74714
28 1104 75079
29 1141 74976
30 1180 74975
31 1213 74640
32 1245 74260
33 1287 74434
34 1327 74528
35 1376 75071
36 1427 75741
37 1468 75804
38 1508 75857
39 1549 75922
40 1586 75781
41 1621 75590
42 1663 75705
43 1701 75621
44 1739 75591
45 1776 75460
46 1819 75616
47 1869 76025
48 1916 76288
49 1953 76191
50 1993 76197
51 2038 76381
52 2080 76420
53 2158 77788
54 2220 78529
55 2286 79390
56 2328 79372
57 2367 79254
58 2409 79257
59 2444 79049
60 2484 78985
61 2521 78820
62 2554 78528
63 2593 78466
64 2623 78111
65 2660 77969
66 2695 77776
67 2725 77446
68 2761 77291
69 2791 76975
70 2824 76739
71 2861 76611
72 2897 76476
73 2935 76408
74 3040 78027
75 3097 78411
76 3152 78741
77 3216 79248
78 3256 79195
79 3305 79336
80 3348 79320
81 3381 79089
82 3416 78911
83 3480 79399
84 3535 79649
85 3581 79716
86 3612 79428
87 3644 79185
88 3678 78975
89 3712 78785
90 3743 78531
91 3775 78297
92 3806 78047
93 3837 77821
94 3871 77629
95 3913 77618
96 3945 77403
97 3989 77433
98 4020 77204
99 4053 77020
100 4084 76789
101 4116 76597
102 4148 76401
103 4176 76141
104 4202 75845
105 4232 75634
106 4261 75390
107 4290 75168
108 4324 75018
109 4351 74766
110 4386 74648
111 4424 74577
112 4458 74455
113 4497 74400
114 4533 74307
115 4564 74136
116 4596 73981
117 4628 73818
118 4668 73786
119 4692 73509
120 4723 73354
121 4756 73220
122 4788 73065
123 4815 72854
124 4843 72647
125 4875 72514
126 4916 72515
127 4952 72436
128 4991 72397
129 5028 72327
130 5059 72180
131 5096 72116
132 5125 71946
133 5156 71804
134 5190 71704
135 5221 71564
136 5251 71407
137 5274 71165
138 5309 71084
139 5344 71008
140 5377 70902
141 5416 70866
142 5452 70803
143 5490 70760
144 5521 70641
145 5553 70522
146 5582 70365
147 5611 70217
148 5636 70026
149 5673 69975
150 5706 69874
151 5738 69764
152 5765 69605
153 5795 69471
154 5817 69246
155 5853 69191
156 5888 69122
157 5924 69070
158 5964 69061
159 5996 68963
160 6022 68789
161 6050 68650
162 6079 68510
163 6108 68385
164 6140 68292
165 6169 68162
166 6202 68074
167 6231 67953
168 6263 67858
169 6295 67764
170 6325 67656
171 6356 67561
172 6395 67545
173 6437 67554
174 6472 67495
175 6503 67395
176 6533 67291
177 6562 67174
178 6590 67049
179 6624 66982
180 6655 66882
181 6687 66804
182 6718 66703
183 6751 66632
184 6784 66559
185 6810 66424
186 6832 66246
187 6867 66187
188 6918 66294
189 6969 66393
190 7018 66470
191 7074 66614
192 7117 66635
193 7191 66943
194 7242 67036
195 7282 67027
196 7317 66967
197 7351 66903
198 7389 66879
199 7432 66896
200 7471 66869
201 7506 66814
202 7540 66752
203 7568 66628
204 7605 66596
205 7638 66519
206 7665 66397
207 7700 66340
208 7734 66276
209 7766 66197
210 7796 66106
211 7831 66053
212 7871 66037
213 7910 66016
214 7951 66014
215 7989 65983
216 8025 65946
217 8058 65872
218 8087 65768
219 8112 65638
220 8148 65594
221 8197 65655
222 8239 65655
223 8268 65556
224 8298 65466
225 8327 65366
226 8357 65278
227 8384 65167
228 8418 65103
229 8453 65058
230 8490 65020
231 8523 64958
232 8550 64848
233 8575 64718
234 8607 64648
235 8635 64545
236 8660 64426
237 8691 64345
238 8719 64250
239 8746 64137
240 8773 64038
241 8803 63951
242 8833 63873
243 8862 63779
244 8892 63698
245 8932 63688
246 8962 63611
247 8991 63521
248 9021 63442
249 9051 63358
250 9085 63306
251 9110 63193
252 9137 63093
253 9174 63066
254 9196 62935
255 9238 62934
256 9267 62855
257 9297 62776
258 9324 62681
259 9357 62625
260 9388 62552
261 9427 62536
262 9461 62491
263 9496 62443
264 9524 62356
265 9553 62278
266 9590 62247
267 9620 62172
268 9645 62071
269 9682 62040
270 9711 61962
271 9739 61872
272 9768 61797
273 9804 61761
274 9848 61777
275 9886 61755
276 9925 61740
277 9965 61728
278 9995 61656
279 10022 61564
280 10055 61516
281 10080 61410
282 10111 61344
283 10147 61311
284 10175 61230
285 10202 61141
286 10234 61084
287 10264 61018
288 10299 60977
289 10323 60874
290 10353 60804
291 10394 60803
292 10431 60773
293 10471 60763
294 10503 60707
295 10534 60645
296 10576 60646
297 10612 60612
298 10639 60525
299 10668 60453
300 10702 60411
301 10729 60326
302 10764 60290
303 10801 60263
304 10829 60182
305 10857 60108
306 10892 60067
307 10930 60047
308 10972 60045
309 11002 59983
310 11030 59902
311 11058 59828
312 11092 59788
313 11117 59696
314 11149 59641
315 11187 59617
316 11211 59525
317 11243 59468
318 11274 59413
319 11304 59346
320 11334 59287
321 11362 59209
322 11394 59158
323 11436 59158
324 11477 59153
325 11513 59122
326 11547 59081
327 11572 58991
328 11607 58956
329 11637 58894
330 11668 58833
331 11700 58785
332 11724 58694
333 11757 58648
334 11780 58550
335 11815 58515
336 11844 58451
337 11869 58364
338 11905 58335
339 11941 58302
340 11986 58315
341 12020 58274
342 12066 58292
343 12122 58358
344 12177 58415
345 12221 58422
346 12264 58423
347 12300 58394
348 12324 58304
349 12354 58243
350 12401 58262
351 12438 58232
352 12479 58228
353 12512 58179
354 12541 58116
355 12569 58044
356 12597 57977
357 12628 57920
358 12653 57839
359 12682 57775
360 12720 57752
361 12744 57666
362 12770 57592
363 12811 57583
364 12841 57522
365 12870 57460
366 12897 57386
367 12938 57378
368 12974 57347
369 13009 57313
370 13038 57249
371 13078 57235
372 13117 57216
373 13147 57159
374 13181 57118
375 13205 57036
376 13235 56979
377 13274 56960
378 13306 56911
379 13333 56841
380 13366 56798
381 13396 56741
382 13421 56666
383 13467 56674
384 13508 56664
385 13540 56616
386 13569 56559
387 13598 56496
388 13627 56438
389 13656 56376
390 13685 56317
391 13717 56271
392 13750 56227
393 13771 56135
394 13804 56090
395 13825 55999
396 13858 55957
397 13888 55904
398 13917 55843
399 13953 55812
400 13994 55802
401 14025 55752
402 14048 55670
403 14076 55607
404 14105 55551
405 14142 55526
406 14182 55511
407 14214 55464
408 14240 55394
409 14267 55328
410 14299 55284
411 14324 55213
412 14351 55146
413 14379 55086
414 14410 55036
415 14451 55025
416 14484 54984
417 14513 54929
418 14536 54851
419 14565 54793
420 14587 54710
421 14615 54650
422 14642 54588
423 14666 54515
424 14690 54441
425 14719 54384
426 14739 54297
427 14772 54257
428 14790 54164
429 14824 54125
430 14844 54039
431 14876 53995
432 14906 53946
433 14938 53902
434 14980 53894
435 15006 53829
436 15033 53770
437 15059 53706
438 15085 53639
439 15110 53574
440 15134 53503
441 15160 53438
442 15184 53369
443 15211 53308
444 15234 53236
445 15266 53193
446 15287 53114
447 15316 53059
448 15336 52978
449 15366 52929
450 15393 52870
451 15429 52843
452 15469 52828
453 15490 52748
454 15523 52712
455 15550 52653
456 15577 52594
457 15604 52536
458 15630 52476
459 15656 52414
460 15682 52353
461 15711 52304
462 15736 52238
463 15765 52188
464 15786 52112
465 15817 52068
466 15839 51996
467 15873 51961
468 15903 51916
469 15935 51873
470 15969 51840
471 15994 51779
472 16022 51726
473 16047 51663
474 16073 51605
475 16099 51546
476 16128 51495
477 16152 51431
478 16176 51367
479 16205 51317
480 16228 51250
481 16255 51194
482 16277 51123
483 16305 51071
484 16328 51005
485 16362 50973
486 16392 50928
487 16426 50894
488 16459 50860
489 16480 50787
490 16510 50743
491 16530 50668
492 16561 50625
493 16585 50562
494 16613 50510
495 16638 50453
496 16663 50393
497 16690 50339
498 16716 50282
499 16740 50222
500 16773 50186
501 16802 50139
502 16836 50107
503 16873 50085
504 16921 50094
505 16989 50163
506 17038 50173
507 17069 50132
508 17110 50121
509 17145 50091
510 17190 50091
511 17219 50044
512 17247 49994
513 17271 49932
514 17298 49878
515 17343 49878
516 17373 49836
517 17417 49831
518 17460 49823
519 17490 49781
520 17518 49731
521 17546 49680
522 17571 49622
523 17600 49577
524 17625 49520
525 17655 49474
526 17679 49414
527 17707 49366
528 17729 49300
529 17758 49254
530 17781 49191
531 17808 49141
532 17829 49071
533 17862 49038
534 17905 49031
535 18028 49241
536 18072 49236
537 18106 49203
538 18135 49157
539 18165 49114
540 18200 49083
541 18223 49022
542 18254 48980
543 18280 48927
544 18307 48876
545 18338 48834
546 18367 48790
547 18411 48783
548 18444 48747
549 18470 48693
550 18503 48660
551 18531 48611
552 18557 48558
553 18584 48508
554 18625 48493
555 18650 48436
556 18677 48388
557 18703 48333
558 18729 48282
559 18756 48231
560 18781 48176
561 18808 48126
562 18834 48074
563 18869 48043
564 18902 48008
565 18930 47960
566 18958 47914
567 18983 47859
568 19016 47824
569 19037 47761
570 19068 47720
571 19090 47660
572 19111 47595
573 19141 47553
574 19164 47494
575 19196 47458
576 19217 47393
577 19249 47358
578 19274 47303
579 19298 47247
580 19324 47195
581 19357 47162
582 19391 47130
583 19427 47103
584 19460 47070
585 19483 47012
586 19511 46967
587 19542 46929
588 19564 46867
589 19597 46833
590 19621 46779
591 19647 46729
592 19670 46672
593 19699 46627
594 19726 46582
595 19753 46532
596 19778 46480
597 19803 46429
598 19830 46381
599 19857 46335
600 19896 46313
601 19925 46271
602 19957 46236
603 19991 46204
604 20019 46159
605 20047 46115
606 20072 46063
607 20098 46015
608 20123 45963
609 20149 45913
610 20176 45867
611 20202 45817
612 20230 45774
613 20253 45719
614 20285 45682
615 20307 45626
616 20338 45589
617 20361 45532
618 20394 45500
619 20423 45459
620 20454 45420
621 20488 45390
622 20510 45333
623 20543 45301
624 20569 45252
625 20594 45201
626 20619 45151
627 20646 45107
628 20675 45066
629 20701 45016
630 20727 44970
631 20752 44919
632 20782 44881
633 20804 44825
634 20837 44791
635 20862 44742
636 20892 44704
637 20931 44683
638 20960 44643
639 20994 44612
640 21022 44570
641 21052 44531
642 21082 44493
643 21107 44443
644 21135 44401
645 21160 44351
646 21185 44302
647 21210 44253
648 21236 44208
649 21262 44161
650 21288 44113
651 21315 44068
652 21343 44027
653 21377 43997
654 21403 43949
655 21440 43926
656 21477 43903
657 21502 43854
658 21533 43819
659 21559 43772
660 21586 43727
661 21611 43680
662 21637 43633
663 21662 43586
664 21688 43539
665 21714 43493
666 21742 43451
667 21771 43413
668 21818 43409
669 21846 43366
670 21888 43352
671 21934 43345
672 21971 43322
673 22019 43320
674 22053 43289
675 22090 43266
676 22141 43269
677 22176 43240
678 22213 43215
679 22239 43171
680 22270 43134
681 22296 43088
682 22321 43041
683 22350 43002
684 22379 42962
685 22419 42944
686 22452 42912
687 22484 42878
688 22511 42834
689 22537 42789
690 22571 42757
691 22598 42714
692 22624 42669
693 22653 42630
694 22680 42586
695 22708 42545
696 22739 42510
697 22761 42457
698 22792 42421
699 22816 42373
700 22845 42333
701 22870 42288
702 22902 42253
703 22942 42234
704 22974 42201
705 23002 42160
706 23033 42124
707 23054 42071
708 23086 42038
709 23115 41999
710 23143 41957
711 23169 41914
712 23195 41868
713 23230 41840
714 23259 41801
715 23287 41760
716 23311 41713
717 23341 41676
718 23372 41641
719 23405 41610
720 23438 41578
721 23483 41566
722 23507 41519
723 23540 41488
724 23566 41444
725 23595 41406
726 23623 41365
727 23648 41320
728 23677 41281
729 23700 41231
730 23728 41192
731 23752 41144
732 23784 41111
733 23807 41063
734 23840 41031
735 23870 40994
736 23908 40972
737 23941 40940
738 23974 40909
739 24006 40875
740 24036 40838
741 24064 40798
742 24092 40759
743 24127 40730
744 24153 40688
745 24179 40644
746 24207 40604
747 24233 40561
748 24261 40522
749 24295 40491
750 24318 40444
751 24349 40410
752 24376 40368
753 24408 40335
754 24442 40306
755 24474 40273
756 24508 40242
757 24548 40222
758 24575 40182
759 24605 40145
760 24632 40104
761 24660 40064
762 24689 40027
763 24714 39982
764 24745 39949
765 24766 39897
766 24797 39863
767 24825 39823
768 24854 39786
769 24880 39744
770 24909 39706
771 24940 39672
772 24970 39635
773 25004 39606
774 25030 39564
775 25056 39522
776 25086 39486
777 25107 39436
778 25139 39403
779 25159 39351
780 25188 39314
781 25214 39272
782 25240 39230
783 25266 39188
784 25288 39141
785 25315 39101
786 25341 39058
787 25367 39016
788 25391 38972
789 25417 38930
790 25448 38895
791 25482 38867
792 25514 38834
793 25542 38795
794 25569 38756
795 25595 38714
796 25618 38669
797 25643 38626
798 25667 38581
799 25695 38543
800 25716 38494
801 25743 38454
802 25770 38415
803 25790 38364
804 25822 38332
805 25843 38284
806 25873 38249
807 25896 38203
808 25925 38167
809 25955 38131
810 25988 38101
811 26028 38080
812 26055 38042
813 26081 38000
814 26108 37961
815 26131 37916
816 26159 37878
817 26188 37841
818 26214 37800
819 26242 37764
820 26272 37728
821 26298 37688
822 26327 37652
823 26359 37619
824 26385 37580
825 26408 37534
826 26444 37507
827 26477 37478
828 26517 37456
829 26539 37411
830 26573 37382
831 26597 37339
832 26623 37298
833 26650 37259
834 26677 37221
835 26704 37182
836 26728 37138
837 26763 37111
838 26791 37073
839 26822 37041
840 26872 37033
841 26924 37029
842 26982 37033
843 27054 37055
844 27097 37038
845 27120 36994
846 27146 36954
847 27180 36925
848 27206 36884
849 27234 36846
850 27260 36807
851 27289 36770
852 27318 36734
853 27347 36698
854 27386 36675
855 27413 36637
856 27439 36596
857 27471 36564
858 27501 36529
859 27535 36500
860 27572 36474
861 27595 36431
862 27627 36398
863 27654 36360
864 27683 36324
865 27711 36287
866 27738 36249
867 27765 36210
868 27794 36175
869 27820 36135
1 iter Passed Remaining
2 0 46 93548
3 1 83 83419
4 2 132 88415
5 3 162 81250
6 4 196 78573
7 5 230 76747
8 6 269 76701
9 7 319 79674
10 8 364 80653
11 9 411 81918
12 10 456 82497
13 11 491 81432
14 12 522 79809
15 13 555 78774
16 14 595 78777
17 15 630 78123
18 16 662 77290
19 17 700 77124
20 18 730 76120
21 19 764 75651
22 20 804 75774
23 21 835 75128
24 22 886 76169
25 23 920 75764
26 24 960 75853
27 25 989 75130
28 26 1025 74941
29 27 1060 74714
30 28 1104 75079
31 29 1141 74976
32 30 1180 74975
33 31 1213 74640
34 32 1245 74260
35 33 1287 74434
36 34 1327 74528
37 35 1376 75071
38 36 1427 75741
39 37 1468 75804
40 38 1508 75857
41 39 1549 75922
42 40 1586 75781
43 41 1621 75590
44 42 1663 75705
45 43 1701 75621
46 44 1739 75591
47 45 1776 75460
48 46 1819 75616
49 47 1869 76025
50 48 1916 76288
51 49 1953 76191
52 50 1993 76197
53 51 2038 76381
54 52 2080 76420
55 53 2158 77788
56 54 2220 78529
57 55 2286 79390
58 56 2328 79372
59 57 2367 79254
60 58 2409 79257
61 59 2444 79049
62 60 2484 78985
63 61 2521 78820
64 62 2554 78528
65 63 2593 78466
66 64 2623 78111
67 65 2660 77969
68 66 2695 77776
69 67 2725 77446
70 68 2761 77291
71 69 2791 76975
72 70 2824 76739
73 71 2861 76611
74 72 2897 76476
75 73 2935 76408
76 74 3040 78027
77 75 3097 78411
78 76 3152 78741
79 77 3216 79248
80 78 3256 79195
81 79 3305 79336
82 80 3348 79320
83 81 3381 79089
84 82 3416 78911
85 83 3480 79399
86 84 3535 79649
87 85 3581 79716
88 86 3612 79428
89 87 3644 79185
90 88 3678 78975
91 89 3712 78785
92 90 3743 78531
93 91 3775 78297
94 92 3806 78047
95 93 3837 77821
96 94 3871 77629
97 95 3913 77618
98 96 3945 77403
99 97 3989 77433
100 98 4020 77204
101 99 4053 77020
102 100 4084 76789
103 101 4116 76597
104 102 4148 76401
105 103 4176 76141
106 104 4202 75845
107 105 4232 75634
108 106 4261 75390
109 107 4290 75168
110 108 4324 75018
111 109 4351 74766
112 110 4386 74648
113 111 4424 74577
114 112 4458 74455
115 113 4497 74400
116 114 4533 74307
117 115 4564 74136
118 116 4596 73981
119 117 4628 73818
120 118 4668 73786
121 119 4692 73509
122 120 4723 73354
123 121 4756 73220
124 122 4788 73065
125 123 4815 72854
126 124 4843 72647
127 125 4875 72514
128 126 4916 72515
129 127 4952 72436
130 128 4991 72397
131 129 5028 72327
132 130 5059 72180
133 131 5096 72116
134 132 5125 71946
135 133 5156 71804
136 134 5190 71704
137 135 5221 71564
138 136 5251 71407
139 137 5274 71165
140 138 5309 71084
141 139 5344 71008
142 140 5377 70902
143 141 5416 70866
144 142 5452 70803
145 143 5490 70760
146 144 5521 70641
147 145 5553 70522
148 146 5582 70365
149 147 5611 70217
150 148 5636 70026
151 149 5673 69975
152 150 5706 69874
153 151 5738 69764
154 152 5765 69605
155 153 5795 69471
156 154 5817 69246
157 155 5853 69191
158 156 5888 69122
159 157 5924 69070
160 158 5964 69061
161 159 5996 68963
162 160 6022 68789
163 161 6050 68650
164 162 6079 68510
165 163 6108 68385
166 164 6140 68292
167 165 6169 68162
168 166 6202 68074
169 167 6231 67953
170 168 6263 67858
171 169 6295 67764
172 170 6325 67656
173 171 6356 67561
174 172 6395 67545
175 173 6437 67554
176 174 6472 67495
177 175 6503 67395
178 176 6533 67291
179 177 6562 67174
180 178 6590 67049
181 179 6624 66982
182 180 6655 66882
183 181 6687 66804
184 182 6718 66703
185 183 6751 66632
186 184 6784 66559
187 185 6810 66424
188 186 6832 66246
189 187 6867 66187
190 188 6918 66294
191 189 6969 66393
192 190 7018 66470
193 191 7074 66614
194 192 7117 66635
195 193 7191 66943
196 194 7242 67036
197 195 7282 67027
198 196 7317 66967
199 197 7351 66903
200 198 7389 66879
201 199 7432 66896
202 200 7471 66869
203 201 7506 66814
204 202 7540 66752
205 203 7568 66628
206 204 7605 66596
207 205 7638 66519
208 206 7665 66397
209 207 7700 66340
210 208 7734 66276
211 209 7766 66197
212 210 7796 66106
213 211 7831 66053
214 212 7871 66037
215 213 7910 66016
216 214 7951 66014
217 215 7989 65983
218 216 8025 65946
219 217 8058 65872
220 218 8087 65768
221 219 8112 65638
222 220 8148 65594
223 221 8197 65655
224 222 8239 65655
225 223 8268 65556
226 224 8298 65466
227 225 8327 65366
228 226 8357 65278
229 227 8384 65167
230 228 8418 65103
231 229 8453 65058
232 230 8490 65020
233 231 8523 64958
234 232 8550 64848
235 233 8575 64718
236 234 8607 64648
237 235 8635 64545
238 236 8660 64426
239 237 8691 64345
240 238 8719 64250
241 239 8746 64137
242 240 8773 64038
243 241 8803 63951
244 242 8833 63873
245 243 8862 63779
246 244 8892 63698
247 245 8932 63688
248 246 8962 63611
249 247 8991 63521
250 248 9021 63442
251 249 9051 63358
252 250 9085 63306
253 251 9110 63193
254 252 9137 63093
255 253 9174 63066
256 254 9196 62935
257 255 9238 62934
258 256 9267 62855
259 257 9297 62776
260 258 9324 62681
261 259 9357 62625
262 260 9388 62552
263 261 9427 62536
264 262 9461 62491
265 263 9496 62443
266 264 9524 62356
267 265 9553 62278
268 266 9590 62247
269 267 9620 62172
270 268 9645 62071
271 269 9682 62040
272 270 9711 61962
273 271 9739 61872
274 272 9768 61797
275 273 9804 61761
276 274 9848 61777
277 275 9886 61755
278 276 9925 61740
279 277 9965 61728
280 278 9995 61656
281 279 10022 61564
282 280 10055 61516
283 281 10080 61410
284 282 10111 61344
285 283 10147 61311
286 284 10175 61230
287 285 10202 61141
288 286 10234 61084
289 287 10264 61018
290 288 10299 60977
291 289 10323 60874
292 290 10353 60804
293 291 10394 60803
294 292 10431 60773
295 293 10471 60763
296 294 10503 60707
297 295 10534 60645
298 296 10576 60646
299 297 10612 60612
300 298 10639 60525
301 299 10668 60453
302 300 10702 60411
303 301 10729 60326
304 302 10764 60290
305 303 10801 60263
306 304 10829 60182
307 305 10857 60108
308 306 10892 60067
309 307 10930 60047
310 308 10972 60045
311 309 11002 59983
312 310 11030 59902
313 311 11058 59828
314 312 11092 59788
315 313 11117 59696
316 314 11149 59641
317 315 11187 59617
318 316 11211 59525
319 317 11243 59468
320 318 11274 59413
321 319 11304 59346
322 320 11334 59287
323 321 11362 59209
324 322 11394 59158
325 323 11436 59158
326 324 11477 59153
327 325 11513 59122
328 326 11547 59081
329 327 11572 58991
330 328 11607 58956
331 329 11637 58894
332 330 11668 58833
333 331 11700 58785
334 332 11724 58694
335 333 11757 58648
336 334 11780 58550
337 335 11815 58515
338 336 11844 58451
339 337 11869 58364
340 338 11905 58335
341 339 11941 58302
342 340 11986 58315
343 341 12020 58274
344 342 12066 58292
345 343 12122 58358
346 344 12177 58415
347 345 12221 58422
348 346 12264 58423
349 347 12300 58394
350 348 12324 58304
351 349 12354 58243
352 350 12401 58262
353 351 12438 58232
354 352 12479 58228
355 353 12512 58179
356 354 12541 58116
357 355 12569 58044
358 356 12597 57977
359 357 12628 57920
360 358 12653 57839
361 359 12682 57775
362 360 12720 57752
363 361 12744 57666
364 362 12770 57592
365 363 12811 57583
366 364 12841 57522
367 365 12870 57460
368 366 12897 57386
369 367 12938 57378
370 368 12974 57347
371 369 13009 57313
372 370 13038 57249
373 371 13078 57235
374 372 13117 57216
375 373 13147 57159
376 374 13181 57118
377 375 13205 57036
378 376 13235 56979
379 377 13274 56960
380 378 13306 56911
381 379 13333 56841
382 380 13366 56798
383 381 13396 56741
384 382 13421 56666
385 383 13467 56674
386 384 13508 56664
387 385 13540 56616
388 386 13569 56559
389 387 13598 56496
390 388 13627 56438
391 389 13656 56376
392 390 13685 56317
393 391 13717 56271
394 392 13750 56227
395 393 13771 56135
396 394 13804 56090
397 395 13825 55999
398 396 13858 55957
399 397 13888 55904
400 398 13917 55843
401 399 13953 55812
402 400 13994 55802
403 401 14025 55752
404 402 14048 55670
405 403 14076 55607
406 404 14105 55551
407 405 14142 55526
408 406 14182 55511
409 407 14214 55464
410 408 14240 55394
411 409 14267 55328
412 410 14299 55284
413 411 14324 55213
414 412 14351 55146
415 413 14379 55086
416 414 14410 55036
417 415 14451 55025
418 416 14484 54984
419 417 14513 54929
420 418 14536 54851
421 419 14565 54793
422 420 14587 54710
423 421 14615 54650
424 422 14642 54588
425 423 14666 54515
426 424 14690 54441
427 425 14719 54384
428 426 14739 54297
429 427 14772 54257
430 428 14790 54164
431 429 14824 54125
432 430 14844 54039
433 431 14876 53995
434 432 14906 53946
435 433 14938 53902
436 434 14980 53894
437 435 15006 53829
438 436 15033 53770
439 437 15059 53706
440 438 15085 53639
441 439 15110 53574
442 440 15134 53503
443 441 15160 53438
444 442 15184 53369
445 443 15211 53308
446 444 15234 53236
447 445 15266 53193
448 446 15287 53114
449 447 15316 53059
450 448 15336 52978
451 449 15366 52929
452 450 15393 52870
453 451 15429 52843
454 452 15469 52828
455 453 15490 52748
456 454 15523 52712
457 455 15550 52653
458 456 15577 52594
459 457 15604 52536
460 458 15630 52476
461 459 15656 52414
462 460 15682 52353
463 461 15711 52304
464 462 15736 52238
465 463 15765 52188
466 464 15786 52112
467 465 15817 52068
468 466 15839 51996
469 467 15873 51961
470 468 15903 51916
471 469 15935 51873
472 470 15969 51840
473 471 15994 51779
474 472 16022 51726
475 473 16047 51663
476 474 16073 51605
477 475 16099 51546
478 476 16128 51495
479 477 16152 51431
480 478 16176 51367
481 479 16205 51317
482 480 16228 51250
483 481 16255 51194
484 482 16277 51123
485 483 16305 51071
486 484 16328 51005
487 485 16362 50973
488 486 16392 50928
489 487 16426 50894
490 488 16459 50860
491 489 16480 50787
492 490 16510 50743
493 491 16530 50668
494 492 16561 50625
495 493 16585 50562
496 494 16613 50510
497 495 16638 50453
498 496 16663 50393
499 497 16690 50339
500 498 16716 50282
501 499 16740 50222
502 500 16773 50186
503 501 16802 50139
504 502 16836 50107
505 503 16873 50085
506 504 16921 50094
507 505 16989 50163
508 506 17038 50173
509 507 17069 50132
510 508 17110 50121
511 509 17145 50091
512 510 17190 50091
513 511 17219 50044
514 512 17247 49994
515 513 17271 49932
516 514 17298 49878
517 515 17343 49878
518 516 17373 49836
519 517 17417 49831
520 518 17460 49823
521 519 17490 49781
522 520 17518 49731
523 521 17546 49680
524 522 17571 49622
525 523 17600 49577
526 524 17625 49520
527 525 17655 49474
528 526 17679 49414
529 527 17707 49366
530 528 17729 49300
531 529 17758 49254
532 530 17781 49191
533 531 17808 49141
534 532 17829 49071
535 533 17862 49038
536 534 17905 49031
537 535 18028 49241
538 536 18072 49236
539 537 18106 49203
540 538 18135 49157
541 539 18165 49114
542 540 18200 49083
543 541 18223 49022
544 542 18254 48980
545 543 18280 48927
546 544 18307 48876
547 545 18338 48834
548 546 18367 48790
549 547 18411 48783
550 548 18444 48747
551 549 18470 48693
552 550 18503 48660
553 551 18531 48611
554 552 18557 48558
555 553 18584 48508
556 554 18625 48493
557 555 18650 48436
558 556 18677 48388
559 557 18703 48333
560 558 18729 48282
561 559 18756 48231
562 560 18781 48176
563 561 18808 48126
564 562 18834 48074
565 563 18869 48043
566 564 18902 48008
567 565 18930 47960
568 566 18958 47914
569 567 18983 47859
570 568 19016 47824
571 569 19037 47761
572 570 19068 47720
573 571 19090 47660
574 572 19111 47595
575 573 19141 47553
576 574 19164 47494
577 575 19196 47458
578 576 19217 47393
579 577 19249 47358
580 578 19274 47303
581 579 19298 47247
582 580 19324 47195
583 581 19357 47162
584 582 19391 47130
585 583 19427 47103
586 584 19460 47070
587 585 19483 47012
588 586 19511 46967
589 587 19542 46929
590 588 19564 46867
591 589 19597 46833
592 590 19621 46779
593 591 19647 46729
594 592 19670 46672
595 593 19699 46627
596 594 19726 46582
597 595 19753 46532
598 596 19778 46480
599 597 19803 46429
600 598 19830 46381
601 599 19857 46335
602 600 19896 46313
603 601 19925 46271
604 602 19957 46236
605 603 19991 46204
606 604 20019 46159
607 605 20047 46115
608 606 20072 46063
609 607 20098 46015
610 608 20123 45963
611 609 20149 45913
612 610 20176 45867
613 611 20202 45817
614 612 20230 45774
615 613 20253 45719
616 614 20285 45682
617 615 20307 45626
618 616 20338 45589
619 617 20361 45532
620 618 20394 45500
621 619 20423 45459
622 620 20454 45420
623 621 20488 45390
624 622 20510 45333
625 623 20543 45301
626 624 20569 45252
627 625 20594 45201
628 626 20619 45151
629 627 20646 45107
630 628 20675 45066
631 629 20701 45016
632 630 20727 44970
633 631 20752 44919
634 632 20782 44881
635 633 20804 44825
636 634 20837 44791
637 635 20862 44742
638 636 20892 44704
639 637 20931 44683
640 638 20960 44643
641 639 20994 44612
642 640 21022 44570
643 641 21052 44531
644 642 21082 44493
645 643 21107 44443
646 644 21135 44401
647 645 21160 44351
648 646 21185 44302
649 647 21210 44253
650 648 21236 44208
651 649 21262 44161
652 650 21288 44113
653 651 21315 44068
654 652 21343 44027
655 653 21377 43997
656 654 21403 43949
657 655 21440 43926
658 656 21477 43903
659 657 21502 43854
660 658 21533 43819
661 659 21559 43772
662 660 21586 43727
663 661 21611 43680
664 662 21637 43633
665 663 21662 43586
666 664 21688 43539
667 665 21714 43493
668 666 21742 43451
669 667 21771 43413
670 668 21818 43409
671 669 21846 43366
672 670 21888 43352
673 671 21934 43345
674 672 21971 43322
675 673 22019 43320
676 674 22053 43289
677 675 22090 43266
678 676 22141 43269
679 677 22176 43240
680 678 22213 43215
681 679 22239 43171
682 680 22270 43134
683 681 22296 43088
684 682 22321 43041
685 683 22350 43002
686 684 22379 42962
687 685 22419 42944
688 686 22452 42912
689 687 22484 42878
690 688 22511 42834
691 689 22537 42789
692 690 22571 42757
693 691 22598 42714
694 692 22624 42669
695 693 22653 42630
696 694 22680 42586
697 695 22708 42545
698 696 22739 42510
699 697 22761 42457
700 698 22792 42421
701 699 22816 42373
702 700 22845 42333
703 701 22870 42288
704 702 22902 42253
705 703 22942 42234
706 704 22974 42201
707 705 23002 42160
708 706 23033 42124
709 707 23054 42071
710 708 23086 42038
711 709 23115 41999
712 710 23143 41957
713 711 23169 41914
714 712 23195 41868
715 713 23230 41840
716 714 23259 41801
717 715 23287 41760
718 716 23311 41713
719 717 23341 41676
720 718 23372 41641
721 719 23405 41610
722 720 23438 41578
723 721 23483 41566
724 722 23507 41519
725 723 23540 41488
726 724 23566 41444
727 725 23595 41406
728 726 23623 41365
729 727 23648 41320
730 728 23677 41281
731 729 23700 41231
732 730 23728 41192
733 731 23752 41144
734 732 23784 41111
735 733 23807 41063
736 734 23840 41031
737 735 23870 40994
738 736 23908 40972
739 737 23941 40940
740 738 23974 40909
741 739 24006 40875
742 740 24036 40838
743 741 24064 40798
744 742 24092 40759
745 743 24127 40730
746 744 24153 40688
747 745 24179 40644
748 746 24207 40604
749 747 24233 40561
750 748 24261 40522
751 749 24295 40491
752 750 24318 40444
753 751 24349 40410
754 752 24376 40368
755 753 24408 40335
756 754 24442 40306
757 755 24474 40273
758 756 24508 40242
759 757 24548 40222
760 758 24575 40182
761 759 24605 40145
762 760 24632 40104
763 761 24660 40064
764 762 24689 40027
765 763 24714 39982
766 764 24745 39949
767 765 24766 39897
768 766 24797 39863
769 767 24825 39823
770 768 24854 39786
771 769 24880 39744
772 770 24909 39706
773 771 24940 39672
774 772 24970 39635
775 773 25004 39606
776 774 25030 39564
777 775 25056 39522
778 776 25086 39486
779 777 25107 39436
780 778 25139 39403
781 779 25159 39351
782 780 25188 39314
783 781 25214 39272
784 782 25240 39230
785 783 25266 39188
786 784 25288 39141
787 785 25315 39101
788 786 25341 39058
789 787 25367 39016
790 788 25391 38972
791 789 25417 38930
792 790 25448 38895
793 791 25482 38867
794 792 25514 38834
795 793 25542 38795
796 794 25569 38756
797 795 25595 38714
798 796 25618 38669
799 797 25643 38626
800 798 25667 38581
801 799 25695 38543
802 800 25716 38494
803 801 25743 38454
804 802 25770 38415
805 803 25790 38364
806 804 25822 38332
807 805 25843 38284
808 806 25873 38249
809 807 25896 38203
810 808 25925 38167
811 809 25955 38131
812 810 25988 38101
813 811 26028 38080
814 812 26055 38042
815 813 26081 38000
816 814 26108 37961
817 815 26131 37916
818 816 26159 37878
819 817 26188 37841
820 818 26214 37800
821 819 26242 37764
822 820 26272 37728
823 821 26298 37688
824 822 26327 37652
825 823 26359 37619
826 824 26385 37580
827 825 26408 37534
828 826 26444 37507
829 827 26477 37478
830 828 26517 37456
831 829 26539 37411
832 830 26573 37382
833 831 26597 37339
834 832 26623 37298
835 833 26650 37259
836 834 26677 37221
837 835 26704 37182
838 836 26728 37138
839 837 26763 37111
840 838 26791 37073
841 839 26822 37041
842 840 26872 37033
843 841 26924 37029
844 842 26982 37033
845 843 27054 37055
846 844 27097 37038
847 845 27120 36994
848 846 27146 36954
849 847 27180 36925
850 848 27206 36884
851 849 27234 36846
852 850 27260 36807
853 851 27289 36770
854 852 27318 36734
855 853 27347 36698
856 854 27386 36675
857 855 27413 36637
858 856 27439 36596
859 857 27471 36564
860 858 27501 36529
861 859 27535 36500
862 860 27572 36474
863 861 27595 36431
864 862 27627 36398
865 863 27654 36360
866 864 27683 36324
867 865 27711 36287
868 866 27738 36249
869 867 27765 36210
870 868 27794 36175
871 869 27820 36135
+73 -4
View File
@@ -1,17 +1,19 @@
import os
import json
import yaml
from typing import Dict, Any, Optional
class EnsembleConfig:
_instance: Optional['EnsembleConfig'] = None
_config: Dict[str, Any] = {}
def __new__(cls):
if cls._instance is None:
cls._instance = super(EnsembleConfig, cls).__new__(cls)
cls._instance._load_config()
return cls._instance
def _load_config(self):
"""Load configuration from YAML file."""
config_path = os.path.join(os.path.dirname(__file__), 'ensemble_config.yaml')
@@ -22,12 +24,12 @@ class EnsembleConfig:
except Exception as e:
print(f"❌ Failed to load ensemble config: {e}")
self._config = {}
def get(self, key: str, default: Any = None) -> Any:
"""Get configuration value by key (supports dot notation for nested keys)."""
keys = key.split('.')
value = self._config
try:
for k in keys:
value = value[k]
@@ -35,12 +37,79 @@ class EnsembleConfig:
except (KeyError, TypeError):
return default
# Singleton accessor
def get_config() -> EnsembleConfig:
return EnsembleConfig()
# ── Market Thresholds Loader ────────────────────────────────────────────
_market_thresholds_cache: Optional[Dict[str, Any]] = None
def load_market_thresholds() -> Dict[str, Any]:
"""
Load market thresholds from JSON config file.
Returns the full config dict with 'markets' and 'defaults' keys.
Caches after first load for performance.
"""
global _market_thresholds_cache
if _market_thresholds_cache is not None:
return _market_thresholds_cache
config_path = os.path.join(os.path.dirname(__file__), 'market_thresholds.json')
try:
with open(config_path, 'r', encoding='utf-8') as f:
data = json.load(f)
_market_thresholds_cache = data
print(f"✅ Market thresholds loaded: {len(data.get('markets', {}))} markets (v={data.get('_meta', {}).get('version', '?')})")
return data
except Exception as e:
print(f"❌ Failed to load market thresholds: {e} — using built-in defaults")
_market_thresholds_cache = {"markets": {}, "defaults": {
"calibration": 0.55,
"min_conf": 55.0,
"min_play_score": 68.0,
"min_edge": 0.02,
"odds_band_min_sample": 0.0,
"odds_band_min_edge": 0.0,
}}
return _market_thresholds_cache
def build_threshold_dict(field: str) -> Dict[str, float]:
"""
Build a flat {market: value} dict for a specific threshold field.
Usage:
calibration_map = build_threshold_dict("calibration")
# → {"MS": 0.62, "DC": 0.82, ...}
"""
data = load_market_thresholds()
markets = data.get("markets", {})
result: Dict[str, float] = {}
for market, cfg in markets.items():
if field in cfg:
result[market] = float(cfg[field])
return result
def get_threshold_default(field: str) -> float:
"""Get the default fallback value for a threshold field."""
data = load_market_thresholds()
defaults = data.get("defaults", {})
return float(defaults.get(field, 0.0))
if __name__ == "__main__":
# Test
cfg = get_config()
print(f"Weights: {cfg.get('engine_weights')}")
print(f"Team Weight: {cfg.get('engine_weights.team')}")
print()
print("--- Market Thresholds ---")
for field in ["calibration", "min_conf", "min_play_score", "min_edge"]:
d = build_threshold_dict(field)
print(f"{field}: {d}")
print(f"Default calibration: {get_threshold_default('calibration')}")
+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
}
}
+40 -13
View File
@@ -18,15 +18,20 @@ from features.sidelined_analyzer import get_sidelined_analyzer
@dataclass
class PlayerPrediction:
"""Player engine prediction output."""
home_squad_quality: float = 50.0 # 0-100
away_squad_quality: float = 50.0
squad_diff: float = 0.0 # -100 to +100
"""Player engine prediction output.
IMPORTANT: squad_quality uses the SAME composite formula as
extract_training_data.py so that inference values match the
distribution the model was trained on (~3-36 range).
"""
home_squad_quality: float = 12.0 # training-scale composite (~3-36)
away_squad_quality: float = 12.0
squad_diff: float = 0.0 # home - away (training scale)
home_key_players: int = 0
away_key_players: int = 0
home_missing_impact: float = 0.0 # 0-1, how much weaker due to missing players
home_missing_impact: float = 0.0 # 0-1, how much weaker due to missing players
away_missing_impact: float = 0.0
home_goals_form: int = 0 # Goals in last 5 matches
home_goals_form: int = 0 # Goals in last 5 matches
away_goals_form: int = 0
lineup_available: bool = False
confidence: float = 0.0
@@ -100,10 +105,12 @@ class PlayerPredictorEngine:
"home_goals_last_5": home_analysis.total_goals_last_5,
"home_assists_last_5": home_analysis.total_assists_last_5,
"home_key_players": home_analysis.key_players_count,
"home_forwards": home_analysis.forward_count or 2,
"away_starting_11": away_analysis.starting_count or 11,
"away_goals_last_5": away_analysis.total_goals_last_5,
"away_assists_last_5": away_analysis.total_assists_last_5,
"away_key_players": away_analysis.key_players_count,
"away_forwards": away_analysis.forward_count or 2,
}
elif match_id:
# Try to get from database
@@ -131,13 +138,31 @@ class PlayerPredictorEngine:
away_goals = features.get("away_goals_last_5", 0)
home_key = features.get("home_key_players", 0)
away_key = features.get("away_key_players", 0)
home_assists = features.get("home_assists_last_5", 0)
away_assists = features.get("away_assists_last_5", 0)
home_starting = features.get("home_starting_11", 11)
away_starting = features.get("away_starting_11", 11)
home_fwd = features.get("home_forwards", 2)
away_fwd = features.get("away_forwards", 2)
# Calculate squad quality (0-100)
# Based on: goals scored, key players, assists
home_quality = min(100, 50 + (home_goals * 3) + (home_key * 5) +
features.get("home_assists_last_5", 0) * 2)
away_quality = min(100, 50 + (away_goals * 3) + (away_key * 5) +
features.get("away_assists_last_5", 0) * 2)
# Calculate squad quality — MUST match extract_training_data.py formula
# Formula: starting_count * 0.3 + goals * 2.0 + assists * 1.0
# + key_players * 3.0 + fwd_count * 1.5
# Typical range: ~3 36 (model trained on this distribution)
home_quality = (
home_starting * 0.3 +
home_goals * 2.0 +
home_assists * 1.0 +
home_key * 3.0 +
home_fwd * 1.5
)
away_quality = (
away_starting * 0.3 +
away_goals * 2.0 +
away_assists * 1.0 +
away_key * 3.0 +
away_fwd * 1.5
)
# Squad difference
squad_diff = home_quality - away_quality
@@ -186,8 +211,10 @@ class PlayerPredictorEngine:
Calculate 1X2 probability modifiers based on squad analysis.
Returns modifiers to apply to base probabilities.
squad_diff is in training scale (~-33 to +33), normalize to -1..+1.
"""
diff = prediction.squad_diff / 100 # -1 to +1
diff = prediction.squad_diff / 33.0 # training-scale normalisation
diff = max(-1.0, min(1.0, diff)) # clamp
return {
"home_modifier": 1.0 + (diff * 0.3), # Up to +/-30%
File diff suppressed because it is too large Load Diff
+243
View File
@@ -0,0 +1,243 @@
"""
V27 Rolling Window Feature Calculator
======================================
Computes rolling averages over 5/10/20 match windows,
with home/away splits and trend detection.
"""
from __future__ import annotations
from typing import Dict, List, Tuple
import math
def calc_rolling_features(
team_matches: List[Tuple], # [(mst, is_home, team_goals, opp_goals, opp_id), ...]
before_date: int,
team_is_home: bool,
) -> Dict[str, float]:
"""Calculate rolling window features for a team before a given date."""
valid = [m for m in team_matches if m[0] < before_date]
defaults = {
"rolling5_goals_avg": 1.3, "rolling5_conceded_avg": 1.2,
"rolling10_goals_avg": 1.3, "rolling10_conceded_avg": 1.2,
"rolling20_goals_avg": 1.3, "rolling20_conceded_avg": 1.2,
"rolling5_clean_sheets": 0.25,
"venue_goals_avg": 1.3, "venue_conceded_avg": 1.2,
"goal_trend": 0.0,
}
if len(valid) < 3:
return defaults
result = {}
for window in [5, 10, 20]:
recent = valid[-window:] if len(valid) >= window else valid
n = len(recent)
g_sum = sum(m[2] for m in recent)
c_sum = sum(m[3] for m in recent)
result[f"rolling{window}_goals_avg"] = g_sum / n
result[f"rolling{window}_conceded_avg"] = c_sum / n
# Clean sheet rate (last 5)
r5 = valid[-5:] if len(valid) >= 5 else valid
result["rolling5_clean_sheets"] = sum(1 for m in r5 if m[3] == 0) / len(r5)
# Venue-specific (home-only or away-only)
venue_matches = [m for m in valid if m[1] == team_is_home]
if venue_matches:
vm = venue_matches[-10:] if len(venue_matches) >= 10 else venue_matches
result["venue_goals_avg"] = sum(m[2] for m in vm) / len(vm)
result["venue_conceded_avg"] = sum(m[3] for m in vm) / len(vm)
else:
result["venue_goals_avg"] = defaults["venue_goals_avg"]
result["venue_conceded_avg"] = defaults["venue_conceded_avg"]
# Goal trend: compare last 3 vs previous 3
if len(valid) >= 6:
last3 = sum(m[2] for m in valid[-3:]) / 3
prev3 = sum(m[2] for m in valid[-6:-3]) / 3
result["goal_trend"] = last3 - prev3
else:
result["goal_trend"] = 0.0
return result
def calc_league_quality(
all_matches: List[Tuple], # all FT matches in this league
) -> Dict[str, float]:
"""Calculate league-level quality features."""
defaults = {
"league_home_win_rate": 0.45,
"league_draw_rate": 0.25,
"league_btts_rate": 0.50,
"league_ou25_rate": 0.50,
"league_reliability_score": 0.50,
}
if len(all_matches) < 20:
return defaults
n = len(all_matches)
home_wins = sum(1 for m in all_matches if m[2] > m[3])
draws = sum(1 for m in all_matches if m[2] == m[3])
btts = sum(1 for m in all_matches if m[2] > 0 and m[3] > 0)
ou25 = sum(1 for m in all_matches if (m[2] + m[3]) > 2.5)
hw_rate = home_wins / n
dr_rate = draws / n
btts_rate = btts / n
ou25_rate = ou25 / n
# Reliability: leagues closer to averages are more predictable
predictability = 1.0 - abs(hw_rate - 0.45) - abs(dr_rate - 0.27) * 0.5
reliability = max(0.2, min(0.95, predictability))
return {
"league_home_win_rate": round(hw_rate, 4),
"league_draw_rate": round(dr_rate, 4),
"league_btts_rate": round(btts_rate, 4),
"league_ou25_rate": round(ou25_rate, 4),
"league_reliability_score": round(reliability, 4),
}
def calc_time_features(
team_matches: List[Tuple],
match_mst: int,
) -> Dict[str, float]:
"""Calculate time-based features."""
from datetime import datetime
# Days since last match
valid = [m for m in team_matches if m[0] < match_mst]
if valid:
last_mst = valid[-1][0]
days_rest = (match_mst - last_mst) / 86_400_000 # ms to days
days_rest = min(days_rest, 60.0) # cap at 60 days
else:
days_rest = 14.0
# Month and season flags
try:
dt = datetime.utcfromtimestamp(match_mst / 1000)
month = dt.month
is_season_start = 1.0 if month in (7, 8) else 0.0
is_season_end = 1.0 if month in (5, 6) else 0.0
except Exception:
month = 6
is_season_start = 0.0
is_season_end = 0.0
return {
"days_rest": round(days_rest, 2),
"match_month": month,
"is_season_start": is_season_start,
"is_season_end": is_season_end,
}
def calc_advanced_h2h(
team_matches: List[Tuple],
home_id: int,
away_id: int,
before_date: int,
) -> Dict[str, float]:
"""Calculate advanced H2H features."""
defaults = {
"h2h_home_goals_avg": 1.3,
"h2h_away_goals_avg": 1.1,
"h2h_recent_trend": 0.0,
"h2h_venue_advantage": 0.0,
}
h2h = [m for m in team_matches if m[4] == away_id and m[0] < before_date]
if not h2h:
return defaults
recent = h2h[-10:]
home_goals_total = 0
away_goals_total = 0
venue_home_wins = 0
venue_total = 0
for mst, is_home, team_goals, opp_goals, _ in recent:
if is_home:
home_goals_total += team_goals
away_goals_total += opp_goals
venue_total += 1
if team_goals > opp_goals:
venue_home_wins += 1
else:
home_goals_total += opp_goals
away_goals_total += team_goals
n = len(recent)
result = {
"h2h_home_goals_avg": home_goals_total / n,
"h2h_away_goals_avg": away_goals_total / n,
"h2h_venue_advantage": venue_home_wins / venue_total if venue_total > 0 else 0.5,
}
# Recent trend: last 3 vs overall
if len(h2h) >= 4:
last3_pts = sum(
1.0 if m[2] > m[3] else (0.5 if m[2] == m[3] else 0.0)
for m in h2h[-3:]
) / 3
overall_pts = sum(
1.0 if m[2] > m[3] else (0.5 if m[2] == m[3] else 0.0)
for m in h2h
) / len(h2h)
result["h2h_recent_trend"] = round(last3_pts - overall_pts, 4)
else:
result["h2h_recent_trend"] = 0.0
return result
def calc_strength_diff(
home_form: Dict[str, float],
away_form: Dict[str, float],
home_elo: Dict[str, float],
away_elo: Dict[str, float],
home_momentum: float,
away_momentum: float,
upset_potential: float,
) -> Dict[str, float]:
"""Calculate strength differential features."""
# Attack vs Defense mismatches
h_attack = home_form.get("goals_avg", 1.3)
a_defense = away_form.get("conceded_avg", 1.2)
a_attack = away_form.get("goals_avg", 1.3)
h_defense = home_form.get("conceded_avg", 1.2)
atk_def_home = h_attack - a_defense # positive = home attack > away defense
atk_def_away = a_attack - h_defense
# XG diff approximation
xg_diff = (h_attack + a_defense) / 2 - (a_attack + h_defense) / 2
# Form × Momentum interaction
form_mom = (home_momentum - away_momentum) * (
home_form.get("scoring_rate", 0.75) - away_form.get("scoring_rate", 0.75)
)
# ELO-Form consistency
elo_diff = home_elo.get("overall", 1500) - away_elo.get("overall", 1500)
form_diff = h_attack - a_attack
elo_form_consistency = 1.0 if (elo_diff > 0 and form_diff > 0) or (elo_diff < 0 and form_diff < 0) else 0.0
# Upset × ELO gap
elo_gap = abs(elo_diff)
upset_x_elo = upset_potential * (elo_gap / 400.0)
return {
"attack_vs_defense_home": round(atk_def_home, 4),
"attack_vs_defense_away": round(atk_def_away, 4),
"xg_diff": round(xg_diff, 4),
"form_momentum_interaction": round(form_mom, 4),
"elo_form_consistency": elo_form_consistency,
"upset_x_elo_gap": round(upset_x_elo, 4),
}
+32 -17
View File
@@ -14,9 +14,13 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from models.basketball_v25 import get_basketball_v25_predictor
try:
from models.basketball_v25 import get_basketball_v25_predictor
HAS_BASKETBALL = True
except ImportError:
HAS_BASKETBALL = False
from services.single_match_orchestrator import get_single_match_orchestrator
from data.database import dispose_engine
from services.v26_shadow_engine import get_v26_shadow_engine
load_dotenv()
@@ -36,9 +40,10 @@ class CouponRequest(BaseModel):
@asynccontextmanager
async def lifespan(_: FastAPI):
try:
print("🚀 Initializing V25 orchestrator...", flush=True)
print("🚀 Initializing V28 orchestrator...", flush=True)
get_single_match_orchestrator()
print("✅ V25 orchestrator ready", flush=True)
get_v26_shadow_engine()
print("✅ V28 orchestrator ready", flush=True)
except Exception as error:
print(f"❌ Failed to initialize orchestrator: {error}", flush=True)
import traceback
@@ -47,14 +52,11 @@ async def lifespan(_: FastAPI):
yield
# Cleanup async DB connections on shutdown
await dispose_engine()
app = FastAPI(
title="Suggest-Bet AI Engine",
version="25.0.0",
description="V25 Single Match Prediction Package API",
version="28.0.0",
description="V28 Single Match Prediction Package API",
lifespan=lifespan,
)
@@ -102,8 +104,9 @@ async def global_exception_handler(_: Request, exc: Exception):
@app.get("/")
def read_root() -> dict[str, Any]:
return {
"status": "Suggest-Bet AI Engine v25",
"engine": "V25 Single Match Orchestrator",
"status": "Suggest-Bet AI Engine v28",
"engine": "V28 Single Match Orchestrator",
"mode": os.getenv("AI_ENGINE_MODE", "v28"),
"routes": [
"POST /v20plus/analyze/{match_id}",
"GET /v20plus/analyze-htms/{match_id}",
@@ -118,15 +121,27 @@ def read_root() -> dict[str, Any]:
@app.get("/health")
def health_check() -> dict[str, Any]:
try:
get_single_match_orchestrator()
basketball_predictor = get_basketball_v25_predictor()
basketball_readiness = basketball_predictor.readiness_summary()
ready = bool(basketball_readiness["fully_loaded"])
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.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": "v25.main",
"engine": "v28.main",
"mode": os.getenv("AI_ENGINE_MODE", "v28"),
"ready": ready,
"basketball_v25": basketball_readiness,
"v26_shadow": shadow_engine.readiness_summary(),
"prediction_service_ready": True,
"model_loaded": ready,
"orchestrator_mode": getattr(orchestrator, "engine_mode", "v28"),
}
except Exception as error:
return {"status": "unhealthy", "ready": False, "error": str(error)}
@@ -196,7 +211,7 @@ async def analyze_match_htft_v20plus(match_id: str, timeout_sec: int = 30) -> di
key=lambda item: float(item[1]),
)
return {
"engine": "v25.main",
"engine": "v28.main",
"match_info": result.get("match_info", {}),
"timing_ms": int((time.time() - started_at) * 1000),
"ht_ft_probs": htft_probs,
+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
@@ -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
}
}
}
}
@@ -0,0 +1,19 @@
{
"version": "v26.shadow.0",
"calibration_version": "v26.shadow.calib.0",
"train_rows": 6853,
"validation_rows": 1469,
"label_priors": {
"MS": 0.4404,
"OU25": 0.5214,
"BTTS": 0.5398,
"HT": 0.4275,
"HTFT": 0.26,
"CARDS": 0.6052
},
"artifact_path": "/Users/piton/Documents/GitHub/iddaai/iddaai-be/ai-engine/models/v26_shadow/market_profiles.json",
"notes": [
"v26.shadow runtime currently uses artifact-based calibration and ROI gating",
"market profile JSON remains the source of truth for runtime thresholds"
]
}
+1
View File
@@ -17,3 +17,4 @@ pyyaml>=6.0
# V2 async database
asyncpg>=0.29.0
pydantic>=2.5.0
pytest>=8.0.0
-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()
-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()
-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()
@@ -0,0 +1,312 @@
"""
V28 — CONDITIONAL FREQUENCY ENGINE
====================================
User's strategy automated at scale:
For every match (e.g. Beşiktaş vs Konya):
1. Look at Beşiktaş's HOME history when their MS1 odds were in the same band (e.g. 1.30-1.40)
→ What % of those matches ended OU 1.5 over? OU 2.5 over? MS1?
2. Look at Konya's AWAY history when their MS2 odds were in the same band (e.g. 2.00-2.20)
→ Same questions
3. COMBINE both signals:
→ If BOTH teams historically produce >80% OU1.5 over at these odds → BET OU1.5 over
→ This is the user's exact Excel strategy, now running on 104K matches
CRITICAL: Only uses PAST matches for each prediction (no future leakage)
"""
import pandas as pd
import numpy as np
from collections import defaultdict
import warnings
warnings.filterwarnings('ignore')
# ─── Load Data ───
print("Loading data...")
df = pd.read_csv('data/training_data_v27.csv', low_memory=False)
KEEP_STR = ['match_id', 'league_name', 'home_team', 'away_team',
'home_team_id', 'away_team_id', 'league_id', 'mst_utc']
for c in df.columns:
if c not in KEEP_STR:
df[c] = pd.to_numeric(df[c], errors='coerce')
# Ensure chronological order (by match_id or date)
if 'mst_utc' in df.columns:
df['mst_utc'] = pd.to_datetime(df['mst_utc'], errors='coerce')
df = df.sort_values('mst_utc').reset_index(drop=True)
# Filter: need valid odds + scores
df = df.dropna(subset=['odds_ms_h', 'odds_ms_a', 'score_home', 'score_away',
'home_team_id', 'away_team_id', 'label_ms'])
# Compute actual goal labels
df['total_goals'] = df['score_home'] + df['score_away']
df['ou15_actual'] = (df['total_goals'] > 1.5).astype(int)
df['ou25_actual'] = (df['total_goals'] > 2.5).astype(int)
df['ou35_actual'] = (df['total_goals'] > 3.5).astype(int)
df['btts_actual'] = ((df['score_home'] > 0) & (df['score_away'] > 0)).astype(int)
df['ms_result'] = df['label_ms'].astype(int) # 0=H, 1=D, 2=A
N = len(df)
print(f"Total matches: {N}")
print(f"Unique home teams: {df.home_team_id.nunique()}")
print(f"Unique away teams: {df.away_team_id.nunique()}")
# ─── Odds Band Helper ───
def get_odds_band(odds, band_width=0.10):
"""Round odds to nearest band. E.g. 1.35 → (1.30, 1.40)"""
lower = round(np.floor(odds / band_width) * band_width, 2)
upper = round(lower + band_width, 2)
return (lower, upper)
def get_odds_band_wide(odds):
"""Wider band for less common teams. E.g. 1.35 → (1.20, 1.50)"""
if odds < 1.50:
return (1.01, 1.50)
elif odds < 2.00:
return (1.50, 2.00)
elif odds < 2.50:
return (2.00, 2.50)
elif odds < 3.00:
return (2.50, 3.00)
elif odds < 4.00:
return (3.00, 4.00)
elif odds < 6.00:
return (4.00, 6.00)
else:
return (6.00, 20.00)
# ─── Build Conditional Frequency Lookup (Expanding Window) ───
print("\nBuilding conditional frequency features (expanding window)...")
# We'll compute features for each match using only past data
MIN_MATCHES = 5 # minimum historical matches to generate a signal
# Pre-allocate feature arrays
feat_names = [
'home_ou15_rate_at_band', 'home_ou25_rate_at_band', 'home_ou35_rate_at_band',
'home_btts_rate_at_band', 'home_win_rate_at_band', 'home_n_at_band',
'away_ou15_rate_at_band', 'away_ou25_rate_at_band', 'away_ou35_rate_at_band',
'away_btts_rate_at_band', 'away_win_rate_at_band', 'away_n_at_band',
'combined_ou15', 'combined_ou25', 'combined_ou35', 'combined_btts',
'home_goals_at_band', 'away_goals_at_band', 'combined_goals_at_band',
'home_conceded_at_band', 'away_conceded_at_band',
]
features = np.full((N, len(feat_names)), np.nan)
# Historical ledger: team_id → list of (odds_band, ou15, ou25, ou35, btts, ms_result, goals_scored, goals_conceded)
home_history = defaultdict(list) # team performances when playing HOME
away_history = defaultdict(list) # team performances when playing AWAY
for i in range(N):
row = df.iloc[i]
ht_id = row.home_team_id
at_id = row.away_team_id
h_odds = row.odds_ms_h
a_odds = row.odds_ms_a
if pd.isna(h_odds) or pd.isna(a_odds):
continue
h_band = get_odds_band_wide(h_odds)
a_band = get_odds_band_wide(a_odds)
# ── Look up HOME team's historical performance at this odds band ──
h_hist = [x for x in home_history[ht_id] if h_band[0] <= x[0] < h_band[1]]
if len(h_hist) >= MIN_MATCHES:
features[i, 0] = np.mean([x[1] for x in h_hist]) # ou15 rate
features[i, 1] = np.mean([x[2] for x in h_hist]) # ou25 rate
features[i, 2] = np.mean([x[3] for x in h_hist]) # ou35 rate
features[i, 3] = np.mean([x[4] for x in h_hist]) # btts rate
features[i, 4] = np.mean([x[5] for x in h_hist]) # win rate (home win = 1 if ms==0)
features[i, 5] = len(h_hist)
features[i, 16] = np.mean([x[6] for x in h_hist]) # avg goals scored
features[i, 19] = np.mean([x[7] for x in h_hist]) # avg goals conceded
# ── Look up AWAY team's historical performance at this odds band ──
a_hist = [x for x in away_history[at_id] if a_band[0] <= x[0] < a_band[1]]
if len(a_hist) >= MIN_MATCHES:
features[i, 6] = np.mean([x[1] for x in a_hist]) # ou15 rate
features[i, 7] = np.mean([x[2] for x in a_hist]) # ou25 rate
features[i, 8] = np.mean([x[3] for x in a_hist]) # ou35 rate
features[i, 9] = np.mean([x[4] for x in a_hist]) # btts rate
features[i, 10] = np.mean([x[5] for x in a_hist]) # away win rate
features[i, 11] = len(a_hist)
features[i, 17] = np.mean([x[6] for x in a_hist]) # avg goals scored (away)
features[i, 20] = np.mean([x[7] for x in a_hist]) # avg goals conceded (away)
# ── Combined signals ──
if not np.isnan(features[i, 0]) and not np.isnan(features[i, 6]):
features[i, 12] = (features[i, 0] + features[i, 6]) / 2 # combined ou15
features[i, 13] = (features[i, 1] + features[i, 7]) / 2 # combined ou25
features[i, 14] = (features[i, 2] + features[i, 8]) / 2 # combined ou35
features[i, 15] = (features[i, 3] + features[i, 9]) / 2 # combined btts
features[i, 18] = features[i, 16] + features[i, 17] # combined goals
# ── Add THIS match to history (for future lookups) ──
ou15 = int(row.total_goals > 1.5)
ou25 = int(row.total_goals > 2.5)
ou35 = int(row.total_goals > 3.5)
btts = int(row.score_home > 0 and row.score_away > 0)
h_won = int(row.label_ms == 0)
a_won = int(row.label_ms == 2)
home_history[ht_id].append((h_odds, ou15, ou25, ou35, btts, h_won,
row.score_home, row.score_away))
away_history[at_id].append((a_odds, ou15, ou25, ou35, btts, a_won,
row.score_away, row.score_home))
if (i+1) % 20000 == 0:
valid = np.sum(~np.isnan(features[:i+1, 12]))
print(f" Processed {i+1}/{N} matches, {valid} with combined signals")
# Count valid features
valid_mask = ~np.isnan(features[:, 12])
print(f"\nMatches with combined conditional signals: {valid_mask.sum()} / {N}")
# ─── BACKTEST: Walk-Forward ───
print("\n" + "="*70)
print(" CONDITIONAL FREQUENCY BACKTEST")
print("="*70)
# Only test on last 20% of data (to avoid early sparse data)
test_start = int(N * 0.7)
test_idx = range(test_start, N)
test_valid = [i for i in test_idx if valid_mask[i]]
print(f"Test window: matches {test_start}-{N} ({len(test_valid)} with signals)")
# Strategy: bet on OU1.5 over when combined_ou15 > threshold
markets = [
('OU 1.5 Over', 'combined_ou15', 12, 'ou15_actual', 'odds_ou15_o'),
('OU 2.5 Over', 'combined_ou25', 13, 'ou25_actual', 'odds_ou25_o'),
('OU 3.5 Over', 'combined_ou35', 14, 'ou35_actual', 'odds_ou35_o'),
('BTTS Yes', 'combined_btts', 15, 'btts_actual', 'odds_btts_y'),
]
for market_name, feat_key, feat_idx, label_col, odds_col in markets:
print(f"\n ── {market_name} ──")
if odds_col not in df.columns:
print(f" No odds column '{odds_col}', skipping")
continue
for threshold in [0.60, 0.65, 0.70, 0.75, 0.80, 0.85, 0.90]:
bets = 0
wins = 0
pnl = 0.0
for i in test_valid:
signal = features[i, feat_idx]
if np.isnan(signal) or signal < threshold:
continue
odds_val = df.iloc[i][odds_col]
if pd.isna(odds_val) or odds_val < 1.05:
continue
actual = df.iloc[i][label_col]
if pd.isna(actual):
continue
bets += 1
if actual == 1:
wins += 1
pnl += odds_val - 1
else:
pnl -= 1
if bets >= 20:
roi = pnl / bets * 100
hit = wins / bets * 100
ev = (wins/bets) * (pnl/wins + 1) if wins > 0 else 0
marker = " *** PROFITABLE ***" if roi > 0 else ""
print(f" Threshold>{threshold:.2f}: {bets:5d} bets, "
f"hit={hit:.1f}%, ROI={roi:+.1f}%{marker}")
# Also test MS (1X2) market
print(f"\n ── Maç Sonucu (1X2) ──")
# Home win when home_win_rate_at_band > X AND away team loses often at that band
for threshold in [0.50, 0.55, 0.60, 0.65, 0.70, 0.75, 0.80]:
bets = wins = 0
pnl = 0.0
for i in test_valid:
h_wr = features[i, 4] # home win rate at band
a_lr = 1 - features[i, 10] if not np.isnan(features[i, 10]) else np.nan # away loss rate
if np.isnan(h_wr) or np.isnan(a_lr):
continue
combined = (h_wr + a_lr) / 2
if combined < threshold:
continue
odds_val = df.iloc[i].odds_ms_h
if pd.isna(odds_val) or odds_val < 1.10 or odds_val > 5.0:
continue
bets += 1
if df.iloc[i].label_ms == 0:
wins += 1
pnl += odds_val - 1
else:
pnl -= 1
if bets >= 20:
roi = pnl / bets * 100
hit = wins / bets * 100
marker = " *** PROFITABLE ***" if roi > 0 else ""
print(f" Home win comb>{threshold:.2f}: {bets:5d} bets, "
f"hit={hit:.1f}%, ROI={roi:+.1f}%{marker}")
# ─── DEEP DIVE: Best performing niches ───
print("\n" + "="*70)
print(" DEEP DIVE: Combined OU15 + Odds Value Filter")
print("="*70)
# The user's strategy: high confidence + the odds must pay enough
for threshold in [0.75, 0.80, 0.85, 0.90]:
for min_odds in [1.10, 1.20, 1.30, 1.40]:
bets = wins = 0
pnl = 0.0
for i in test_valid:
signal = features[i, 12] # combined ou15
if np.isnan(signal) or signal < threshold:
continue
odds_val = df.iloc[i].get('odds_ou15_o', np.nan) if 'odds_ou15_o' in df.columns else np.nan
if pd.isna(odds_val) or odds_val < min_odds:
continue
actual = df.iloc[i].ou15_actual
bets += 1
if actual == 1:
wins += 1
pnl += odds_val - 1
else:
pnl -= 1
if bets >= 30:
roi = pnl / bets * 100
hit = wins / bets * 100
if roi > -5: # show near-profitable too
marker = " *** PROFITABLE ***" if roi > 0 else ""
print(f" OU15 sig>{threshold:.2f} odds>{min_odds}: "
f"{bets:5d} bets, hit={hit:.1f}%, ROI={roi:+.1f}%{marker}")
# ─── Additional: Goal expectation accuracy ───
print("\n" + "="*70)
print(" GOAL PREDICTION ACCURACY")
print("="*70)
valid_goals = [i for i in test_valid if not np.isnan(features[i, 18])]
if valid_goals:
pred_goals = [features[i, 18] for i in valid_goals]
actual_goals = [df.iloc[i].total_goals for i in valid_goals]
from sklearn.metrics import mean_absolute_error
mae = mean_absolute_error(actual_goals, pred_goals)
corr = np.corrcoef(pred_goals, actual_goals)[0, 1]
print(f" Combined goal prediction MAE: {mae:.3f}")
print(f" Correlation: {corr:.4f}")
print(f" Avg predicted: {np.mean(pred_goals):.2f}, Avg actual: {np.mean(actual_goals):.2f}")
# Bucket analysis
print("\n Goal prediction buckets:")
for low, high in [(0, 1.5), (1.5, 2.0), (2.0, 2.5), (2.5, 3.0), (3.0, 3.5), (3.5, 5.0)]:
bucket = [i for i, pg in zip(valid_goals, pred_goals) if low <= pg < high]
if len(bucket) >= 20:
avg_actual = np.mean([df.iloc[i].total_goals for i in bucket])
ou25_rate = np.mean([df.iloc[i].ou25_actual for i in bucket])
print(f" Predicted {low:.1f}-{high:.1f}: n={len(bucket)}, "
f"actual_avg={avg_actual:.2f}, OU25%={ou25_rate*100:.1f}%")
print("\nDone!")
+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()
+268 -38
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
@@ -119,6 +120,14 @@ FEATURE_COLS = [
"home_key_players", "away_key_players",
"home_missing_impact", "away_missing_impact",
"home_goals_form", "away_goals_form",
# Player-Level Features (12)
"home_lineup_goals_per90", "away_lineup_goals_per90",
"home_lineup_assists_per90", "away_lineup_assists_per90",
"home_squad_continuity", "away_squad_continuity",
"home_top_scorer_form", "away_top_scorer_form",
"home_avg_player_exp", "away_avg_player_exp",
"home_goals_diversity", "away_goals_diversity",
# Labels
"score_home", "score_away", "total_goals",
@@ -336,7 +345,7 @@ class BatchDataLoader:
self.team_stats[tid].append((mst, poss, sot, tshots, corn, team_goals))
def _load_squad_data(self):
"""Bulk load squad participation + player events for squad features."""
"""Bulk load squad participation + player events + player career for squad features."""
ph = ",".join(["%s"] * len(self.top_league_ids))
# 1) Participation: starting XI count + position distribution per (match, team)
@@ -424,12 +433,99 @@ class BatchDataLoader:
for mid, tid, pid in self.cur.fetchall():
starting_players[(mid, tid)].append(pid)
# 5) Build combined cache
# 5) Build match_id → mst_utc mapping for temporal filtering
match_mst = {}
for m in self.matches:
match_mst[m[0]] = m[7] # m[0]=id, m[7]=mst_utc
# ─── NEW: Player Career Stats (prefix-sum for O(1) temporal lookup) ───
# 6a) Goals per player per match date
self.cur.execute(f"""
SELECT mpe.player_id, m.mst_utc,
SUM(CASE WHEN mpe.event_type = 'goal'
AND COALESCE(mpe.event_subtype, '') NOT ILIKE '%%penaltı kaçırma%%'
THEN 1 ELSE 0 END) AS goals
FROM match_player_events mpe
JOIN matches m ON mpe.match_id = m.id
WHERE m.status = 'FT' AND m.sport = 'football' AND m.league_id IN ({ph})
GROUP BY mpe.player_id, m.mst_utc
""", self.top_league_ids)
player_goals_raw = defaultdict(dict)
for pid, mst, goals in self.cur.fetchall():
player_goals_raw[pid][mst] = (player_goals_raw[pid].get(mst, 0)) + (goals or 0)
# 6b) Assists per player per match date
self.cur.execute(f"""
SELECT mpe.assist_player_id, m.mst_utc, COUNT(*) AS assists
FROM match_player_events mpe
JOIN matches m ON mpe.match_id = m.id
WHERE m.status = 'FT' AND m.sport = 'football' AND m.league_id IN ({ph})
AND mpe.event_type = 'goal' AND mpe.assist_player_id IS NOT NULL
GROUP BY mpe.assist_player_id, m.mst_utc
""", self.top_league_ids)
player_assists_raw = defaultdict(dict)
for pid, mst, assists in self.cur.fetchall():
player_assists_raw[pid][mst] = (player_assists_raw[pid].get(mst, 0)) + (assists or 0)
# 6c) Player participation dates (starts only)
self.cur.execute(f"""
SELECT mpp.player_id, m.mst_utc
FROM match_player_participation mpp
JOIN matches m ON mpp.match_id = m.id
WHERE mpp.is_starting = true
AND m.status = 'FT' AND m.sport = 'football' AND m.league_id IN ({ph})
ORDER BY mpp.player_id, m.mst_utc
""", self.top_league_ids)
player_starts_raw = defaultdict(list)
for pid, mst in self.cur.fetchall():
player_starts_raw[pid].append(mst)
# 6d) Build prefix sums per player (goals_prefix[i] = total goals up to start i)
player_career = {}
all_pids = set(player_starts_raw.keys()) | set(player_goals_raw.keys()) | set(player_assists_raw.keys())
for pid in all_pids:
starts = sorted(set(player_starts_raw.get(pid, [])))
if not starts:
continue
g_map = player_goals_raw.get(pid, {})
a_map = player_assists_raw.get(pid, {})
cum_g, cum_a = 0, 0
goals_pf, assists_pf = [], []
for mst in starts:
cum_g += g_map.get(mst, 0)
cum_a += a_map.get(mst, 0)
goals_pf.append(cum_g)
assists_pf.append(cum_a)
player_career[pid] = {'msts': starts, 'gp': goals_pf, 'ap': assists_pf}
# Free raw dicts
del player_goals_raw, player_assists_raw, player_starts_raw
print(f" 📊 Player careers built: {len(player_career)} players", flush=True)
# ─── NEW: Team Lineup History (for squad continuity) ───
# 7) Per-team sorted lineups: [(mst, frozenset(player_ids))]
team_lineup_map = defaultdict(list)
for (mid, tid), pids in starting_players.items():
mst = match_mst.get(mid, 0)
if mst > 0 and pids:
team_lineup_map[tid].append((mst, frozenset(pids)))
team_lineup_history = {}
team_lineup_msts = {}
for tid, ll in team_lineup_map.items():
ll.sort(key=lambda x: x[0])
team_lineup_history[tid] = ll
team_lineup_msts[tid] = [x[0] for x in ll]
del team_lineup_map
# ─── 8) Build combined cache — NO DATA LEAKAGE ───
all_keys = set(participation.keys()) | set(events.keys())
for key in all_keys:
mid, tid = key
part = participation.get(key, {'starting_count': 0, 'total_squad': 0, 'fwd_count': 0})
evt = events.get(key, {'goals': 0, 'assists': 0, 'unique_scorers': 0})
# Count key players in starting XI
starters = starting_players.get(key, [])
@@ -437,22 +533,78 @@ class BatchDataLoader:
kp_total = len(key_players_by_team.get(tid, set()))
kp_missing = max(0, kp_total - kp_in_starting)
# Squad quality: composite score
# Squad quality: composite score — ONLY pre-match info
squad_quality = (
part['starting_count'] * 0.3 +
evt['goals'] * 2.0 +
evt['assists'] * 1.0 +
kp_in_starting * 3.0 +
part['fwd_count'] * 1.5
)
# Missing impact: how many key players are missing
missing_impact = min(kp_missing / max(kp_total, 1), 1.0)
# goals_form: avg goals from last 5 matches BEFORE this match
current_mst = match_mst.get(mid, 0)
team_history = self.team_matches.get(tid, [])
recent_goals = [
tm[2] for tm in team_history if tm[0] < current_mst
][-5:]
goals_form = sum(recent_goals) / len(recent_goals) if recent_goals else 1.3
# ─── NEW: Player-level aggregation for starting XI ───
lineup_g90, lineup_a90, total_exp = 0.0, 0.0, 0
best_scorer_total, best_scorer_id = 0, None
scorers_in_lineup = 0
for pid in starters:
pc = player_career.get(pid)
if not pc:
continue
idx = bisect.bisect_left(pc['msts'], current_mst)
if idx == 0:
continue # no prior matches for this player
prior_starts = idx
prior_goals = pc['gp'][idx - 1]
prior_assists = pc['ap'][idx - 1]
lineup_g90 += prior_goals / prior_starts
lineup_a90 += prior_assists / prior_starts
total_exp += prior_starts
if prior_goals > 0:
scorers_in_lineup += 1
if prior_goals > best_scorer_total:
best_scorer_total = prior_goals
best_scorer_id = pid
n_st = len(starters) or 1
# Top scorer recent form (goals in last 5 starts)
top_scorer_form = 0
if best_scorer_id:
pc = player_career.get(best_scorer_id)
if pc:
idx = bisect.bisect_left(pc['msts'], current_mst)
if idx > 0:
s5 = max(0, idx - 5)
top_scorer_form = pc['gp'][idx - 1] - (pc['gp'][s5 - 1] if s5 > 0 else 0)
# Squad continuity (overlap with previous match lineup)
squad_continuity = 0.5
msts_list = team_lineup_msts.get(tid)
if msts_list:
li = bisect.bisect_left(msts_list, current_mst)
if li > 0:
prev_lineup = team_lineup_history[tid][li - 1][1]
squad_continuity = len(frozenset(starters) & prev_lineup) / n_st
self.squad_cache[key] = {
'squad_quality': squad_quality,
'key_players': kp_in_starting,
'missing_impact': missing_impact,
'goals_form': evt['goals'],
'goals_form': round(goals_form, 2),
'lineup_goals_per90': round(lineup_g90, 3),
'lineup_assists_per90': round(lineup_a90, 3),
'squad_continuity': round(squad_continuity, 3),
'top_scorer_form': top_scorer_form,
'avg_player_exp': round(total_exp / n_st, 1),
'goals_diversity': round(scorers_in_lineup / n_st, 3),
}
def _load_cards_data(self):
@@ -496,16 +648,24 @@ class FeatureExtractor:
self.referee_engine = get_referee_engine()
self.momentum_engine = get_momentum_engine()
# ── Data Quality Thresholds ──
# Matches below these thresholds produce default-only features that
# teach the model noise rather than signal.
DQ_MIN_FORM_MATCHES = 3 # team must have ≥3 prior matches
DQ_MIN_FEATURE_COVERAGE = 0.30 # ≥30% of key features must be non-default
def extract_all(self) -> list:
"""Extract features for all matches, yield row dicts."""
"""Extract features for all matches with data quality validation."""
matches = self.loader.matches
total = len(matches)
rows = []
skipped = 0
dq_rejected = 0
dq_reasons: dict = defaultdict(int)
t_start = time.time()
print(f"\n🔄 Extracting features for {total} matches...", flush=True)
# Process chronologically — ELO grows as we go
for i, m in enumerate(matches):
(
@@ -522,38 +682,43 @@ class FeatureExtractor:
away_name,
league_name,
) = m
if i % 100 == 0 and i > 0:
elapsed = time.time() - t_start
rate = i / elapsed # matches per second
remaining = (total - i) / rate if rate > 0 else 0
pct = i / total * 100
print(f" [{i}/{total}] ({pct:.0f}%) | {rate:.1f} maç/s | ETA: {remaining/60:.1f} dk | skipped: {skipped}", flush=True)
print(
f" [{i}/{total}] ({pct:.0f}%) | {rate:.1f} maç/s | "
f"ETA: {remaining/60:.1f} dk | skipped: {skipped} | "
f"dq_rejected: {dq_rejected}",
flush=True,
)
row = self._extract_one(
mid,
hid,
aid,
sh,
sa,
hth,
hta,
mst,
lid,
home_name,
away_name,
league_name,
mid, hid, aid, sh, sa, hth, hta, mst, lid,
home_name, away_name, league_name,
)
if row:
rows.append(row)
# ── Data Quality Gate ──
dq_pass, reason = self._validate_row_quality(row, hid, aid, mst)
if dq_pass:
rows.append(row)
else:
dq_rejected += 1
dq_reasons[reason] += 1
else:
skipped += 1
# Update ELO after processing (so ELO is calculated BEFORE the match)
self._update_elo(hid, aid, sh, sa)
print(f" ✅ Extracted {len(rows)} rows, skipped {skipped}", flush=True)
print(f" ✅ Extracted {len(rows)} rows, skipped {skipped}, DQ rejected {dq_rejected}", flush=True)
if dq_reasons:
print(f" 📊 DQ Rejection reasons:")
for reason, count in sorted(dq_reasons.items(), key=lambda x: -x[1]):
print(f" {reason}: {count}")
return rows
def _extract_one(
@@ -828,6 +993,20 @@ class FeatureExtractor:
"away_missing_impact": away_missing_impact,
"home_goals_form": home_goals_form,
"away_goals_form": away_goals_form,
# Player-Level Features
"home_lineup_goals_per90": home_sq.get('lineup_goals_per90', 0.0),
"away_lineup_goals_per90": away_sq.get('lineup_goals_per90', 0.0),
"home_lineup_assists_per90": home_sq.get('lineup_assists_per90', 0.0),
"away_lineup_assists_per90": away_sq.get('lineup_assists_per90', 0.0),
"home_squad_continuity": home_sq.get('squad_continuity', 0.5),
"away_squad_continuity": away_sq.get('squad_continuity', 0.5),
"home_top_scorer_form": home_sq.get('top_scorer_form', 0),
"away_top_scorer_form": away_sq.get('top_scorer_form', 0),
"home_avg_player_exp": home_sq.get('avg_player_exp', 0.0),
"away_avg_player_exp": away_sq.get('avg_player_exp', 0.0),
"home_goals_diversity": home_sq.get('goals_diversity', 0.0),
"away_goals_diversity": away_sq.get('goals_diversity', 0.0),
# Labels
"score_home": sh,
@@ -853,7 +1032,58 @@ class FeatureExtractor:
}
return row
def _validate_row_quality(
self,
row: dict,
home_id: str,
away_id: str,
before_date: int,
) -> tuple:
"""
Data quality gate for training rows.
Ensures the feature vector has enough real signal to be useful for
training. Rejects rows where critical features are all at their
default/fallback values — these teach the model noise, not patterns.
Returns (pass: bool, reason: str | None).
"""
# 1. Minimum form history: both teams must have enough prior matches
home_history = self.loader.team_matches.get(home_id, [])
away_history = self.loader.team_matches.get(away_id, [])
home_prior = sum(1 for m in home_history if m[0] < before_date)
away_prior = sum(1 for m in away_history if m[0] < before_date)
if home_prior < self.DQ_MIN_FORM_MATCHES:
return False, 'home_insufficient_history'
if away_prior < self.DQ_MIN_FORM_MATCHES:
return False, 'away_insufficient_history'
# 2. Feature coverage check: count how many key features are non-default
key_features = [
('home_goals_avg', 1.3),
('away_goals_avg', 1.3),
('home_clean_sheet_rate', 0.25),
('away_clean_sheet_rate', 0.25),
('home_avg_possession', 0.50),
('away_avg_possession', 0.50),
('home_avg_shots_on_target', 3.5),
('away_avg_shots_on_target', 3.5),
('h2h_total_matches', 0),
('odds_ms_h', 0.0),
]
non_default = sum(
1 for feat_name, default_val in key_features
if abs(float(row.get(feat_name, default_val)) - default_val) > 0.01
)
coverage = non_default / len(key_features)
if coverage < self.DQ_MIN_FEATURE_COVERAGE:
return False, f'low_feature_coverage_{coverage:.0%}'
return True, None
# -------------------------------------------------------------------------
# ELO (simplified inline version — doesn't need DB, grows incrementally)
# -------------------------------------------------------------------------
@@ -1071,13 +1301,13 @@ class FeatureExtractor:
for mst, poss, sot, total_shots, corners, team_goals in rows:
if poss and poss > 0:
poss_sum += poss
poss_sum += float(poss)
poss_count += 1
sot_sum += sot or 0
shots_sum += total_shots or 0
corners_sum += corners or 0
sot_sum += float(sot or 0)
shots_sum += float(total_shots or 0)
corners_sum += float(corners or 0)
goals_scored += team_goals or 0
goals_scored += float(team_goals or 0)
return {
"possession": (poss_sum / poss_count / 100) if poss_count > 0 else 0.50,
@@ -0,0 +1,93 @@
from __future__ import annotations
import json
from pathlib import Path
import pandas as pd
AI_ENGINE_DIR = Path(__file__).resolve().parents[1]
SOURCE_CSV = AI_ENGINE_DIR / "data" / "training_data.csv"
TARGET_DIR = AI_ENGINE_DIR / "data" / "v26_shadow"
TARGET_DIR.mkdir(parents=True, exist_ok=True)
def _rolling_windows(frame: pd.DataFrame) -> list[dict[str, int]]:
ordered = frame.sort_values("mst_utc").reset_index(drop=True)
windows: list[dict[str, int]] = []
if ordered.empty:
return windows
size = len(ordered)
cuts = [0.55, 0.7, 0.85]
for idx, cut in enumerate(cuts, start=1):
end_ix = max(int(size * cut), 1)
test_end = min(size - 1, end_ix + max(int(size * 0.10), 1))
windows.append(
{
"window": idx,
"train_end_ix": end_ix - 1,
"test_start_ix": end_ix,
"test_end_ix": test_end,
"train_end_mst_utc": int(ordered.iloc[end_ix - 1]["mst_utc"]),
"test_end_mst_utc": int(ordered.iloc[test_end]["mst_utc"]),
}
)
return windows
def main() -> None:
if not SOURCE_CSV.exists():
raise SystemExit(f"Missing source CSV: {SOURCE_CSV}")
frame = pd.read_csv(SOURCE_CSV)
if "mst_utc" not in frame.columns:
raise SystemExit("training_data.csv must include mst_utc")
ordered = frame.sort_values("mst_utc").reset_index(drop=True)
ordered["lineup_completeness"] = 1.0
ordered["referee_available"] = (
ordered.get("referee_experience", pd.Series([0] * len(ordered))).fillna(0) > 0
).astype(float)
ordered["league_reliability"] = ordered.get("league_zero_goal_rate", 0).fillna(0).apply(
lambda value: round(max(0.25, min(0.95, 0.85 - float(value))), 4)
)
ordered["odds_snapshot_freshness"] = 1.0
train_end = max(int(len(ordered) * 0.70), 1)
validation_end = max(int(len(ordered) * 0.85), train_end + 1)
validation_end = min(validation_end, len(ordered) - 1)
train_df = ordered.iloc[:train_end].copy()
validation_df = ordered.iloc[train_end:validation_end].copy()
holdout_df = ordered.iloc[validation_end:].copy()
train_df.to_csv(TARGET_DIR / "train.csv", index=False)
validation_df.to_csv(TARGET_DIR / "validation.csv", index=False)
holdout_df.to_csv(TARGET_DIR / "holdout.csv", index=False)
meta = {
"source": str(SOURCE_CSV),
"rows": int(len(ordered)),
"train_rows": int(len(train_df)),
"validation_rows": int(len(validation_df)),
"holdout_rows": int(len(holdout_df)),
"rolling_windows": _rolling_windows(ordered),
"derived_columns": [
"lineup_completeness",
"referee_available",
"league_reliability",
"odds_snapshot_freshness",
],
"feature_policy": "prediction_time_only",
}
(TARGET_DIR / "dataset_meta.json").write_text(
json.dumps(meta, indent=2),
encoding="utf-8",
)
print(f"[OK] V26 dataset written to {TARGET_DIR}")
if __name__ == "__main__":
main()
@@ -0,0 +1,305 @@
"""
V27 Training Data Extraction - Value Sniper
Extends V25 to ALL matches with odds (~104K).
Adds rolling window, league quality, time, H2H, strength features.
Usage: python3 scripts/extract_training_data_v27.py
"""
import os, sys, csv, time
from collections import defaultdict
AI_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, AI_DIR)
from scripts.extract_training_data import (
BatchDataLoader as V25Loader,
FeatureExtractor as V25Extractor,
FEATURE_COLS as V25_COLS,
get_conn,
)
from features.rolling_features import (
calc_rolling_features, calc_league_quality,
calc_time_features, calc_advanced_h2h, calc_strength_diff,
)
OUTPUT = os.path.join(AI_DIR, "data", "training_data_v27.csv")
os.makedirs(os.path.dirname(OUTPUT), exist_ok=True)
V27_NEW = [
"home_rolling5_goals","home_rolling5_conceded",
"home_rolling10_goals","home_rolling10_conceded",
"home_rolling20_goals","home_rolling20_conceded",
"away_rolling5_goals","away_rolling5_conceded",
"away_rolling10_goals","away_rolling10_conceded",
"home_rolling5_cs","away_rolling5_cs",
"home_venue_goals","home_venue_conceded",
"away_venue_goals","away_venue_conceded",
"home_goal_trend","away_goal_trend",
"league_home_win_rate","league_draw_rate",
"league_btts_rate","league_ou25_rate",
"league_reliability_score",
"home_days_rest","away_days_rest",
"match_month","is_season_start","is_season_end",
"h2h_home_goals_avg","h2h_away_goals_avg",
"h2h_recent_trend","h2h_venue_advantage",
"attack_vs_defense_home","attack_vs_defense_away",
"xg_diff","form_momentum_interaction",
"elo_form_consistency","upset_x_elo_gap",
]
ALL_COLS = V25_COLS + V27_NEW
class V27Loader(V25Loader):
"""Load ALL matches with odds, not just top leagues."""
def __init__(self, conn):
super().__init__(conn, [])
self.league_matches_cache = {}
def _load_matches(self):
self.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, m.league_id,
ht.name, at.name, l.name
FROM matches m
JOIN teams ht ON m.home_team_id = ht.id
JOIN teams at ON m.away_team_id = at.id
JOIN leagues l ON m.league_id = l.id
WHERE m.status='FT' AND m.score_home IS NOT NULL
AND m.sport='football'
AND EXISTS(SELECT 1 FROM odd_categories oc WHERE oc.match_id=m.id)
ORDER BY m.mst_utc ASC
""")
self.matches = self.cur.fetchall()
def _load_odds(self):
self.cur.execute("""
SELECT oc.match_id, oc.name, os.name, os.odd_value
FROM odd_selections os
JOIN odd_categories oc ON os.odd_category_db_id=oc.db_id
JOIN matches m ON oc.match_id=m.id
WHERE m.status='FT' AND m.sport='football'
""")
for mid, cat, sel, val in self.cur.fetchall():
try:
v = float(val) if val else 0
if v <= 0 or not cat or not sel: continue
if mid not in self.odds_cache: self.odds_cache[mid] = {}
c = cat.lower().strip()
s = sel.lower().strip()
o = self.odds_cache[mid]
if c == 'maç sonucu':
if sel=='1': o['ms_h']=v
elif sel in('0','X'): o['ms_d']=v
elif sel=='2': o['ms_a']=v
elif c == '1. yarı sonucu':
if sel=='1': o['ht_ms_h']=v
elif sel in('0','X'): o['ht_ms_d']=v
elif sel=='2': o['ht_ms_a']=v
elif c == 'karşılıklı gol':
if 'var' in s: o['btts_y']=v
elif 'yok' in s: o['btts_n']=v
elif c == '2,5 alt/üst':
if 'alt' in s: o['ou25_u']=v
elif 'üst' in s: o['ou25_o']=v
elif c == '1,5 alt/üst':
if 'alt' in s: o['ou15_u']=v
elif 'üst' in s: o['ou15_o']=v
elif c == '3,5 alt/üst':
if 'alt' in s: o['ou35_u']=v
elif 'üst' in s: o['ou35_o']=v
elif c == '0,5 alt/üst':
if 'alt' in s: o['ou05_u']=v
elif 'üst' in s: o['ou05_o']=v
elif c == '1. yarı 0,5 alt/üst':
if 'alt' in s: o['ht_ou05_u']=v
elif 'üst' in s: o['ht_ou05_o']=v
elif c == '1. yarı 1,5 alt/üst':
if 'alt' in s: o['ht_ou15_u']=v
elif 'üst' in s: o['ht_ou15_o']=v
except (ValueError, TypeError): pass
def _load_league_stats(self):
self.cur.execute("""
SELECT league_id,
AVG(score_home+score_away), AVG(CASE WHEN score_home=0 AND score_away=0 THEN 1.0 ELSE 0.0 END),
COUNT(*)
FROM matches WHERE status='FT' AND score_home IS NOT NULL AND sport='football'
GROUP BY league_id
""")
for lid, ag, zr, cnt in self.cur.fetchall():
self.league_stats_cache[lid] = {
"avg_goals": float(ag) if ag else 2.5,
"zero_rate": float(zr) if zr else 0.07,
"match_count": cnt
}
def _load_squad_data(self):
self.cur.execute("""
SELECT mpp.match_id, mpp.team_id,
COUNT(*) FILTER(WHERE mpp.is_starting=true),
COUNT(*),
COUNT(*) FILTER(WHERE mpp.is_starting=true
AND LOWER(COALESCE(mpp.position::TEXT,''))~'(forward|fwd|forvet|striker)')
FROM match_player_participation mpp
JOIN matches m ON mpp.match_id=m.id
WHERE m.status='FT' AND m.sport='football'
GROUP BY mpp.match_id, mpp.team_id
""")
part = {}
for mid,tid,st,tot,fwd in self.cur.fetchall():
part[(mid,tid)]={'starting_count':st or 0,'total_squad':tot or 0,'fwd_count':fwd or 0}
self.cur.execute("""
SELECT mpe.match_id, mpe.team_id,
COUNT(*) FILTER(WHERE mpe.event_type='goal' AND COALESCE(mpe.event_subtype,'') NOT ILIKE '%%penaltı kaçırma%%'),
COUNT(DISTINCT mpe.assist_player_id) FILTER(WHERE mpe.event_type='goal' AND mpe.assist_player_id IS NOT NULL),
COUNT(DISTINCT mpe.player_id) FILTER(WHERE mpe.event_type='goal' AND COALESCE(mpe.event_subtype,'') NOT ILIKE '%%penaltı kaçırma%%')
FROM match_player_events mpe
JOIN matches m ON mpe.match_id=m.id
WHERE m.status='FT' AND m.sport='football'
GROUP BY mpe.match_id, mpe.team_id
""")
evts = {}
for mid,tid,g,a,sc in self.cur.fetchall():
evts[(mid,tid)]={'goals':g or 0,'assists':a or 0,'unique_scorers':sc or 0}
self.cur.execute("""
SELECT mpe.team_id, mpe.player_id, COUNT(*)
FROM match_player_events mpe JOIN matches m ON mpe.match_id=m.id
WHERE m.status='FT' AND m.sport='football' AND mpe.event_type='goal'
AND COALESCE(mpe.event_subtype,'') NOT ILIKE '%%penaltı kaçırma%%'
GROUP BY mpe.team_id, mpe.player_id HAVING COUNT(*)>=3
""")
kp_by_team = defaultdict(set)
for tid,pid,_ in self.cur.fetchall(): kp_by_team[tid].add(pid)
self.cur.execute("""
SELECT mpp.match_id, mpp.team_id, mpp.player_id
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'
""")
starters = defaultdict(list)
for mid,tid,pid in self.cur.fetchall(): starters[(mid,tid)].append(pid)
for key in set(part)|set(evts):
mid,tid = key
p = part.get(key,{'starting_count':0,'total_squad':0,'fwd_count':0})
e = evts.get(key,{'goals':0,'assists':0,'unique_scorers':0})
s = starters.get(key,[])
kp_in = sum(1 for x in s if x in kp_by_team.get(tid,set()))
kp_tot = len(kp_by_team.get(tid,set()))
kp_miss = max(0, kp_tot - kp_in)
sq = p['starting_count']*0.3 + e['goals']*2.0 + e['assists']*1.0 + kp_in*3.0 + p['fwd_count']*1.5
mi = min(kp_miss/max(kp_tot,1), 1.0)
self.squad_cache[key] = {'squad_quality':sq,'key_players':kp_in,'missing_impact':mi,'goals_form':e['goals']}
def _load_cards_data(self):
self.cur.execute("""
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)
FROM match_player_events mpe JOIN matches m ON mpe.match_id=m.id
WHERE m.status='FT' AND m.sport='football' AND mpe.event_type::text LIKE '%%card%%'
GROUP BY mpe.match_id
""")
for mid, cw in self.cur.fetchall():
self.cards_cache[mid] = float(cw) if cw else 0.0
def load_league_matches(self):
for m in self.matches:
lid = m[8]
if lid not in self.league_matches_cache:
self.league_matches_cache[lid] = []
self.league_matches_cache[lid].append((m[7],None,m[3],m[4],None))
class V27Extractor(V25Extractor):
"""Adds V27 features on top of V25."""
def _extract_one(self, mid, hid, aid, sh, sa, hth, hta, mst, lid,
hn, an, ln):
row = super()._extract_one(mid,hid,aid,sh,sa,hth,hta,mst,lid,hn,an,ln)
if not row: return None
hm = self.loader.team_matches.get(hid,[])
am = self.loader.team_matches.get(aid,[])
hr = calc_rolling_features(hm, mst, True)
ar = calc_rolling_features(am, mst, False)
for pfx,r in [("home",hr),("away",ar)]:
row[f"{pfx}_rolling5_goals"]=r["rolling5_goals_avg"]
row[f"{pfx}_rolling5_conceded"]=r["rolling5_conceded_avg"]
row[f"{pfx}_rolling10_goals"]=r["rolling10_goals_avg"]
row[f"{pfx}_rolling10_conceded"]=r["rolling10_conceded_avg"]
row[f"{pfx}_rolling20_goals"]=r["rolling20_goals_avg"]
row[f"{pfx}_rolling20_conceded"]=r["rolling20_conceded_avg"]
row[f"{pfx}_rolling5_cs"]=r["rolling5_clean_sheets"]
row[f"{pfx}_venue_goals"]=r["venue_goals_avg"]
row[f"{pfx}_venue_conceded"]=r["venue_conceded_avg"]
row[f"{pfx}_goal_trend"]=r["goal_trend"]
lb = [x for x in self.loader.league_matches_cache.get(lid,[]) if x[0]<mst]
lq = calc_league_quality(lb)
for k,v in lq.items(): row[k]=v
ht = calc_time_features(hm, mst)
at = calc_time_features(am, mst)
row["home_days_rest"]=ht["days_rest"]
row["away_days_rest"]=at["days_rest"]
row["match_month"]=ht["match_month"]
row["is_season_start"]=ht["is_season_start"]
row["is_season_end"]=ht["is_season_end"]
h2h = calc_advanced_h2h(hm, hid, aid, mst)
for k,v in h2h.items(): row[k]=v
sd = calc_strength_diff(
{"goals_avg":row.get("home_goals_avg",1.3),"conceded_avg":row.get("home_conceded_avg",1.2),"scoring_rate":row.get("home_scoring_rate",0.75)},
{"goals_avg":row.get("away_goals_avg",1.3),"conceded_avg":row.get("away_conceded_avg",1.2),"scoring_rate":row.get("away_scoring_rate",0.75)},
self.elo_ratings[hid], self.elo_ratings[aid],
row.get("home_momentum_score",0.5), row.get("away_momentum_score",0.5),
row.get("upset_potential",0.0),
)
row.update(sd)
return row
def main():
print("🚀 V27 Value Sniper — Training Data Extraction")
print("="*60)
t0 = time.time()
conn = get_conn()
print("\n📦 Loading ALL odds-bearing matches...")
loader = V27Loader(conn)
loader.load_all()
loader.load_league_matches()
print(f" Matches: {len(loader.matches)}")
print(f" Leagues: {len(loader.league_stats_cache)}")
print(f" Odds: {len(loader.odds_cache)}")
ext = V27Extractor(conn, loader)
rows = ext.extract_all()
if not rows:
print("❌ No data!"); return
print(f"\n💾 Writing {len(rows)} rows...")
with open(OUTPUT,"w",newline="",encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=ALL_COLS, extrasaction='ignore')
w.writeheader(); w.writerows(rows)
n = len(rows)
wo = sum(1 for r in rows if r.get("odds_ms_h",0)>0)
md = defaultdict(int)
for r in rows: md[r["label_ms"]]+=1
print(f"\n📊 Summary:")
print(f" Rows: {n}")
print(f" With odds: {wo} ({wo/n*100:.1f}%)")
print(f" Features: {len(ALL_COLS)} ({len(V25_COLS)} V25 + {len(V27_NEW)} new)")
print(f" MS: H={md[0]/n*100:.1f}% D={md[1]/n*100:.1f}% A={md[2]/n*100:.1f}%")
print(f" Time: {(time.time()-t0)/60:.1f}min")
print(f"\n✅ Done! → {OUTPUT}")
conn.close()
if __name__=="__main__":
main()
+317
View File
@@ -0,0 +1,317 @@
"""
Strategy Generator Senin Excel mantığını DB üzerinde otomatize eder.
Mantık:
1. Ev sahibi takım X, evinde oran bandı Y'de oynadığında → OU1.5/OU2.5/BTTS oranları
2. Deplasman takım Z, deplasmanda oran bandı W'de oynadığında → OU1.5/OU2.5/BTTS oranları
3. İkisi de yüksekse STRATEJİ ÜRET
Çıktı: Her maç için hangi bahis oynanabilir, neden, ve geçmiş başarı oranı
"""
import psycopg2
import pandas as pd
import numpy as np
from collections import defaultdict
from datetime import datetime
# DB connection
conn = psycopg2.connect(
host="localhost",
port=15432,
dbname="boilerplate_db",
user="suggestbet",
password="SuGGesT2026SecuRe"
)
print("=" * 70)
print(" STRATEGY GENERATOR — Veritabanından Strateji Üretimi")
print("=" * 70)
# 1. Tüm biten maçları, takım adları ve MS oranlarıyla çek
query = """
SELECT
m.id as match_id,
m.home_team_id,
m.away_team_id,
m.league_id,
m.score_home,
m.score_away,
m.mst_utc,
ht.name as home_team,
at.name as away_team,
l.name as league_name
FROM matches m
JOIN teams ht ON m.home_team_id = ht.id
JOIN teams at ON m.away_team_id = at.id
JOIN leagues l ON m.league_id = l.id
WHERE m.status = 'FT'
AND m.score_home IS NOT NULL
ORDER BY m.mst_utc ASC
"""
df = pd.read_sql(query, conn)
print(f"\nToplam biten maç: {len(df):,}")
# 2. Tüm oranları çek (MS, OU25, BTTS, OU15)
odds_query = """
SELECT
oc.match_id,
oc.name as market,
os.name as selection,
CAST(os.odd_value AS DECIMAL) as odds
FROM odd_categories oc
JOIN odd_selections os ON os.odd_category_db_id = oc.db_id
WHERE oc.name IN (
'Maç Sonucu',
'2,5 Alt/Üst',
'1,5 Alt/Üst',
'3,5 Alt/Üst',
'Karşılıklı Gol'
)
"""
odds_df = pd.read_sql(odds_query, conn)
print(f"Toplam oran kaydı: {len(odds_df):,}")
# Pivot: her maç için oranları sütunlara çevir
def get_odds(match_id, market, selection):
mask = (odds_df.match_id == match_id) & (odds_df.market == market) & (odds_df.selection == selection)
vals = odds_df.loc[mask, 'odds']
return float(vals.iloc[0]) if len(vals) > 0 else None
# Daha verimli: oran lookup dict oluştur
print("Oran lookup oluşturuluyor...")
odds_lookup = {}
for _, row in odds_df.iterrows():
key = (row.match_id, row.market, row.selection)
odds_lookup[key] = float(row.odds)
def get_o(mid, market, sel):
return odds_lookup.get((mid, market, sel))
# 3. Her maça oranları ekle
print("Maçlara oranlar ekleniyor...")
df['odds_ms_h'] = df.match_id.map(lambda x: get_o(x, 'Maç Sonucu', '1'))
df['odds_ms_a'] = df.match_id.map(lambda x: get_o(x, 'Maç Sonucu', '2'))
df['odds_ms_d'] = df.match_id.map(lambda x: get_o(x, 'Maç Sonucu', '0'))
df['odds_ou25_o'] = df.match_id.map(lambda x: get_o(x, '2,5 Alt/Üst', 'Üst'))
df['odds_ou25_u'] = df.match_id.map(lambda x: get_o(x, '2,5 Alt/Üst', 'Alt'))
df['odds_ou15_o'] = df.match_id.map(lambda x: get_o(x, '1,5 Alt/Üst', 'Üst'))
df['odds_ou15_u'] = df.match_id.map(lambda x: get_o(x, '1,5 Alt/Üst', 'Alt'))
df['odds_ou35_o'] = df.match_id.map(lambda x: get_o(x, '3,5 Alt/Üst', 'Üst'))
df['odds_ou35_u'] = df.match_id.map(lambda x: get_o(x, '3,5 Alt/Üst', 'Alt'))
df['odds_btts_y'] = df.match_id.map(lambda x: get_o(x, 'Karşılıklı Gol', 'Var'))
df['odds_btts_n'] = df.match_id.map(lambda x: get_o(x, 'Karşılıklı Gol', 'Yok'))
# Sonuç hesapla
df['total_goals'] = df.score_home + df.score_away
df['ou15'] = (df.total_goals > 1).astype(int)
df['ou25'] = (df.total_goals > 2).astype(int)
df['ou35'] = (df.total_goals > 3).astype(int)
df['btts'] = ((df.score_home > 0) & (df.score_away > 0)).astype(int)
print(f"Oranı olan maç sayısı: {df.odds_ms_h.notna().sum():,}")
# 4. ORAN BANDI fonksiyonu
def odds_band(odds):
if pd.isna(odds): return None
if odds < 1.30: return '1.00-1.30'
if odds < 1.50: return '1.30-1.50'
if odds < 1.80: return '1.50-1.80'
if odds < 2.20: return '1.80-2.20'
if odds < 2.80: return '2.20-2.80'
if odds < 4.00: return '2.80-4.00'
if odds < 6.00: return '4.00-6.00'
return '6.00+'
# 5. STRATEJİ: Expanding window — sadece geçmiş veriye bakarak tahmin
print("\n" + "=" * 70)
print(" STRATEJİ BACKTEST — Expanding Window")
print("=" * 70)
# Ev sahibi geçmişi: {team_id: {odds_band: [ou15, ou25, btts, ou35, ...]}}
home_history = defaultdict(lambda: defaultdict(list))
away_history = defaultdict(lambda: defaultdict(list))
MIN_MATCHES = 8 # Minimum geçmiş maç sayısı
TEST_PCT = 0.30 # Son %30 test
N = len(df)
test_start = int(N * (1 - TEST_PCT))
results = {
'ou15_over': [], 'ou25_over': [], 'ou35_over': [],
'btts_yes': [], 'btts_no': [],
'ou25_under': [], 'ou15_under': [],
'ms_home': []
}
for i in range(N):
row = df.iloc[i]
h_odds = row.odds_ms_h
a_odds = row.odds_ms_a
if pd.isna(h_odds) or pd.isna(a_odds):
continue
h_band = odds_band(h_odds)
a_band = odds_band(a_odds)
# TEST: sadece test bölümünde bahis yap
if i >= test_start:
h_hist = home_history[row.home_team_id][h_band]
a_hist = away_history[row.away_team_id][a_band]
if len(h_hist) >= MIN_MATCHES and len(a_hist) >= MIN_MATCHES:
# Ev sahibi bu oran bandında ne yapmış?
h_ou15 = np.mean([x[0] for x in h_hist])
h_ou25 = np.mean([x[1] for x in h_hist])
h_ou35 = np.mean([x[2] for x in h_hist])
h_btts = np.mean([x[3] for x in h_hist])
h_win = np.mean([x[4] for x in h_hist])
# Deplasman bu oran bandında ne yapmış?
a_ou15 = np.mean([x[0] for x in a_hist])
a_ou25 = np.mean([x[1] for x in a_hist])
a_ou35 = np.mean([x[2] for x in a_hist])
a_btts = np.mean([x[3] for x in a_hist])
a_loss = np.mean([x[4] for x in a_hist]) # deplasman kaybetme oranı
# KOMBİNE SİNYAL
sig_ou15 = (h_ou15 + a_ou15) / 2
sig_ou25 = (h_ou25 + a_ou25) / 2
sig_ou35 = (h_ou35 + a_ou35) / 2
sig_btts = (h_btts + a_btts) / 2
sig_hw = (h_win + a_loss) / 2 # ev kazanma + deplasman kaybetme
base = {
'match': f"{row.home_team} vs {row.away_team}",
'league': row.league_name,
'home_team': row.home_team,
'away_team': row.away_team,
'h_band': h_band,
'a_band': a_band,
'h_n': len(h_hist),
'a_n': len(a_hist),
}
# OU 1.5 OVER
if sig_ou15 >= 0.85 and row.odds_ou15_o and row.odds_ou15_o > 1.01:
results['ou15_over'].append({
**base, 'signal': sig_ou15, 'odds': row.odds_ou15_o,
'won': row.ou15 == 1, 'actual_goals': row.total_goals,
'h_sig': h_ou15, 'a_sig': a_ou15
})
# OU 2.5 OVER
if sig_ou25 >= 0.70 and row.odds_ou25_o and row.odds_ou25_o > 1.10:
results['ou25_over'].append({
**base, 'signal': sig_ou25, 'odds': row.odds_ou25_o,
'won': row.ou25 == 1, 'actual_goals': row.total_goals,
'h_sig': h_ou25, 'a_sig': a_ou25
})
# OU 3.5 OVER
if sig_ou35 >= 0.60 and row.odds_ou35_o and row.odds_ou35_o > 1.20:
results['ou35_over'].append({
**base, 'signal': sig_ou35, 'odds': row.odds_ou35_o,
'won': row.ou35 == 1, 'actual_goals': row.total_goals,
'h_sig': h_ou35, 'a_sig': a_ou35
})
# BTTS YES
if sig_btts >= 0.70 and row.odds_btts_y and row.odds_btts_y > 1.10:
results['btts_yes'].append({
**base, 'signal': sig_btts, 'odds': row.odds_btts_y,
'won': row.btts == 1, 'actual_goals': row.total_goals,
'h_sig': h_btts, 'a_sig': a_btts
})
# OU 2.5 UNDER (düşük gol beklentisi)
if sig_ou25 <= 0.30 and row.odds_ou25_u and row.odds_ou25_u > 1.10:
results['ou25_under'].append({
**base, 'signal': 1-sig_ou25, 'odds': row.odds_ou25_u,
'won': row.ou25 == 0, 'actual_goals': row.total_goals,
'h_sig': 1-h_ou25, 'a_sig': 1-a_ou25
})
# MS HOME WIN (ev sahibi kazanma)
if sig_hw >= 0.75 and row.odds_ms_h and 1.10 < row.odds_ms_h < 3.50:
results['ms_home'].append({
**base, 'signal': sig_hw, 'odds': row.odds_ms_h,
'won': row.score_home > row.score_away,
'actual_goals': row.total_goals,
'h_sig': h_win, 'a_sig': a_loss
})
# History güncelle (her zaman)
home_history[row.home_team_id][h_band].append((
row.ou15, row.ou25, row.ou35, row.btts,
int(row.score_home > row.score_away)
))
away_history[row.away_team_id][a_band].append((
row.ou15, row.ou25, row.ou35, row.btts,
int(row.score_away < row.score_home) # deplasman kaybetme
))
# 6. SONUÇLARI YAZIDIR
print(f"\nTest bölümü: son {TEST_PCT*100:.0f}% ({N - test_start:,} maç)")
print(f"Minimum geçmiş: {MIN_MATCHES} maç\n")
for market_name, bets in results.items():
if not bets:
print(f"\n {market_name}: sinyal yok")
continue
bdf = pd.DataFrame(bets)
total = len(bdf)
wins = bdf.won.sum()
hit = wins / total * 100
pnl = (bdf.won * (bdf.odds - 1) - (~bdf.won) * 1).sum()
roi = pnl / total * 100
avg_odds = bdf.odds.mean()
print(f"\n{'='*60}")
print(f" {market_name.upper()}")
print(f"{'='*60}")
print(f" Toplam bahis: {total}")
print(f" Kazanan: {wins} ({hit:.1f}%)")
print(f" Ortalama odds: {avg_odds:.2f}")
print(f" PnL: {pnl:+.1f} birim")
print(f" ROI: {roi:+.1f}%")
# Farklı sinyal eşiklerinde performans
print(f"\n Sinyal eşik analizi:")
for threshold in [0.70, 0.75, 0.80, 0.85, 0.90, 0.95]:
sub = bdf[bdf.signal >= threshold]
if len(sub) < 5: continue
w = sub.won.sum()
p = (sub.won * (sub.odds - 1) - (~sub.won) * 1).sum()
r = p / len(sub) * 100
star = ' ✅ PROFIT' if r > 0 else (' ⚖️ BE' if r > -3 else '')
print(f"{threshold:.2f}: {len(sub):5d} bahis, hit={w/len(sub)*100:.1f}%, ROI={r:+.1f}%{star}")
# En iyi 10 örnek (kazanan)
if wins > 0:
best = bdf[bdf.won].nlargest(min(5, wins), 'signal')
print(f"\n Örnek kazanan bahisler:")
for _, b in best.iterrows():
print(f" {b.home_team} vs {b.away_team} ({b.league})")
print(f" Ev {b.h_band} ({b.h_sig:.0%}) + Dep {b.a_band} ({b.a_sig:.0%}) → sinyal={b.signal:.0%}, odds={b.odds:.2f}, gol={b.actual_goals:.0f}")
# 7. ÖZET TABLO
print("\n\n" + "=" * 70)
print(" ÖZET TABLO")
print("=" * 70)
print(f"{'Market':<15} {'Bahis':>6} {'Hit':>7} {'ROI':>8} {'Avg Odds':>9}")
print("-" * 50)
for market_name, bets in results.items():
if not bets: continue
bdf = pd.DataFrame(bets)
total = len(bdf)
wins = bdf.won.sum()
hit = wins / total * 100
pnl = (bdf.won * (bdf.odds - 1) - (~bdf.won) * 1).sum()
roi = pnl / total * 100
avg_odds = bdf.odds.mean()
print(f"{market_name:<15} {total:>6} {hit:>6.1f}% {roi:>+7.1f}% {avg_odds:>8.2f}")
conn.close()
print("\n✅ Tamamlandı!")
+239 -151
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"]
# Train/Test Split
X_train, X_test, y_h_train, y_h_test, y_a_train, y_a_test, y_ht_h_train, y_ht_h_test, y_ht_a_train, y_ht_a_test = train_test_split(
X, y_home, y_away, y_ht_home, y_ht_away, test_size=0.2, random_state=42
)
print(f" Training set: {len(X_train)} matches")
print(f" Test set: {len(X_test)} matches")
# --- HOME GOALS MODEL ---
print("\n🏠 Training Home Goals Model...")
xgb_home = xgb.XGBRegressor(
objective='reg:squarederror',
n_estimators=1000,
learning_rate=0.01,
max_depth=5,
subsample=0.7,
colsample_bytree=0.7,
n_jobs=-1,
random_state=42,
early_stopping_rounds=50 # Configure here for newer XGBoost or remove if not supported in constructor (depends on version)
)
# Actually, to be safe across versions, let's remove early stopping for now or use validation set properly
# Using 'eval_set' without early_stopping_rounds just prints metrics
xgb_home = xgb.XGBRegressor(
objective='reg:squarederror',
n_estimators=1000,
learning_rate=0.01,
max_depth=5,
subsample=0.7,
colsample_bytree=0.7,
n_jobs=-1,
random_state=42
)
xgb_home.fit(X_train, y_h_train, eval_set=[(X_test, y_h_test)], verbose=False)
home_preds = xgb_home.predict(X_test)
mae_home = mean_absolute_error(y_h_test, home_preds)
r2_home = r2_score(y_h_test, home_preds)
print(f" ✅ FT Home MAE: {mae_home:.4f} goals")
print(f" ✅ FT Home R2: {r2_home:.4f}")
# Ensure all features exist
missing = [f for f in FEATURES if f not in df.columns]
if missing:
print(f"⚠️ Missing {len(missing)} features, filling with 0: {missing[:5]}...")
for f in missing:
df[f] = 0
# --- AWAY GOALS MODEL ---
print("\n✈️ Training FT Away Goals Model...")
xgb_away = xgb.XGBRegressor(
objective='reg:squarederror',
n_estimators=1000,
learning_rate=0.01,
max_depth=5,
subsample=0.7,
colsample_bytree=0.7,
n_jobs=-1,
random_state=42
)
xgb_away.fit(X_train, y_a_train, eval_set=[(X_test, y_a_test)], verbose=False)
away_preds = xgb_away.predict(X_test)
mae_away = mean_absolute_error(y_a_test, away_preds)
r2_away = r2_score(y_a_test, away_preds)
print(f" ✅ FT Away MAE: {mae_away:.4f} goals")
print(f" ✅ FT Away R2: {r2_away:.4f}")
# --- HT HOME GOALS MODEL ---
print("\n🏠 Training HT Home Goals Model...")
xgb_ht_home = xgb.XGBRegressor(
objective='reg:squarederror',
n_estimators=1000,
learning_rate=0.01,
max_depth=5,
subsample=0.7,
colsample_bytree=0.7,
n_jobs=-1,
random_state=42
)
xgb_ht_home.fit(X_train, y_ht_h_train, eval_set=[(X_test, y_ht_h_test)], verbose=False)
ht_home_preds = xgb_ht_home.predict(X_test)
mae_ht_home = mean_absolute_error(y_ht_h_test, ht_home_preds)
print(f" ✅ HT Home MAE: {mae_ht_home:.4f} goals")
return df
# --- HT AWAY GOALS MODEL ---
print("\n✈️ Training HT Away Goals Model...")
xgb_ht_away = xgb.XGBRegressor(
objective='reg:squarederror',
n_estimators=1000,
learning_rate=0.01,
max_depth=5,
subsample=0.7,
colsample_bytree=0.7,
n_jobs=-1,
random_state=42
def temporal_split(df: pd.DataFrame, train_ratio: float = 0.80):
"""
Temporal train/test split by match date.
Ensures no future information leaks into training.
"""
if "match_date" in df.columns:
df = df.sort_values("match_date").reset_index(drop=True)
elif "round" in df.columns:
df = df.sort_values("round").reset_index(drop=True)
split_idx = int(len(df) * train_ratio)
return df.iloc[:split_idx].copy(), df.iloc[split_idx:].copy()
def train_single_model(X_train, y_train, X_test, y_test, name: str):
"""Train a single XGBoost regression model with early stopping."""
print(f"\n🏗️ Training {name} model...")
model = xgb.XGBRegressor(**XGB_PARAMS)
model.fit(
X_train, y_train,
eval_set=[(X_test, y_test)],
verbose=False,
)
xgb_ht_away.fit(X_train, y_ht_a_train, eval_set=[(X_test, y_ht_a_test)], verbose=False)
ht_away_preds = xgb_ht_away.predict(X_test)
mae_ht_away = mean_absolute_error(y_ht_a_test, ht_away_preds)
print(f" ✅ HT Away MAE: {mae_ht_away:.4f} goals")
# --- EVALUATE EXACT SCORE ACCURACY (ROUNDED) ---
print("\n🎯 Exact FT Score Accuracy (Test Set):")
correct = 0
close = 0 # Within 1 goal diff for both
for h_true, a_true, h_pred, a_pred in zip(y_h_test, y_a_test, home_preds, away_preds):
h_p = round(h_pred)
a_p = round(a_pred)
if h_p == h_true and a_p == a_true:
correct += 1
if abs(h_p - h_true) <= 1 and abs(a_p - a_true) <= 1:
preds = model.predict(X_test)
mae = mean_absolute_error(y_test, preds)
rmse = np.sqrt(mean_squared_error(y_test, preds))
r2 = r2_score(y_test, preds)
print(f" MAE: {mae:.4f} goals")
print(f" RMSE: {rmse:.4f}")
print(f" R²: {r2:.4f}")
return model, {"mae": mae, "rmse": rmse, "r2": r2}
def evaluate_combined(models: dict, X_test, y_test_dict: dict):
"""Evaluate combined score accuracy (FT and HT)."""
print("\n🎯 Combined Score Evaluation (Test Set):")
# FT Score
ft_h_preds = models["ft_home"].predict(X_test)
ft_a_preds = models["ft_away"].predict(X_test)
y_ft_h = y_test_dict["score_home"].values
y_ft_a = y_test_dict["score_away"].values
exact = 0
close = 0
result_correct = 0
total = len(X_test)
for h_true, a_true, h_pred, a_pred in zip(y_ft_h, y_ft_a, ft_h_preds, ft_a_preds):
hp = max(0, round(h_pred))
ap = max(0, round(a_pred))
# Exact score
if hp == h_true and ap == a_true:
exact += 1
# Close (±1 each)
if abs(hp - h_true) <= 1 and abs(ap - a_true) <= 1:
close += 1
acc = correct / len(X_test) * 100
close_acc = close / len(X_test) * 100
print(f" Exact Match: {acc:.2f}%")
print(f" Close Match (+/- 1 goal): {close_acc:.2f}%")
# Result direction (1X2)
true_result = 1 if h_true > a_true else (0 if h_true == a_true else -1)
pred_result = 1 if hp > ap else (0 if hp == ap else -1)
if true_result == pred_result:
result_correct += 1
print(f" FT Exact Score: {exact / total * 100:.2f}% ({exact}/{total})")
print(f" FT Close (±1): {close / total * 100:.2f}% ({close}/{total})")
print(f" FT Result (1X2): {result_correct / total * 100:.2f}% ({result_correct}/{total})")
# HT Score
ht_h_preds = models["ht_home"].predict(X_test)
ht_a_preds = models["ht_away"].predict(X_test)
y_ht_h = y_test_dict["ht_score_home"].values
y_ht_a = y_test_dict["ht_score_away"].values
ht_exact = 0
ht_total = len(X_test)
for h_true, a_true, h_pred, a_pred in zip(y_ht_h, y_ht_a, ht_h_preds, ht_a_preds):
hp = max(0, round(h_pred))
ap = max(0, round(a_pred))
if hp == h_true and ap == a_true:
ht_exact += 1
print(f" HT Exact Score: {ht_exact / ht_total * 100:.2f}% ({ht_exact}/{ht_total})")
return {
"ft_exact_pct": exact / total * 100,
"ft_close_pct": close / total * 100,
"ft_result_pct": result_correct / total * 100,
"ht_exact_pct": ht_exact / ht_total * 100,
}
def train():
"""Main training pipeline."""
print("🚀 Score Prediction Model Trainer (V25-Compatible)")
print(f" Feature count: {len(FEATURES)}")
print("=" * 60)
# Load data
df = load_data()
print(f" Total valid rows: {len(df)}")
# Temporal split
train_df, test_df = temporal_split(df)
print(f" Training set: {len(train_df)} matches")
print(f" Test set: {len(test_df)} matches (temporally after training)")
X_train = train_df[FEATURES]
X_test = test_df[FEATURES]
# Train 4 models
models = {}
metrics = {}
for target_name, model_key in [
("score_home", "ft_home"),
("score_away", "ft_away"),
("ht_score_home", "ht_home"),
("ht_score_away", "ht_away"),
]:
model, metric = train_single_model(
X_train, train_df[target_name],
X_test, test_df[target_name],
model_key,
)
models[model_key] = model
metrics[model_key] = metric
# Combined evaluation
y_test_dict = {t: test_df[t] for t in TARGETS}
combined = evaluate_combined(models, X_test, y_test_dict)
# Save
print(f"\n💾 Saving models to {MODEL_PATH}...")
print(f"\n💾 Saving to {MODEL_PATH}...")
model_data = {
"home_model": xgb_home,
"away_model": xgb_away,
"ht_home_model": xgb_ht_home,
"ht_away_model": xgb_ht_away,
"home_model": models["ft_home"],
"away_model": models["ft_away"],
"ht_home_model": models["ht_home"],
"ht_away_model": models["ht_away"],
"features": FEATURES,
"meta": {
"mae_home": mae_home,
"mae_away": mae_away,
"mae_ht_home": mae_ht_home,
"mae_ht_away": mae_ht_away,
"acc": acc
}
**{f"{k}_{mk}": mv for k, m in metrics.items() for mk, mv in m.items()},
**combined,
"trained_at": datetime.now().isoformat(),
"feature_count": len(FEATURES),
"train_size": len(train_df),
"test_size": len(test_df),
},
}
with open(MODEL_PATH, "wb") as f:
pickle.dump(model_data, f)
print("✅ Done.")
print("\n✅ Score model training complete!")
print(f" Saved: {MODEL_PATH}")
if __name__ == "__main__":
train()
+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()
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import json
from pathlib import Path
import pandas as pd
AI_ENGINE_DIR = Path(__file__).resolve().parents[1]
DATA_DIR = AI_ENGINE_DIR / "data" / "v26_shadow"
CONFIG_PATH = AI_ENGINE_DIR / "models" / "v26_shadow" / "market_profiles.json"
REPORT_PATH = AI_ENGINE_DIR / "reports" / "training_v26_shadow.json"
REPORT_PATH.parent.mkdir(parents=True, exist_ok=True)
def _market_accuracy(frame: pd.DataFrame, target_col: str) -> float:
if target_col not in frame.columns or frame.empty:
return 0.0
counts = frame[target_col].value_counts(normalize=True)
if counts.empty:
return 0.0
return round(float(counts.max()), 4)
def main() -> None:
train_csv = DATA_DIR / "train.csv"
validation_csv = DATA_DIR / "validation.csv"
if not train_csv.exists() or not validation_csv.exists():
raise SystemExit("Run extract_training_data_v26.py first")
train_df = pd.read_csv(train_csv)
validation_df = pd.read_csv(validation_csv)
config = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
report = {
"version": config.get("version"),
"calibration_version": config.get("calibration_version"),
"train_rows": int(len(train_df)),
"validation_rows": int(len(validation_df)),
"label_priors": {
"MS": _market_accuracy(validation_df, "label_ms"),
"OU25": _market_accuracy(validation_df, "label_ou25"),
"BTTS": _market_accuracy(validation_df, "label_btts"),
"HT": _market_accuracy(validation_df, "label_ht_result"),
"HTFT": _market_accuracy(validation_df, "label_ht_ft"),
"CARDS": _market_accuracy(validation_df, "label_cards_ou45"),
},
"artifact_path": str(CONFIG_PATH),
"notes": [
"v26.shadow runtime currently uses artifact-based calibration and ROI gating",
"market profile JSON remains the source of truth for runtime thresholds",
],
}
REPORT_PATH.write_text(json.dumps(report, indent=2), encoding="utf-8")
print(f"[OK] Shadow training report written to {REPORT_PATH}")
if __name__ == "__main__":
main()
+577
View File
@@ -0,0 +1,577 @@
"""
V27 Value Sniper PRO Training Script
========================================
KEY INSIGHT: Train model WITHOUT odds to get independent probability.
Then compare with market odds to find genuine value edges.
Strategy:
Stage A: "Fundamentals Model" odds-free, learns from ELO/form/rolling/H2H
Stage B: "Value Model" uses fundamentals + odds disagreement as features
Stage C: Multi-market 1X2, O/U 2.5, BTTS
Stage D: Walk-forward backtest with Kelly sizing
"""
import os, sys, json, pickle, time, warnings
import numpy as np
import pandas as pd
from pathlib import Path
from sklearn.metrics import accuracy_score, log_loss
from sklearn.isotonic import IsotonicRegression
warnings.filterwarnings("ignore")
AI_DIR = Path(__file__).resolve().parent.parent
DATA_CSV = AI_DIR / "data" / "training_data.csv"
MODELS_DIR = AI_DIR / "models" / "v27"
MODELS_DIR.mkdir(parents=True, exist_ok=True)
# ── Leakage & category definitions ──
LEAKAGE_COLS = [
"total_goals", "goal_diff", "ht_total_goals", "ht_goal_diff",
"score_home", "score_away", "ht_score_home", "ht_score_away",
"home_goals_form", "away_goals_form",
"home_squad_quality", "away_squad_quality", "squad_diff",
"home_key_players", "away_key_players",
"home_missing_impact", "away_missing_impact",
"referee_home_bias", "referee_avg_goals", "referee_cards_total",
"referee_avg_yellow", "referee_avg_red", "referee_penalty_rate",
"referee_over25_rate", "referee_experience", "referee_matches",
]
LABEL_COLS = [c for c in [] ] # populated dynamically
META_COLS = ["match_id", "league_name", "home_team", "away_team"]
ODDS_COLS_PATTERNS = ["odds_", "implied_"]
def get_odds_cols(df):
return [c for c in df.columns if any(c.startswith(p) for p in ODDS_COLS_PATTERNS)]
def get_label_cols(df):
return [c for c in df.columns if c.startswith("label_")]
def get_clean_features(df):
"""Features with NO odds and NO leakage — pure fundamentals."""
odds = set(get_odds_cols(df))
labels = set(get_label_cols(df))
exclude = odds | labels | set(LEAKAGE_COLS) | set(META_COLS)
# Also exclude ID columns
exclude |= {c for c in df.columns if c.endswith("_id") and c != "match_id"}
feats = [c for c in df.columns if c not in exclude]
# Keep only numeric
feats = [c for c in feats if pd.to_numeric(df[c], errors="coerce").notna().sum() > len(df)*0.3]
return feats
def load_data():
print(f"Loading {DATA_CSV}...")
df = pd.read_csv(DATA_CSV, low_memory=False)
print(f" Raw: {len(df)} rows")
# Ensure odds exist for value comparison
for c in ["odds_ms_h","odds_ms_d","odds_ms_a"]:
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)]
# OU25 odds
for c in ["odds_ou25_over","odds_ou25_under"]:
if c in df.columns:
df[c] = pd.to_numeric(df[c], errors="coerce")
# Implied probabilities
margin = 1/df.odds_ms_h + 1/df.odds_ms_d + 1/df.odds_ms_a
df["implied_h"] = (1/df.odds_ms_h)/margin
df["implied_d"] = (1/df.odds_ms_d)/margin
df["implied_a"] = (1/df.odds_ms_a)/margin
print(f" After filter: {len(df)} rows")
return df
def temporal_split(df, val_ratio=0.15, test_ratio=0.10):
n = len(df)
tr = int(n*(1-val_ratio-test_ratio))
va = int(n*(1-test_ratio))
return df.iloc[:tr].copy(), df.iloc[tr:va].copy(), df.iloc[va:].copy()
# ═══════════════════════════════════════════════════════════════════
# STAGE A: Fundamentals-Only Model (NO ODDS)
# ═══════════════════════════════════════════════════════════════════
def train_fundamentals_model(X_tr, y_tr, X_va, y_va, feat_cols, market="ms"):
"""Train ensemble WITHOUT odds features."""
models = {}
n_class = 3 if market == "ms" else 2
# XGBoost
try:
import xgboost as xgb
print(f" [XGB] Training {market.upper()}...")
dtrain = xgb.DMatrix(X_tr, label=y_tr, feature_names=feat_cols)
dval = xgb.DMatrix(X_va, label=y_va, feature_names=feat_cols)
params = {
"objective": "multi:softprob" if n_class==3 else "binary:logistic",
"eval_metric": "mlogloss" if n_class==3 else "logloss",
"max_depth": 6, "learning_rate": 0.02, "subsample": 0.75,
"colsample_bytree": 0.75, "min_child_weight": 10,
"reg_alpha": 0.5, "reg_lambda": 2.0,
"verbosity": 0, "tree_method": "hist",
}
if n_class == 3:
params["num_class"] = 3
m = xgb.train(params, dtrain, num_boost_round=2000,
evals=[(dval,"val")], early_stopping_rounds=80,
verbose_eval=False)
p = m.predict(dval)
if n_class == 2:
p = np.column_stack([1-p, p])
acc = accuracy_score(y_va, p.argmax(1))
print(f" acc={acc:.4f}")
models["xgb"] = m
except ImportError:
pass
# LightGBM
try:
import lightgbm as lgb
print(f" [LGB] Training {market.upper()}...")
ds_tr = lgb.Dataset(X_tr, label=y_tr)
ds_va = lgb.Dataset(X_va, label=y_va, reference=ds_tr)
par = {
"objective": "multiclass" if n_class==3 else "binary",
"metric": "multi_logloss" if n_class==3 else "binary_logloss",
"num_leaves": 48, "learning_rate": 0.02,
"feature_fraction": 0.7, "bagging_fraction": 0.7,
"bagging_freq": 1, "min_child_samples": 30,
"lambda_l1": 0.5, "lambda_l2": 2.0, "verbose": -1,
}
if n_class == 3:
par["num_class"] = 3
m = lgb.train(par, ds_tr, 2000, valid_sets=[ds_va],
callbacks=[lgb.early_stopping(80, verbose=False)])
p = m.predict(X_va)
if n_class == 2:
p = np.column_stack([1-p, p])
acc = accuracy_score(y_va, p.argmax(1))
print(f" acc={acc:.4f}")
models["lgb"] = m
except ImportError:
pass
# CatBoost
try:
from catboost import CatBoostClassifier
print(f" [CB] Training {market.upper()}...")
m = CatBoostClassifier(
iterations=2000, learning_rate=0.02, depth=6,
l2_leaf_reg=5, loss_function="MultiClass" if n_class==3 else "Logloss",
early_stopping_rounds=80, verbose=0, task_type="CPU",
**({"classes_count": 3} if n_class==3 else {}),
)
m.fit(X_tr, y_tr, eval_set=(X_va, y_va))
p = m.predict_proba(X_va)
acc = accuracy_score(y_va, p.argmax(1))
print(f" acc={acc:.4f}")
models["cb"] = m
except ImportError:
pass
return models
def ensemble_predict(models, X, feat_cols, n_class=3):
preds = []
for name, m in models.items():
if name == "xgb":
import xgboost as xgb
dm = xgb.DMatrix(X, feature_names=feat_cols)
p = m.predict(dm)
if n_class == 2 and p.ndim == 1:
p = np.column_stack([1-p, p])
elif name == "lgb":
p = m.predict(X)
if n_class == 2 and p.ndim == 1:
p = np.column_stack([1-p, p])
elif name == "cb":
p = m.predict_proba(X)
preds.append(np.array(p))
if not preds:
raise RuntimeError("No models!")
return np.mean(preds, axis=0)
# ═══════════════════════════════════════════════════════════════════
# STAGE B: Walk-Forward Backtest with Kelly
# ═══════════════════════════════════════════════════════════════════
def kelly_fraction(model_prob, odds, fraction=0.25):
"""Fractional Kelly: f = fraction * (p*odds - 1) / (odds - 1)"""
edge = model_prob * odds - 1
if edge <= 0 or odds <= 1:
return 0.0
f = edge / (odds - 1)
return max(0, min(fraction * f, 0.10)) # cap at 10% bankroll
def backtest_value(models, df_test, feat_cols, market="ms",
min_edge=0.05, min_odds=1.40, max_odds=4.50,
use_kelly=True):
"""Realistic backtest: flat or Kelly sizing, edge filtering."""
X = df_test[feat_cols].values
n_class = 3 if market == "ms" else 2
probs = ensemble_predict(models, X, feat_cols, n_class)
if market == "ms":
y = df_test["label_ms"].values
odds_arr = df_test[["odds_ms_h","odds_ms_d","odds_ms_a"]].values
implied = df_test[["implied_h","implied_d","implied_a"]].values
class_names = ["Home","Draw","Away"]
elif market == "ou25":
if "label_ou25" not in df_test.columns:
return {}
y = df_test["label_ou25"].values
o_over = pd.to_numeric(df_test["odds_ou25_o"], errors="coerce").fillna(1.85).values if "odds_ou25_o" in df_test.columns else np.full(len(df_test), 1.85)
o_under = pd.to_numeric(df_test["odds_ou25_u"], errors="coerce").fillna(1.85).values if "odds_ou25_u" in df_test.columns else np.full(len(df_test), 1.85)
odds_arr = np.column_stack([o_under, o_over])
m = 1/odds_arr
implied = m / m.sum(axis=1, keepdims=True)
class_names = ["Under","Over"]
else:
return {}
results = {"bets": [], "total": 0, "wins": 0, "pnl": 0.0, "bankroll_curve": [1000.0]}
bankroll = 1000.0
for i in range(len(y)):
for cls in range(n_class):
edge = probs[i, cls] - implied[i, cls]
odds_val = odds_arr[i, cls]
# FILTERS
if edge < min_edge:
continue
if odds_val < min_odds or odds_val > max_odds:
continue
# Don't bet on heavy favorites with tiny edge
if implied[i, cls] > 0.65 and edge < 0.08:
continue
# Sizing
if use_kelly:
frac = kelly_fraction(probs[i, cls], odds_val, fraction=0.15)
stake = bankroll * frac
else:
stake = 10.0 # flat
if stake < 1:
continue
won = (y[i] == cls)
pnl = stake * (odds_val - 1) if won else -stake
bankroll += pnl
results["bets"].append({
"edge": float(edge), "odds": float(odds_val),
"model_p": float(probs[i,cls]), "implied_p": float(implied[i,cls]),
"won": bool(won), "pnl": float(pnl), "stake": float(stake),
"class": class_names[cls],
})
results["bankroll_curve"].append(bankroll)
results["total"] += 1
if won:
results["wins"] += 1
results["pnl"] = bankroll - 1000.0
return results
def print_backtest(results, label=""):
total = results.get("total", 0)
if total == 0:
print(f" {label}: No bets placed")
return
wins = results["wins"]
pnl = results["pnl"]
hit = wins/total*100
roi = pnl / sum(b["stake"] for b in results["bets"]) * 100
curve = results["bankroll_curve"]
peak = max(curve)
dd = min((c - peak) / peak * 100 for c in curve if c <= peak) if len(curve) > 1 else 0
# Per-class breakdown
by_class = {}
for b in results["bets"]:
cls = b["class"]
if cls not in by_class:
by_class[cls] = {"n": 0, "w": 0, "pnl": 0}
by_class[cls]["n"] += 1
if b["won"]:
by_class[cls]["w"] += 1
by_class[cls]["pnl"] += b["pnl"]
print(f"\n {label}")
print(f" Bets: {total} | Hit: {hit:.1f}% | ROI: {roi:+.1f}%")
print(f" PnL: {pnl:+.0f} | Final: {curve[-1]:.0f} | MaxDD: {dd:.1f}%")
for cls, d in sorted(by_class.items()):
r = d["pnl"]/d["n"]*100 if d["n"] > 0 else 0
print(f" {cls:6s}: {d['n']:4d} bets, "
f"hit={d['w']/d['n']*100:.1f}%, avg_pnl={r:+.1f}%")
# ═══════════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════════
def main():
print("=" * 65)
print(" V27 VALUE SNIPER — PRO TRAINING (Odds-Free Fundamentals)")
print("=" * 65)
t0 = time.time()
df = load_data()
clean_feats = get_clean_features(df)
print(f" Clean features (no odds): {len(clean_feats)}")
# Numerify
for c in clean_feats:
df[c] = pd.to_numeric(df[c], errors="coerce")
df[clean_feats] = df[clean_feats].fillna(df[clean_feats].median())
# Remove constant columns
clean_feats = [c for c in clean_feats if df[c].nunique() > 1]
print(f" After removing constants: {len(clean_feats)}")
# Split
tr, va, te = temporal_split(df)
print(f" Train: {len(tr)}, Val: {len(va)}, Test: {len(te)}")
print(f" Target: H={tr.label_ms.eq(0).mean():.1%}, "
f"D={tr.label_ms.eq(1).mean():.1%}, A={tr.label_ms.eq(2).mean():.1%}")
X_tr = tr[clean_feats].values
y_tr = tr["label_ms"].values
X_va = va[clean_feats].values
y_va = va["label_ms"].values
# ── STAGE A: Train fundamentals model (1X2) ──
print("\n" + ""*65)
print(" STAGE A: Fundamentals-Only 1X2 Model")
print(""*65)
ms_models = train_fundamentals_model(X_tr, y_tr, X_va, y_va, clean_feats, "ms")
val_probs = ensemble_predict(ms_models, X_va, clean_feats, 3)
val_acc = accuracy_score(y_va, val_probs.argmax(1))
val_ll = log_loss(y_va, val_probs)
print(f"\n Ensemble Val: acc={val_acc:.4f}, logloss={val_ll:.4f}")
# Compare with odds baseline
odds_pred = va[["implied_h","implied_d","implied_a"]].values.argmax(1)
odds_acc = accuracy_score(y_va, odds_pred)
print(f" Odds baseline: acc={odds_acc:.4f}")
print(f" Model vs Odds: {val_acc - odds_acc:+.4f}")
# ── STAGE B: O/U 2.5 Model ──
ou_models = None
if "label_ou25" in tr.columns:
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
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')
# ── 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)
print(" STAGE B: Walk-Forward Backtest (Test Set)")
print(""*65)
# Try multiple edge thresholds
best_roi = -999
best_cfg = {}
for min_edge in [0.03, 0.05, 0.07, 0.10, 0.12, 0.15]:
for min_odds in [1.35, 1.50, 1.70]:
r = backtest_value(ms_models, te, clean_feats, "ms",
min_edge=min_edge, min_odds=min_odds,
max_odds=5.0, use_kelly=True)
if r.get("total", 0) >= 20:
invested = sum(b["stake"] for b in r["bets"])
roi = r["pnl"] / invested * 100 if invested > 0 else -100
if roi > best_roi:
best_roi = roi
best_cfg = {"edge": min_edge, "min_odds": min_odds, "result": r}
if best_cfg:
cfg = best_cfg
print(f"\n Best 1X2 Config: edge>{cfg['edge']}, odds>{cfg['min_odds']}")
print_backtest(cfg["result"], "1X2 VALUE")
# Flat bet comparison
print("\n --- Flat Bet Comparison ---")
for edge in [0.05, 0.07, 0.10]:
r = backtest_value(ms_models, te, clean_feats, "ms",
min_edge=edge, min_odds=1.50, max_odds=4.5,
use_kelly=False)
if r.get("total", 0) > 0:
inv = r["total"] * 10
roi = r["pnl"]/inv*100
print(f" Edge>{edge:.2f}: {r['total']} bets, "
f"hit={r['wins']/r['total']*100:.1f}%, ROI={roi:+.1f}%")
# OU25 backtest
if ou_models:
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',
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}')
# 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:
imp = ms_models["lgb"].feature_importance(importance_type="gain")
imp_df = pd.DataFrame({"feature": clean_feats, "importance": imp}
).sort_values("importance", ascending=False)
print("\n TOP 15 FEATURES (no odds!):")
for _, r in imp_df.head(15).iterrows():
print(f" {r['feature']:40s} {r['importance']:.0f}")
imp_df.to_csv(MODELS_DIR / "v27_feature_importance.csv", index=False)
# ── Save ──
print("\n" + ""*65)
print(" SAVING MODELS")
print(""*65)
for name, m in ms_models.items():
p = MODELS_DIR / f"v27_ms_{name}.pkl"
with open(p, "wb") as f:
pickle.dump(m, f)
print(f"{p.name}")
if ou_models:
for name, m in ou_models.items():
p = MODELS_DIR / f'v27_ou25_{name}.pkl'
with open(p, 'wb') as f:
pickle.dump(m, f)
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 [])
+ (['btts'] if btts_models else [])
),
}
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:
json.dump(clean_feats, f, indent=2)
print(f' ✓ metadata + feature_cols')
print(f"\n Total time: {(time.time()-t0)/60:.1f} min")
print(" DONE!")
if __name__ == "__main__":
main()
+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
+391 -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."""
@@ -36,6 +65,11 @@ class FeatureEnrichmentService:
'avg_goals': 2.5,
'btts_rate': 0.5,
'over25_rate': 0.5,
# V27 expanded
'home_goals_avg': 1.3,
'away_goals_avg': 1.1,
'recent_trend': 0.0,
'venue_advantage': 0.0,
}
_DEFAULT_FORM = {
'clean_sheet_rate': 0.2,
@@ -53,6 +87,25 @@ class FeatureEnrichmentService:
_DEFAULT_LEAGUE = {
'avg_goals': 2.7,
'zero_goal_rate': 0.07,
# V27 expanded
'home_win_rate': 0.46,
'draw_rate': 0.26,
'btts_rate': 0.50,
'ou25_rate': 0.50,
'reliability_score': 0.0,
}
_DEFAULT_ROLLING = {
'rolling5_goals': 1.3,
'rolling5_conceded': 1.2,
'rolling10_goals': 1.3,
'rolling10_conceded': 1.2,
'rolling20_goals': 1.3,
'rolling20_conceded': 1.2,
'rolling5_cs': 0.2,
}
_DEFAULT_VENUE = {
'venue_goals': 1.4,
'venue_conceded': 1.1,
}
# ─── 1. Team Stats ──────────────────────────────────────────────
@@ -186,6 +239,13 @@ class FeatureEnrichmentService:
total_goals = 0
btts_count = 0
over25_count = 0
# V27 expanded trackers
home_team_goals_list = []
away_team_goals_list = []
home_team_venue_wins = 0
home_team_venue_total = 0
away_team_venue_wins = 0
away_team_venue_total = 0
for row in rows:
sh = int(row['score_home'])
@@ -195,14 +255,22 @@ class FeatureEnrichmentService:
# Normalise: who is "home team" in THIS prediction context
if str(row['home_team_id']) == home_team_id:
home_team_goals_list.append(sh)
away_team_goals_list.append(sa)
home_team_venue_total += 1
if sh > sa:
home_wins += 1
home_team_venue_wins += 1
elif sh == sa:
draws += 1
else:
# Reversed fixture: away_team was at home
home_team_goals_list.append(sa)
away_team_goals_list.append(sh)
away_team_venue_total += 1
if sa > sh:
home_wins += 1
away_team_venue_wins += 1
elif sh == sa:
draws += 1
@@ -211,6 +279,29 @@ class FeatureEnrichmentService:
if match_goals > 2:
over25_count += 1
# V27: recent_trend = last-5 home_win_rate - first-5 home_win_rate
recent_trend = 0.0
if total >= 6:
recent_5_wins = sum(
1 for r in rows[:5]
if (str(r['home_team_id']) == home_team_id and int(r['score_home']) > int(r['score_away']))
or (str(r['home_team_id']) != home_team_id and int(r['score_away']) > int(r['score_home']))
)
older_5_wins = sum(
1 for r in rows[-5:]
if (str(r['home_team_id']) == home_team_id and int(r['score_home']) > int(r['score_away']))
or (str(r['home_team_id']) != home_team_id and int(r['score_away']) > int(r['score_home']))
)
recent_trend = (recent_5_wins - older_5_wins) / 5.0
# V27: venue_advantage = home_win_rate_at_home - home_win_rate_away
venue_advantage = 0.0
if home_team_venue_total > 0 and away_team_venue_total > 0:
venue_advantage = (
home_team_venue_wins / home_team_venue_total
- away_team_venue_wins / away_team_venue_total
)
return {
'total_matches': total,
'home_win_rate': home_wins / total,
@@ -218,6 +309,11 @@ class FeatureEnrichmentService:
'avg_goals': total_goals / total,
'btts_rate': btts_count / total,
'over25_rate': over25_count / total,
# V27 expanded
'home_goals_avg': _safe_avg(home_team_goals_list, 1.3),
'away_goals_avg': _safe_avg(away_team_goals_list, 1.1),
'recent_trend': round(recent_trend, 4),
'venue_advantage': round(venue_advantage, 4),
}
# ─── 3. Form & Streaks ──────────────────────────────────────────
@@ -313,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)
@@ -392,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(
@@ -433,6 +627,10 @@ class FeatureEnrichmentService:
total = len(rows)
total_goals = 0
zero_goal_matches = 0
home_wins = 0
draw_count = 0
btts_count = 0
over25_count = 0
for row in rows:
sh = int(row['score_home'])
@@ -441,10 +639,24 @@ class FeatureEnrichmentService:
total_goals += match_goals
if match_goals == 0:
zero_goal_matches += 1
if sh > sa:
home_wins += 1
elif sh == sa:
draw_count += 1
if sh > 0 and sa > 0:
btts_count += 1
if match_goals > 2:
over25_count += 1
return {
'avg_goals': total_goals / total,
'zero_goal_rate': zero_goal_matches / total,
# V27 expanded
'home_win_rate': home_wins / total,
'draw_rate': draw_count / total,
'btts_rate': btts_count / total,
'ou25_rate': over25_count / total,
'reliability_score': min(total / 50.0, 1.0),
}
# ─── 6. Momentum ───────────────────────────────────────────────
@@ -514,6 +726,161 @@ class FeatureEnrichmentService:
return round(weighted_score / max_possible, 4)
# ─── 7. Rolling Stats (V27) ─────────────────────────────────────
def compute_rolling_stats(
self,
cur: RealDictCursor,
team_id: str,
before_date_ms: int,
) -> Dict[str, float]:
"""
Rolling goal averages and clean-sheet rates over the last 5/10/20 matches.
Single DB query, three windows computed programmatically.
"""
if not team_id:
return dict(self._DEFAULT_ROLLING)
try:
cur.execute(
"""
SELECT
m.home_team_id,
m.score_home,
m.score_away
FROM matches m
WHERE (m.home_team_id = %s OR m.away_team_id = %s)
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 20
""",
(team_id, team_id, before_date_ms),
)
rows = cur.fetchall()
except Exception:
return dict(self._DEFAULT_ROLLING)
if not rows:
return dict(self._DEFAULT_ROLLING)
goals = []
conceded = []
clean_sheets = []
for row in rows:
is_home = str(row['home_team_id']) == team_id
gf = int(row['score_home'] if is_home else row['score_away'])
ga = int(row['score_away'] if is_home else row['score_home'])
goals.append(gf)
conceded.append(ga)
clean_sheets.append(1 if ga == 0 else 0)
n = len(goals)
return {
'rolling5_goals': _safe_avg(goals[:5], 1.3),
'rolling5_conceded': _safe_avg(conceded[:5], 1.2),
'rolling10_goals': _safe_avg(goals[:min(10, n)], 1.3),
'rolling10_conceded': _safe_avg(conceded[:min(10, n)], 1.2),
'rolling20_goals': _safe_avg(goals[:n], 1.3),
'rolling20_conceded': _safe_avg(conceded[:n], 1.2),
'rolling5_cs': _safe_avg(clean_sheets[:5], 0.2),
}
# ─── 8. Venue Stats (V27) ──────────────────────────────────────
def compute_venue_stats(
self,
cur: RealDictCursor,
team_id: str,
before_date_ms: int,
is_home: bool = True,
) -> Dict[str, float]:
"""
Team goals scored/conceded at specific venue (home or away only).
"""
if not team_id:
return dict(self._DEFAULT_VENUE)
venue_col = 'home_team_id' if is_home else 'away_team_id'
try:
cur.execute(
f"""
SELECT m.score_home, m.score_away
FROM matches m
WHERE m.{venue_col} = %s
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 20
""",
(team_id, before_date_ms),
)
rows = cur.fetchall()
except Exception:
return dict(self._DEFAULT_VENUE)
if not rows:
return dict(self._DEFAULT_VENUE)
goals = []
conceded_list = []
for row in rows:
sh = int(row['score_home'])
sa = int(row['score_away'])
if is_home:
goals.append(sh)
conceded_list.append(sa)
else:
goals.append(sa)
conceded_list.append(sh)
return {
'venue_goals': _safe_avg(goals, 1.4),
'venue_conceded': _safe_avg(conceded_list, 1.1),
}
# ─── 9. Days Rest (V27) ────────────────────────────────────────
def compute_days_rest(
self,
cur: RealDictCursor,
team_id: str,
before_date_ms: int,
) -> float:
"""
Returns number of days since the team's last match.
Default: 7.0 (one-week rest).
"""
if not team_id:
return 7.0
try:
cur.execute(
"""
SELECT m.mst_utc
FROM matches m
WHERE (m.home_team_id = %s OR m.away_team_id = %s)
AND m.status = 'FT'
AND m.mst_utc < %s
ORDER BY m.mst_utc DESC
LIMIT 1
""",
(team_id, team_id, before_date_ms),
)
row = cur.fetchone()
except Exception:
return 7.0
if not row or not row.get('mst_utc'):
return 7.0
last_match_ms = int(row['mst_utc'])
diff_days = (before_date_ms - last_match_ms) / (1000 * 86400)
return round(max(0.0, min(diff_days, 30.0)), 1)
# ─── Utility ────────────────────────────────────────────────────────
def _safe_avg(values: list, default: float) -> float:
+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
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,766 +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.basketball_predictor = MagicMock()
orchestrator.dsn = "postgresql://unit-test"
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)
+63 -50
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,41 +44,46 @@ 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) |
| V20+ Response Alanı | CardDto Alanı |
| ----------------------- | ---------------------------------------------- |
| `score_prediction.ht` | `htScore` (ör: "1-1") |
| `score_prediction.ft` | `ftScore` (ör: "2-1") |
| `main_pick.confidence` | `scoreConfidence` (ör: 65) |
| `bet_summary[]` (array) | `topPicks[]` (ilk 3, confidence'a göre sıralı) |
| `risk.level` | `riskLevel` (LOW/MEDIUM/HIGH/EXTREME) |
| `match_info.home_team` | `homeTeam` (fallback) |
| `risk.level` | `riskLevel` (LOW/MEDIUM/HIGH/EXTREME) |
| `match_info.home_team` | `homeTeam` (fallback) |
**Bet Summary Market Kodları:**
| Kod | Türkçe | English |
|---|---|---|
| MS | Maç Sonucu | Match Result |
| OU15 | Üst 1.5 Gol | Over 1.5 |
| OU25 | Üst 2.5 Gol | Over 2.5 |
| OU35 | Üst 3.5 Gol | Over 3.5 |
| BTTS | Karşılıklı Gol | Both Teams Score |
| DC | Çifte Şans | Double Chance |
| HT | İlk Yarı Sonucu | Half Time Result |
| HT_OU05 | İY 0.5 Üst/Alt | HT Over/Under 0.5 |
| OE | Tek/Çift | Odd/Even |
| HTFT | İY/MS | HT/FT |
| Kod | Türkçe | English |
| ------- | --------------- | ----------------- |
| MS | Maç Sonucu | Match Result |
| OU15 | Üst 1.5 Gol | Over 1.5 |
| OU25 | Üst 2.5 Gol | Over 2.5 |
| OU35 | Üst 3.5 Gol | Over 3.5 |
| BTTS | Karşılıklı Gol | Both Teams Score |
| DC | Çifte Şans | Double Chance |
| HT | İlk Yarı Sonucu | Half Time Result |
| HT_OU05 | İY 0.5 Üst/Alt | HT Over/Under 0.5 |
| OE | Tek/Çift | Odd/Even |
| HTFT | İY/MS | HT/FT |
### 4.2 ImageRendererService
@@ -89,6 +94,7 @@ axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, { timeout: 30000 }
**Boyut:** 1080×1920 px (Instagram Story / Reels uyumlu)
**Özellikler:**
- Koyu gradient arka plan (#0a0e27#1a1040#0d1b2a)
- Lig adı + tarih başlık satırı
- Takım logoları (200×200px) — `public/uploads/teams/` altından okunur
@@ -100,6 +106,7 @@ axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, { timeout: 30000 }
- Alt bilgi: "⚡ AI Powered by SuggestBet"
**Logo Çözümleme:**
```
1. Yerel dosya varsa → public/uploads/teams/xxx.png oku
2. URL http ile başlıyorsa → HTTP ile indir
@@ -118,10 +125,10 @@ Gemini API kullanarak maç verisi JSON'ından Türkçe post metni üretir.
## 5. API Endpointleri
| Method | Path | Auth | Açıklama |
|---|---|---|---|
| GET | `/api/social-poster/preview/:matchId` | @Public | Sadece görsel üret + caption üret (paylaşma) |
| POST | `/api/social-poster/post/:matchId` | @Public | Görsel üret + caption üret + tüm platformlara paylaş |
| Method | Path | Auth | Açıklama |
| ------ | ------------------------------------- | ------- | ---------------------------------------------------- |
| GET | `/api/social-poster/preview/:matchId` | @Public | Sadece görsel üret + caption üret (paylaşma) |
| POST | `/api/social-poster/post/:matchId` | @Public | Görsel üret + caption üret + tüm platformlara paylaş |
> **Not:** Test endpointleri `@Public()` dekoratörüyle auth bypass edilmiştir. Production'da kaldırılmalı veya admin-only yapılmalıdır.
@@ -129,14 +136,20 @@ Gemini API kullanarak maç verisi JSON'ından Türkçe post metni üretir.
## 6. Environment Değişkenleri
| Key | Zorunlu | Varsayılan | Açıklama |
|---|---|---|---|
| `AI_ENGINE_URL` | ✅ | `http://localhost:8000` | AI Engine base URL |
| `APP_BASE_URL` | ✅ | `http://localhost:3000` | Logo URL çözümleme için |
| `SOCIAL_POSTER_ENABLED` | ❌ | `false` | Cron job'ı aktif/pasif |
| `GOOGLE_API_KEY` | ❌ | — | Gemini caption için |
| Twitter API keys | ❌ | — | Twitter paylaşım için |
| Meta API keys | ❌ | — | FB/IG paylaşım için |
| Key | Zorunlu | Varsayılan | Açıklama |
| --------------------------------------------- | ------- | ------------------------ | -------------------------------------------------------------------- |
| `AI_ENGINE_URL` | ✅ | `http://localhost:8000` | AI Engine base URL |
| `APP_BASE_URL` | ✅ | `http://localhost:3000` | Meta'nın çekebileceği public görsel URL'i ve logo URL çözümleme için |
| `SOCIAL_POSTER_ENABLED` | ❌ | `false` | Cron job'ı aktif/pasif |
| `SOCIAL_POSTER_SPORTS` | ❌ | `football,basketball` | Otomatik paylaşılacak sporlar |
| `SOCIAL_POSTER_WINDOW_MIN` | ❌ | `25` | Başlama zaman penceresi alt sınırı (dakika) |
| `SOCIAL_POSTER_WINDOW_MAX` | ❌ | `45` | Başlama zaman penceresi üst sınırı (dakika) |
| `OLLAMA_BASE_URL` | ❌ | `http://localhost:11434` | Lokal LLM endpoint'i |
| `OLLAMA_MODEL` / `SOCIAL_POSTER_OLLAMA_MODEL` | ❌ | — | Caption üretiminde kullanılacak lokal model |
| `GOOGLE_API_KEY` | ❌ | — | Gemini caption için |
| Twitter API keys | ❌ | — | X medya upload + `/2/tweets` paylaşımı için OAuth 1.0a user context |
| `META_GRAPH_API_VERSION` | ❌ | `v25.0` | Meta Graph API sürümü |
| Meta API keys | ❌ | — | FB/IG paylaşım için |
---
@@ -144,9 +157,9 @@ Gemini API kullanarak maç verisi JSON'ından Türkçe post metni üretir.
```json
{
"canvas": "^2.x", // Node Canvas — görsel üretimi
"axios": "^1.x", // HTTP istekleri (AI Engine + logo indirme)
"@nestjs/schedule": "*" // Cron job desteği
"canvas": "^2.x", // Node Canvas — görsel üretimi
"axios": "^1.x", // HTTP istekleri (AI Engine + logo indirme)
"@nestjs/schedule": "*" // Cron job desteği
}
```
@@ -165,10 +178,10 @@ RUN apk add --no-cache cairo-dev pango-dev jpeg-dev giflib-dev librsvg-dev
### Port Yönetimi
| Servis | Port |
|---|---|
| NestJS Backend | 3000 (production: 150X) |
| AI Engine | 8000 (dev: 8005 — Windows port kısıtlaması) |
| Servis | Port |
| -------------- | ------------------------------------------- |
| NestJS Backend | 3000 (production: 150X) |
| AI Engine | 8000 (dev: 8005 — Windows port kısıtlaması) |
### Dosya Sistemi
@@ -182,9 +195,9 @@ public/
## 9. Bilinen Sorunlar & Çözümler
| Sorun | Sebep | Çözüm |
|---|---|---|
| `WinError 10013` port erişim hatası | Windows Hyper-V port rezervasyonu | Farklı port kullan (8005) |
| `Invalid prisma.liveMatch.findUnique()` | Prisma client eskimiş | `npx prisma generate` çalıştır |
| `405 Method Not Allowed` AI Engine | GET yerine POST gerekiyor | `axios.post()` kullan |
| Logolar görünmüyor (lokal dev) | Logo dosyaları sunucuda, lokalde yok | Deploy'da çalışır, lokal'de graceful skip |
| Sorun | Sebep | Çözüm |
| --------------------------------------- | ------------------------------------ | ----------------------------------------- |
| `WinError 10013` port erişim hatası | Windows Hyper-V port rezervasyonu | Farklı port kullan (8005) |
| `Invalid prisma.liveMatch.findUnique()` | Prisma client eskimiş | `npx prisma generate` çalıştır |
| `405 Method Not Allowed` AI Engine | GET yerine POST gerekiyor | `axios.post()` kullan |
| Logolar görünmüyor (lokal dev) | Logo dosyaları sunucuda, lokalde yok | Deploy'da çalışır, lokal'de graceful skip |
+155
View File
@@ -0,0 +1,155 @@
# Changelog - 2026-04-22
Bu doküman, 22 Nisan 2026 tarihinde `iddaai-fe` ve `iddaai-be` üzerinde yapılan Frekans Motoru (Conditional Frequency Engine) frontend entegrasyonunu özetler.
## 1. Frekans Motoru — Backend Recap
- `POST /coupon/frequency-coupon` endpoint'i önceki oturumda tamamlanmıştı.
- `SmartCouponService.generateFrequencyBasedCoupon()` metodu aktif ve çalışır durumda.
- `FrequencyEngineService` → raw SQL ile `matches` tablosundaki tarihsel veriyi tarayarak oran bandı bazlı sinyal üretiyor.
- Strateji: Her takımın ev/deplasman performansını, karşılaştığı oran bandına göre filtreleyip, kombine sinyal (combined_signal) hesaplıyor.
## 2. Frontend Tip Tanımları
- `iddaai-fe/src/lib/api/coupons/types.ts` güncellendi.
- Eklenen tipler:
### FrequencyCouponRequestDto
```typescript
{
maxMatches?: number; // 2-5 arası, varsayılan 3
minSignal?: number; // 0.50-0.99, kombine sinyal eşiği
markets?: string[]; // OU1.5, OU2.5, OU3.5, BTTS, MS
}
```
### FrequencyCouponBetDto
```typescript
{
match_id: string;
match_name: string;
league: string;
market: string;
pick: string;
odds: number;
home_signal: number;
away_signal: number;
combined_signal: number;
home_odds_band: string;
away_odds_band: string;
home_match_count: number;
away_match_count: number;
league_profile: string; // GOLCU | DEFANSIF | NORMAL
}
```
### FrequencyCouponRejectedDto
```typescript
{
match_name: string;
reason: string;
}
```
### FrequencyCouponResultDto
```typescript
{
bets: FrequencyCouponBetDto[];
rejected_matches: FrequencyCouponRejectedDto[];
reasoning: string[];
total_odds: number;
expected_hit_rate: number;
expected_value: number;
ev_positive: boolean;
}
```
## 3. API Service Katmanı
- `iddaai-fe/src/lib/api/coupons/service.ts` güncellendi.
- `generateFrequencyCoupon(dto)` metodu eklendi.
- Endpoint: `POST /coupon/frequency-coupon`
## 4. React Hook
- `iddaai-fe/src/lib/api/coupons/use-hooks.ts` güncellendi.
- `useGenerateFrequencyCoupon()` TanStack Query mutation hook'u eklendi.
- `FrequencyCouponRequestDto` import edildi.
## 5. Çeviri Dosyaları (i18n)
- `messages/tr.json` ve `messages/en.json` güncellendi.
- `coupons` namespace'ine 30+ yeni anahtar eklendi:
| Anahtar | TR | EN |
|---|---|---|
| `freq-engine-title` | Frekans Motoru | Frequency Engine |
| `freq-engine-subtitle` | Takımların oran bandına göre tarihsel performansını analiz eder... | Analyzes teams' historical performance by odds band... |
| `freq-suggest` | Frekans Kuponu Oluştur | Generate Frequency Coupon |
| `freq-min-signal` | Minimum Sinyal | Minimum Signal |
| `freq-ev-label` | Beklenen Değer (EV) | Expected Value (EV) |
| `freq-hit-rate` | Tahmini İsabet | Est. Hit Rate |
| `freq-ev-positive` | +EV Pozitif | +EV Positive |
| `freq-combined-signal` | Kombine Sinyal | Combined Signal |
| `freq-league-golcu` | Golcü | High-Scoring |
| `freq-league-defansif` | Defansif | Defensive |
| `engine-mode-label` | Motor Seçimi | Engine Mode |
| `engine-mode-help` | AI: Gemini tabanlı yapay zeka tahmini. Frekans: Veritabanı tabanlı istatistiksel analiz. | AI: Gemini-based AI prediction. Frequency: Database-driven statistical analysis. |
| `freq-mode-active` | Frekans Motoru aktif | Frequency Engine active |
| `ai-mode-active` | AI Motoru aktif | AI Engine active |
## 6. FrequencyPanel Bileşeni (Yeni Dosya)
- `iddaai-fe/src/components/coupons/frequency-panel.tsx` oluşturuldu.
- Bağımsız (standalone) bileşen, kendi state ve mutation yönetimini içerir.
### Bileşen Özellikleri:
1. **Min Signal Slider** — 50%-95% arası, kombine sinyal eşiği kontrolü
2. **Max Matches Slider** — 2-5 arası, kupon boyutu kontrolü
3. **Market Filtre Badge'leri** — OU1.5, OU2.5, OU3.5, BTTS, MS (çoklu seçim)
4. **Generate Butonu**`useGenerateFrequencyCoupon` mutation'ını tetikler
5. **Sonuç Paneli**:
- EV / Hit Rate / Toplam Oran istatistik kartları
- Her bahis için ev sinyali, deplasman sinyali, kombine sinyal gösterimi
- Oran bandı bilgisi (ör. "1.30-1.50")
- Lig profili badge'i (Golcü/Defansif/Normal)
- Geçmiş maç sayısı gösterimi
- Analiz detayları (reasoning listesi)
- Elenen maçlar (rejected_matches)
6. **Kupon Store Senkronizasyonu** — Sonuç geldiğinde bahisler otomatik olarak `useCouponStore`'a eklenir
## 7. Coupon Builder Engine Toggle
- `iddaai-fe/src/components/coupons/coupon-builder-content.tsx` güncellendi.
- Değişiklikler:
- `LuDatabase` icon import edildi
- `FrequencyPanel` import edildi
- `engineMode` state eklendi: `"ai" | "frequency"`
- Sidebar'a **Motor Seçimi** toggle eklendi (Badge tabanlı)
- `engineMode === "frequency"` olduğunda strateji/AI suggest bölümü gizlenir, yerine `FrequencyPanel` render edilir
- `engineMode === "ai"` olduğunda mevcut AI akışı aynen korunur
### Veri Akışı:
```
Kullanıcı → "Frekans" badge'ine tıklar → FrequencyPanel açılır
→ Sinyal/market/boyut ayarı yapar → "Frekans Kuponu Oluştur" butonuna basar
→ POST /coupon/frequency-coupon { maxMatches, minSignal, markets }
→ Backend: SmartCouponService → FrequencyEngineService (raw SQL)
→ Response: FrequencyCouponResultDto
→ UI: Sinyal kartları, EV istatistikleri, reasoning render edilir
→ Bahisler otomatik olarak CouponStore'a sync edilir
```
## 8. Derleme ve Doğrulama Notları
- `node_modules` kullanıcının makinesinde yüklü olmadığı için `npm run build` çalıştırılamadı.
- Kod yapısal olarak doğru, TypeScript tipleri backend DTO'ları ile birebir eşleşiyor.
- Doğrulama için: `npm install && npm run build` çalıştırılmalı.
## 9. Açık Kalan / Sonraki Adımlar
- `npm install && npm run build` ile frontend build doğrulanmalı.
- Frekans kuponu uçtan uca test edilmeli (backend Docker ayakta iken).
- FrequencyPanel içindeki market badge'lerine `HT_OU05` ve `DC` gibi ek marketler eklenebilir.
- Frekans sonuçlarındaki `league_profile` badge renkleri dark mode için ince ayar gerektirebilir.
- Kupon geçmişinde AI vs Frekans ayrımını gösteren bir etiket eklenebilir.
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",
+11 -4
View File
@@ -22,13 +22,20 @@
"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",
"ai:backtest:v26:roi": "python3 ai-engine/scripts/backtest_v26_shadow_roi_detail.py",
"ai:backtest:v26:htft": "python3 ai-engine/scripts/backtest_v26_shadow_htft_upset.py",
"ai:test": "python3 -m pytest ai-engine/tests/test_main_api.py ai-engine/tests/test_single_match_orchestrator.py ai-engine/tests/test_v26_shadow_engine.py"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.964.0",
@@ -48,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",
@@ -68,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",
@@ -0,0 +1,19 @@
CREATE TABLE "prediction_runs" (
"id" BIGSERIAL NOT NULL,
"match_id" TEXT NOT NULL,
"engine_version" TEXT NOT NULL,
"decision_trace_id" TEXT,
"generated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"odds_snapshot" JSONB,
"payload_summary" JSONB NOT NULL,
"eventual_outcome" TEXT,
"unit_profit" DOUBLE PRECISION,
CONSTRAINT "prediction_runs_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "prediction_runs_match_id_generated_at_idx"
ON "prediction_runs"("match_id", "generated_at" DESC);
CREATE INDEX "prediction_runs_engine_version_generated_at_idx"
ON "prediction_runs"("engine_version", "generated_at" DESC);
+49 -2
View File
@@ -489,6 +489,22 @@ model Prediction {
@@map("predictions")
}
model PredictionRun {
id BigInt @id @default(autoincrement())
matchId String @map("match_id")
engineVersion String @map("engine_version")
decisionTraceId String? @map("decision_trace_id")
generatedAt DateTime @default(now()) @map("generated_at")
oddsSnapshot Json? @map("odds_snapshot")
payloadSummary Json @map("payload_summary")
eventualOutcome String? @map("eventual_outcome")
unitProfit Float? @map("unit_profit")
@@index([matchId, generatedAt(sort: Desc)])
@@index([engineVersion, generatedAt(sort: Desc)])
@@map("prediction_runs")
}
model AiPredictionsLog {
id Int @id @default(autoincrement())
matchId String @map("match_id")
@@ -527,6 +543,7 @@ model User {
analyses Analysis[]
refreshTokens RefreshToken[]
usageLimit UsageLimit?
subscription Subscription?
coupons UserCoupon[]
totoCoupons TotoCoupon[]
@@ -535,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
@@ -553,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")
@@ -749,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,
},
});
+331 -696
View File
File diff suppressed because it is too large Load Diff
+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,
+12
View File
@@ -0,0 +1,12 @@
import { UserRole } from "@prisma/client";
export const APP_ROLES = {
user: UserRole.user,
superadmin: UserRole.superadmin,
} as const;
export const ADMIN_ROLES = [APP_ROLES.superadmin] as const;
export function normalizeRole(role: string | null | undefined): string {
return role?.trim().toLowerCase() ?? "";
}
+299
View File
@@ -0,0 +1,299 @@
import axios, {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
} from "axios";
import { Logger } from "@nestjs/common";
export type AiCircuitState = "closed" | "open";
export interface AiEngineClientOptions {
baseUrl: string;
logger: Logger;
serviceName: string;
timeoutMs?: number;
maxRetries?: number;
retryDelayMs?: number;
circuitBreakerThreshold?: number;
circuitBreakerCooldownMs?: number;
}
interface AiEngineRequestConfig extends AxiosRequestConfig {
retryCount?: number;
}
export interface AiEngineClientSnapshot {
state: AiCircuitState;
consecutiveFailures: number;
openedAt: string | null;
}
export class AiEngineRequestError extends Error {
status?: number;
detail?: unknown;
isCircuitOpen: boolean;
constructor(
message: string,
options: {
status?: number;
detail?: unknown;
isCircuitOpen?: boolean;
} = {},
) {
super(message);
this.name = "AiEngineRequestError";
this.status = options.status;
this.detail = options.detail;
this.isCircuitOpen = options.isCircuitOpen ?? false;
}
}
export class AiEngineClient {
private readonly axiosClient: AxiosInstance;
private readonly logger: Logger;
private readonly serviceName: string;
private readonly defaultTimeoutMs: number;
private readonly maxRetries: number;
private readonly retryDelayMs: number;
private readonly circuitBreakerThreshold: number;
private readonly circuitBreakerCooldownMs: number;
private consecutiveFailures = 0;
private circuitOpenedAt: number | null = null;
constructor(options: AiEngineClientOptions) {
this.logger = options.logger;
this.serviceName = options.serviceName;
this.defaultTimeoutMs = options.timeoutMs ?? 30000;
this.maxRetries = options.maxRetries ?? 2;
this.retryDelayMs = options.retryDelayMs ?? 750;
this.circuitBreakerThreshold = options.circuitBreakerThreshold ?? 5;
this.circuitBreakerCooldownMs = options.circuitBreakerCooldownMs ?? 15000;
this.axiosClient = axios.create({
baseURL: options.baseUrl,
timeout: this.defaultTimeoutMs,
});
}
async get<T>(
path: string,
config?: AiEngineRequestConfig,
): Promise<AxiosResponse<T>> {
return this.request<T>({
method: "get",
url: path,
...config,
});
}
async post<T>(
path: string,
data?: unknown,
config?: AiEngineRequestConfig,
): Promise<AxiosResponse<T>> {
return this.request<T>({
method: "post",
url: path,
data,
...config,
});
}
getSnapshot(): AiEngineClientSnapshot {
return {
state: this.isCircuitOpen() ? "open" : "closed",
consecutiveFailures: this.consecutiveFailures,
openedAt: this.circuitOpenedAt
? new Date(this.circuitOpenedAt).toISOString()
: null,
};
}
private async request<T>(
config: AiEngineRequestConfig,
): Promise<AxiosResponse<T>> {
this.ensureCircuitAvailable();
const retries = this.resolveRetryCount(config);
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const response = await this.axiosClient.request<T>({
timeout: this.defaultTimeoutMs,
...config,
});
this.resetFailures();
return response;
} catch (error) {
lastError = error;
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);
}
this.logger.warn(
`[${this.serviceName}] AI request retry ${attempt + 1}/${retries} for ${config.method?.toUpperCase()} ${config.url}`,
);
await this.delay(this.retryDelayMs * (attempt + 1));
}
}
this.registerFailure(lastError);
throw this.toRequestError(lastError);
}
private resolveRetryCount(config: AiEngineRequestConfig): number {
if (typeof config.retryCount === "number" && config.retryCount >= 0) {
return config.retryCount;
}
return this.maxRetries;
}
private ensureCircuitAvailable() {
if (!this.isCircuitOpen()) {
return;
}
const remainingCooldown =
this.circuitBreakerCooldownMs -
(Date.now() - (this.circuitOpenedAt ?? 0));
if (remainingCooldown > 0) {
throw new AiEngineRequestError("AI engine circuit breaker is open", {
status: 503,
detail: {
cooldownRemainingMs: remainingCooldown,
},
isCircuitOpen: true,
});
}
this.logger.warn(
`[${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;
}
private isCircuitOpen(): boolean {
return this.circuitOpenedAt !== null;
}
private resetFailures() {
this.consecutiveFailures = 0;
this.circuitOpenedAt = null;
}
private registerFailure(error: unknown) {
this.consecutiveFailures += 1;
const normalizedError = this.toRequestError(error);
this.logger.warn(
`[${this.serviceName}] AI request failed (${this.consecutiveFailures}/${this.circuitBreakerThreshold}): ${normalizedError.message}`,
);
if (this.consecutiveFailures >= this.circuitBreakerThreshold) {
this.circuitOpenedAt = Date.now();
this.logger.error(
`[${this.serviceName}] AI circuit breaker opened after ${this.consecutiveFailures} consecutive failures`,
);
}
}
private isRetriableError(error: unknown): boolean {
if (!axios.isAxiosError(error)) {
return false;
}
if (!error.response) {
return true;
}
const status = error.response.status;
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;
}
if (axios.isAxiosError(error)) {
const detail = error.response?.data ?? error.message;
const status = error.response?.status;
const message = this.buildAxiosErrorMessage(error);
return new AiEngineRequestError(message, {
status,
detail,
});
}
if (error instanceof Error) {
return new AiEngineRequestError(error.message);
}
return new AiEngineRequestError("Unknown AI engine error", {
detail: error,
});
}
private buildAxiosErrorMessage(error: AxiosError): string {
if (error.code === "ECONNABORTED") {
return "AI engine request timed out";
}
if (!error.response) {
return "AI engine is unreachable";
}
const detail =
(error.response.data as Record<string, unknown> | undefined)?.detail ??
error.message;
return typeof detail === "string"
? detail
: `AI engine request failed with status ${error.response.status}`;
}
private async delay(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
}
+203
View File
@@ -0,0 +1,203 @@
type ScoreLikeValue = number | string | null | undefined;
type ScoreLike = {
home?: ScoreLikeValue;
away?: ScoreLikeValue;
} | null;
export interface MatchStatusLike {
state?: string | null;
status?: string | null;
substate?: string | null;
statusBoxContent?: string | null;
scoreHome?: ScoreLikeValue;
scoreAway?: ScoreLikeValue;
score?: ScoreLike;
}
const LIVE_STATUS_TOKENS = [
"live",
"livegame",
"playing",
"half time",
"halftime",
"1h",
"2h",
"ht",
"1q",
"2q",
"3q",
"4q",
];
const LIVE_STATE_TOKENS = [
"live",
"livegame",
"firsthalf",
"secondhalf",
"halftime",
"1h",
"2h",
"ht",
"1q",
"2q",
"3q",
"4q",
];
const FINISHED_STATUS_TOKENS = [
"finished",
"played",
"ft",
"aet",
"pen",
"penalties",
"afterpenalties",
"ended",
"post",
"postgame",
"posted",
];
const FINISHED_STATE_TOKENS = [
"finished",
"post",
"postgame",
"posted",
"ft",
"ended",
];
export const LIVE_STATUS_VALUES_FOR_DB = [
"LIVE",
"live",
"1H",
"2H",
"HT",
"1Q",
"2Q",
"3Q",
"4Q",
"Playing",
"Half Time",
"liveGame",
];
export const LIVE_STATE_VALUES_FOR_DB = [
"live",
"liveGame",
"firsthalf",
"secondhalf",
"halfTime",
"1H",
"2H",
"HT",
"1Q",
"2Q",
"3Q",
"4Q",
];
export const FINISHED_STATUS_VALUES_FOR_DB = [
"Finished",
"Played",
"FT",
"AET",
"PEN",
"Ended",
"post",
"postGame",
"posted",
"Posted",
];
export const FINISHED_STATE_VALUES_FOR_DB = [
"Finished",
"post",
"postGame",
"postgame",
"posted",
"FT",
"Ended",
];
function normalizeToken(value: unknown): string {
return String(value || "")
.trim()
.toLowerCase();
}
function parseScoreValue(value: ScoreLikeValue): number | null {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
export function hasResolvedScore(match: MatchStatusLike): boolean {
const homeScore = parseScoreValue(match.score?.home ?? match.scoreHome);
const awayScore = parseScoreValue(match.score?.away ?? match.scoreAway);
return homeScore !== null && awayScore !== null;
}
export function isMatchLive(match: MatchStatusLike): boolean {
const state = normalizeToken(match.state);
const status = normalizeToken(match.status);
const substate = normalizeToken(match.substate);
return (
LIVE_STATE_TOKENS.includes(state) ||
LIVE_STATUS_TOKENS.includes(status) ||
LIVE_STATE_TOKENS.includes(substate)
);
}
export function isMatchCompleted(match: MatchStatusLike): boolean {
if (normalizeToken(match.statusBoxContent) === "ert") {
return false;
}
const state = normalizeToken(match.state);
const status = normalizeToken(match.status);
const substate = normalizeToken(match.substate);
if (
FINISHED_STATE_TOKENS.includes(state) ||
FINISHED_STATUS_TOKENS.includes(status) ||
FINISHED_STATE_TOKENS.includes(substate)
) {
return true;
}
return hasResolvedScore(match) && !isMatchLive(match);
}
export function deriveStoredMatchStatus(match: MatchStatusLike): string {
if (normalizeToken(match.statusBoxContent) === "ert") {
return "POSTPONED";
}
if (isMatchLive(match)) {
return "LIVE";
}
if (isMatchCompleted(match)) {
return "FT";
}
return "NS";
}
export function getDisplayMatchStatus(match: MatchStatusLike): string {
if (isMatchLive(match)) {
return "LIVE";
}
if (isMatchCompleted(match)) {
return "Finished";
}
return String(match.status || match.state || "NS");
}
+79
View File
@@ -0,0 +1,79 @@
function extractDateParts(date: Date, timeZone: string) {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const parts = formatter.formatToParts(date);
const year = Number(parts.find((part) => part.type === "year")?.value);
const month = Number(parts.find((part) => part.type === "month")?.value);
const day = Number(parts.find((part) => part.type === "day")?.value);
return { year, month, day };
}
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")}`;
}
export function getShiftedDateStringInTimeZone(
daysOffset: number,
timeZone: string,
baseDate: Date = new Date(),
): string {
const { year, month, day } = extractDateParts(baseDate, timeZone);
const shifted = new Date(Date.UTC(year, month - 1, day));
shifted.setUTCDate(shifted.getUTCDate() + daysOffset);
return shifted.toISOString().split("T")[0];
}
function getTimeZoneOffsetMs(date: Date, timeZone: string): number {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone,
timeZoneName: "shortOffset",
});
const offsetLabel =
formatter.formatToParts(date).find((part) => part.type === "timeZoneName")
?.value || "GMT+0";
const match = offsetLabel.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/);
if (!match) return 0;
const sign = match[1] === "-" ? -1 : 1;
const hours = Number(match[2] || "0");
const minutes = Number(match[3] || "0");
return sign * (hours * 60 + minutes) * 60 * 1000;
}
export function getDayBoundsForTimeZone(
dateString: string,
timeZone: string,
): { startMs: number; endMs: number } {
const [year, month, day] = dateString.split("-").map(Number);
const startGuess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
const nextDayGuess = new Date(Date.UTC(year, month - 1, day + 1, 0, 0, 0));
const startOffsetMs = getTimeZoneOffsetMs(startGuess, timeZone);
const nextDayOffsetMs = getTimeZoneOffsetMs(nextDayGuess, timeZone);
const startMs = Date.UTC(year, month - 1, day, 0, 0, 0) - startOffsetMs;
const nextDayStartMs =
Date.UTC(year, month - 1, day + 1, 0, 0, 0) - nextDayOffsetMs;
return {
startMs,
endMs: nextDayStartMs - 1,
};
}
export function getDateOnlyValueForTimeZone(
timeZone: string,
date: Date = new Date(),
): Date {
return new Date(`${getDateStringInTimeZone(date, timeZone)}T00:00:00.000Z`);
}
+20 -1
View File
@@ -22,7 +22,8 @@ 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
JWT_SECRET: z.string().min(32),
@@ -55,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,
+99 -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,
@@ -259,15 +320,23 @@ export class AdminController {
premiumUsers,
totalMatches,
totalPredictions,
totalCoupons,
] = 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(),
]);
return createSuccessResponse({
totalUsers,
activeUsers,
totalPredictions,
totalCoupons,
users: {
total: totalUsers,
active: activeUsers,
+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,
+18 -2
View File
@@ -13,11 +13,13 @@ import {
ROLES_KEY,
PERMISSIONS_KEY,
} from "../../../common/decorators";
import { normalizeRole } from "../../../common/constants/roles";
interface AuthenticatedUser {
id: string;
email: string;
roles: string[];
role?: string;
permissions: string[];
}
@@ -88,11 +90,25 @@ export class RolesGuard implements CanActivate {
const user = req.user as AuthenticatedUser | undefined;
if (!user || !user.roles) {
if (!user) {
return false;
}
const hasRole = requiredRoles.some((role) => user.roles.includes(role));
const normalizedUserRoles = (
user.roles?.length ? user.roles : user.role ? [user.role] : []
).map((role) => normalizeRole(role));
const normalizedRequiredRoles = requiredRoles.map((role) =>
normalizeRole(role),
);
if (normalizedUserRoles.length === 0) {
return false;
}
const hasRole = normalizedRequiredRoles.some((role) =>
normalizedUserRoles.includes(role),
);
if (!hasRole) {
throw new ForbiddenException("PERMISSION_DENIED");
}
+6 -1
View File
@@ -3,6 +3,7 @@ import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config";
import { AuthService, JwtPayload } from "../auth.service";
import { normalizeRole } from "../../../common/constants/roles";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -29,9 +30,13 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
return null;
}
const normalizedRole = normalizeRole(payload.role);
return {
...user,
role: payload.role,
role: normalizedRole,
roles: normalizedRole ? [normalizedRole] : [],
permissions: [],
};
}
}
+109 -4
View File
@@ -27,6 +27,7 @@ import {
AnalyzeMatchDto,
DailyBankoDto,
SuggestCouponDto,
FrequencyCouponDto,
} from "./dto/coupons-request.dto";
import { Public } from "../../common/decorators";
import { JwtAuthGuard } from "../auth/guards/auth.guards"; // Assuming standard guard
@@ -52,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) {
@@ -98,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 || [];
@@ -145,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 || [];
@@ -188,8 +223,43 @@ export class CouponsController {
return { success: true, data: coupon };
}
/**
* POST /coupon/frequency-coupon
* Generate a frequency-based parlay coupon (Conditional Frequency Engine)
*/
@Post("frequency-coupon")
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Generate frequency-based parlay coupon",
description:
"Scans upcoming matches, applies conditional frequency analysis " +
"(team odds-band performance), and builds 2-5 match combos with +EV calculation.",
})
@ApiResponse({ status: 200, description: "Frequency coupon generated" })
async getFrequencyCoupon(@Body() dto: FrequencyCouponDto) {
const coupon = await this.smartCouponService.generateFrequencyBasedCoupon({
matchIds: dto.matchIds,
maxMatches: dto.maxMatches,
minSignal: dto.minSignal,
markets: dto.markets,
});
if (!coupon || coupon.bets.length === 0) {
return {
success: false,
message:
"Frekans analizine uygun yeterli maç bulunamadı. " +
"minSignal değerini düşürmeyi veya daha fazla maç beklemeyi deneyin.",
data: coupon,
};
}
return { success: true, data: coupon };
}
// ============================================
// USER COUPON ENDPOINTS (NEW)
// USER COUPON ENDPOINTS
// ============================================
/**
@@ -201,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);
@@ -215,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 };
@@ -227,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(
+13 -2
View File
@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
import { CouponsController } from "./coupons.controller";
import { SmartCouponService } from "./services/smart-coupon.service";
import { UserCouponService } from "./services/user-coupon.service";
import { FrequencyEngineService } from "./services/frequency-engine.service";
import { CouponsService } from "./coupons.service";
import { DatabaseModule } from "../../database/database.module";
import { ServicesModule } from "../../services/services.module";
@@ -10,7 +11,17 @@ import { MatchesModule } from "../matches/matches.module";
@Module({
imports: [DatabaseModule, ServicesModule, MatchesModule],
controllers: [CouponsController],
providers: [CouponsService, SmartCouponService, UserCouponService],
exports: [CouponsService, SmartCouponService, UserCouponService],
providers: [
CouponsService,
SmartCouponService,
UserCouponService,
FrequencyEngineService,
],
exports: [
CouponsService,
SmartCouponService,
UserCouponService,
FrequencyEngineService,
],
})
export class CouponsModule {}

Some files were not shown because too many files have changed in this diff Show More