Add backtest pipeline, betting_brain filters, score coherence + social v3

betting_brain.py:
- HARD_MIN_SAMPLES=50 floor for calibrator bypass
- ev_edge < 0 + >= 0.20 hard vetoes
- BTTS muted (grid search found no profitable config)
- Per-market optimal envelopes (MS, OU25)
- Score coherence filter: main_pick must agree with score prediction
- HTFT reversal cross-check for MS picks

feature_builder.py / data_loader.py:
- Real home/away_position from data (was hardcoded 10)
- Cup detection wired into UpsetEngine
- _estimate_league_position with 300-day season filter

New scripts:
- diagnostic_backtest.py: per-bet diagnostic backtest with loss patterns
- optimize_filters.py: grid search per-market optimal thresholds
- analyze_backtest_csv.py: root-cause hypothesis testing on CSV
- compare_backtests.py: side-by-side validation with verdict
- test_score_coherence.py: smoke test for coherence filter (20/20 pass)

Reports:
- diagnostic_backtest_20260525_024437 (50-match smoke)
- diagnostic_backtest_20260525_035649 (1000-match in-sample)
- filter_optimization_patch.json (grid search winners per market)

Social poster v3:
- satori + resvg HTML/CSS rendering pipeline
- Twemoji football/basketball + flag SVGs
- caption SEO: 12 curated hashtags per post
- image SEO: descriptive filenames + .json metadata sidecar
- /health, /preview-png, /run-now endpoints

Docs:
- mds/SESSION_HANDOFF.md: full session state for cross-machine continuity
- mds/SOCIAL_POSTER_SETUP.md: API keys + test commands

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 20:43:28 +03:00
parent b619c2454a
commit 988ee2f50d
36 changed files with 5268 additions and 46 deletions
@@ -0,0 +1,51 @@
match_id,match_date,league_id,score_home,score_away,ht_score_home,ht_score_away,market,pick,odds,stake_units,playable,won,unit_profit,raw_confidence,calibrated_confidence,play_score,ev_edge,bet_grade,is_value_sniper,bb_score,bb_action,bb_vetoes,bb_issues,bb_positives,bb_model_prob,bb_implied_prob,bb_model_market_gap,bb_divergence,bb_trap_market,v27_consensus,data_quality_score,data_quality_flags,risk_level,odds_reliability,htft_reversal_prob,htft_top_pick,league_name,is_cup,model_version,decision_reason
5iam9c9dw3ggz3y1ohr9uh53o,2026-05-24,8nbwkj392b0xzssqpw9jwmzdn,0,0,0,0,,,,1.0,False,,0.0,,,,,,False,,,,,,,,,,False,AGREE,0.33,missing_full_ms_odds;lineup_unavailable;lineup_incomplete;missing_referee;ai_features_inferred_from_history,MEDIUM,,,,,False,v28-pro-max,no_bet_conditions_met
8c90p2ft4zxjdck8wlgq1a61g,2026-05-24,a9vrdkelbgif0gtu3wxsr75xo,2,2,0,0,OU25,Üst,1.41,0.2,True,True,0.082,59.5,65.4,58.4,-0.1557,B,True,26.4,BET,,inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed,base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample,0.5955,0.7092,-0.1137,0.0339,True,DISAGREE,0.74,ai_features_inferred_from_history,MEDIUM,0.6618,,,,False,v28-pro-max,betting_brain_approved
9ljz1grea3a8jajif4e9b7bpw,2026-05-24,2wolc27r8z03itcvwp43e38c5,1,1,1,1,BTTS,KG Var,1.62,0.2,True,True,0.124,53.7,69.9,48.4,-0.1476,B,True,1.6,BET,,inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed,base_model_playable;value_sniper_override;strong_historical_sample,0.5371,0.6173,-0.0802,,True,AGREE,0.74,lineup_probable_not_confirmed;missing_referee;ai_features_inferred_from_history,HIGH,0.5592,,,,False,v28-pro-max,betting_brain_approved
1r7iq0nhg2b674jpcm92ragpg,2026-05-24,1zp1du9n4rj36p1ss9zbxtqfb,4,0,1,0,BTTS,KG Var,1.89,0.2,True,False,-0.2,53.2,69.9,52.9,-0.0832,B,True,0.0,BET,,inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed,base_model_playable;value_sniper_override;strong_historical_sample,0.5317,0.5291,0.0026,,True,AGREE,0.74,lineup_probable_not_confirmed;missing_referee;ai_features_inferred_from_history,HIGH,0.5353,,,,False,v28-pro-max,betting_brain_approved
70dgok3yq76g076vemaps0178,2026-05-24,6lwpjhktjhl9g7x2w7njmzva6,2,1,1,0,BTTS,KG Var,1.74,0.2,True,True,0.148,51.9,68.4,60.4,-0.1255,B,True,15.7,BET,,inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed,base_model_playable;value_sniper_override;strong_historical_sample,0.5192,0.5747,-0.0555,,True,AGREE,0.74,ai_features_inferred_from_history,MEDIUM,0.6164,,,,False,v28-pro-max,betting_brain_approved
72q9d4uimmby6g6bor26taz9w,2026-05-24,6jgwiu2gq3dllmrwt45pfdn2z,2,0,2,0,,,,1.0,False,,0.0,,,,,,False,,,,,,,,,,False,AGREE,0.555,missing_full_ms_odds;lineup_probable_not_confirmed;lineup_projection_low_confidence;lineup_incomplete;missing_referee;ai_features_inferred_from_history,LOW,,,,,False,v28-pro-max,no_bet_conditions_met
67dd2t7043kv0yw1zj1buwdg4,2026-05-24,8n9w0n3i9kk05echhtmstn6o9,1,1,0,0,,,,1.0,False,,0.0,,,,,,False,,,,,,,,,,False,AGREE,0.698,missing_full_ms_odds;lineup_probable_not_confirmed;missing_referee;ai_features_inferred_from_history,MEDIUM,,,,,False,v28-pro-max,no_bet_conditions_met
ejdwfph35q57phtfz7jr8st1w,2026-05-24,8ey0ww2zsosdmwr8ehsorh6t7,0,2,0,0,BTTS,KG Var,1.82,0.2,True,False,-0.2,53.2,69.9,63.5,-0.1097,B,True,12.1,BET,,inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed,base_model_playable;value_sniper_override;strong_historical_sample,0.5317,0.5495,-0.0178,,True,DISAGREE,0.74,live_match_pre_match_features;ai_features_inferred_from_history,MEDIUM,0.51,,X/X,,False,v28-pro-max,betting_brain_approved
68y8tlfnilw5trs1oqi4dhfkk,2026-05-24,8n9w0n3i9kk05echhtmstn6o9,2,2,1,1,,,,1.0,False,,0.0,,,,,,False,,,,,,,,,,False,DISAGREE,0.535,missing_full_ms_odds;lineup_probable_not_confirmed;lineup_projection_low_confidence;lineup_incomplete;missing_referee;ai_features_inferred_from_history,MEDIUM,,,,,False,v28-pro-max,no_bet_conditions_met
68fghiwdtwspk1m0ft5mspzx0,2026-05-24,8n9w0n3i9kk05echhtmstn6o9,1,2,1,2,,,,1.0,False,,0.0,,,,,,False,,,,,,,,,,False,DISAGREE,0.602,missing_full_ms_odds;lineup_probable_not_confirmed;lineup_projection_low_confidence;lineup_incomplete;missing_referee;ai_features_inferred_from_history,MEDIUM,,,,,False,v28-pro-max,no_bet_conditions_met
a1kqq0ggywfl6sl4srntxy2hg,2026-05-24,3ww12jab49q8q8mk9avdwjqgk,1,1,1,0,OU25,Üst,1.62,0.2,True,False,-0.2,61.4,65.4,60.6,-0.148,B,True,27.0,BET,,inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed,base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample,0.6144,0.6173,-0.0029,0.0854,True,AGREE,0.74,live_match_pre_match_features;ai_features_inferred_from_history,MEDIUM,0.5961,,,,False,v28-pro-max,betting_brain_approved
a1vpp4i6t61v7qm3dfy6iuj2s,2026-05-24,3ww12jab49q8q8mk9avdwjqgk,2,1,0,0,,,,1.0,False,,0.0,,,,,,False,,,,,,,,,,False,AGREE,0.74,missing_full_ms_odds;live_match_pre_match_features;ai_features_inferred_from_history,LOW,,,,,False,v28-pro-max,no_bet_conditions_met
28rk26zqah60qj7h78qh5beac,2026-05-24,663a54fmymndjeev47qm7d3nf,2,2,2,1,OU25,Üst,1.39,0.2,True,True,0.078,61.1,65.4,57.5,-0.2039,B,True,13.3,BET,,low_reliability_league;inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed,base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample,0.6107,0.7194,-0.1087,0.0577,True,AGREE,0.74,lineup_probable_not_confirmed;missing_referee;ai_features_inferred_from_history,MEDIUM,0.3522,,,,False,v28-pro-max,betting_brain_approved
a26stt54g5ju5ecodpcbcqxw4,2026-05-24,3ww12jab49q8q8mk9avdwjqgk,2,2,1,1,BTTS,KG Var,2.05,0.2,True,True,0.21,53.7,69.9,70.6,0.0354,B,True,33.5,BET,,inferred_statistical_features;triple_value_not_confirmed,base_model_playable;value_sniper_override;usable_historical_sample,0.5371,0.4878,0.0493,,False,AGREE,0.74,live_match_pre_match_features;ai_features_inferred_from_history,MEDIUM,0.5961,,,,False,v28-pro-max,betting_brain_approved
318rrnl9b1vlenjd9hwahpvro,2026-05-24,9z5643nd06afqu01ea2wt8y4g,1,0,0,0,DC,1X,1.12,0.0,False,True,0.0,73.5,73.5,72.9,-0.0553,PASS,True,41.8,WATCH_NO_VALUE,odds_below_minimum,base_model_not_playable;inferred_statistical_features,value_sniper_override;v25_v27_aligned;triple_value_confirmed;usable_historical_sample,0.7351,0.8929,-0.1578,0.031,False,AGREE,0.51,lineup_unavailable;lineup_incomplete;missing_referee;ai_features_inferred_from_history,MEDIUM,0.8734,,,,False,v28-pro-max,betting_brain_no_value_odds_below_minimum
65tlfs3m6sc70261z37i90jys,2026-05-24,89ovpy1rarewwzqvi30bfdr8b,4,3,1,1,OU25,Üst,1.36,0.2,True,True,0.072,60.2,65.4,53.5,-0.2107,B,True,24.8,BET,,inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed,base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample,0.6018,0.7353,-0.1335,0.0982,True,AGREE,0.74,live_match_pre_match_features;ai_features_inferred_from_history,MEDIUM,0.7068,,,,False,v28-pro-max,betting_brain_approved
ei6nilo5074tnb17kvv37oy6s,2026-05-24,3j81qr7yc4gdnakfwnxf95ovh,1,1,1,1,MS,2,1.11,0.0,False,False,0.0,75.5,90.9,66.9,-0.0497,PASS,True,0.0,WATCH_NO_VALUE,odds_below_minimum,base_model_not_playable;inferred_statistical_features;v25_v27_soft_disagreement;triple_value_not_confirmed,value_sniper_override;strong_historical_sample,0.7554,0.9009,-0.1455,0.204,False,AGREE,0.51,lineup_probable_not_confirmed;lineup_projection_low_confidence;lineup_incomplete;missing_referee;ai_features_inferred_from_history,MEDIUM,0.8771,0.0,,,False,v28-pro-max,betting_brain_no_value_odds_below_minimum
cx8nb7w3nmell38mta1umh2qc,2026-05-24,54c65mhi143utomzvvv3q2avh,0,2,0,0,,,,1.0,False,,0.0,,,,,,False,,,,,,,,,,False,AGREE,0.726,missing_full_ms_odds;lineup_probable_not_confirmed;missing_referee;ai_features_inferred_from_history,MEDIUM,,,,,False,v28-pro-max,no_bet_conditions_met
53348iqniod61z7xurb4tx250,2026-05-24,477yyajzheg2z8u7uick0e13e,2,0,0,0,OU25,Üst,1.93,0.2,True,False,-0.2,59.5,65.4,71.3,0.0414,B,True,45.8,BET,,inferred_statistical_features;triple_value_not_confirmed,base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample,0.5955,0.5181,0.0774,0.1084,False,AGREE,0.74,lineup_probable_not_confirmed;ai_features_inferred_from_history,MEDIUM,0.4706,,,,False,v28-pro-max,betting_brain_approved
2hw717s7fxi2v2w53kdsplhqs,2026-05-24,8najqkluatpaxvqws78b9s17c,1,1,0,0,DC,12,1.24,0.0,False,False,0.0,70.0,70.0,61.2,-0.12,PASS,True,0.0,WATCH_NO_VALUE,odds_below_minimum,base_model_not_playable;inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed,value_sniper_override;v25_v27_aligned;strong_historical_sample,0.7001,0.8065,-0.1064,0.0075,True,AGREE,0.51,lineup_unavailable;lineup_incomplete;missing_referee;ai_features_inferred_from_history,HIGH,0.5082,,,,False,v28-pro-max,betting_brain_no_value_odds_below_minimum
1q1s55dy4d4z4gs34qk6vx9n8,2026-05-24,cegl2ivkc25blcatxp4jmk1ec,1,0,1,0,OU25,Üst,2.03,0.2,True,False,-0.2,50.0,57.7,54.3,0.0546,B,True,35.7,BET,,inferred_statistical_features;triple_value_not_confirmed,base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample,0.5,0.4926,0.0074,0.0555,False,AGREE,0.74,lineup_probable_not_confirmed;missing_referee;ai_features_inferred_from_history,HIGH,0.5993,,,,False,v28-pro-max,betting_brain_approved
648u8zd49cwcxpspmvinlmexg,2026-05-24,1eruend45vd20g9hbrpiggs5u,1,0,0,0,MS,1,1.32,0.2,True,True,0.064,63.1,65.5,55.7,-0.0828,B,True,39.4,BET,,inferred_statistical_features;triple_value_not_confirmed,base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample,0.6313,0.7576,-0.1263,0.0683,False,AGREE,0.74,lineup_probable_not_confirmed;missing_referee;ai_features_inferred_from_history,MEDIUM,0.8083,0.0,,,False,v28-pro-max,betting_brain_approved
1psnufak57w8dfs9e5cvbmgwk,2026-05-24,cegl2ivkc25blcatxp4jmk1ec,3,0,2,0,BTTS,KG Var,1.94,0.2,True,False,-0.2,53.7,69.9,53.1,-0.0765,B,True,5.3,BET,,inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed,base_model_playable;value_sniper_override;strong_historical_sample,0.5371,0.5155,0.0216,,True,AGREE,0.74,lineup_probable_not_confirmed;missing_referee;ai_features_inferred_from_history,HIGH,0.5993,,,,False,v28-pro-max,betting_brain_approved
921kqaviappxt0w1kfmq1ek2c,2026-05-24,byu00jvt1j6csyv4y1lkt2fm2,1,0,1,0,DC,X2,1.18,0.0,False,False,0.0,75.8,75.8,74.9,-0.1267,PASS,True,0.0,WATCH_NO_VALUE,odds_below_minimum,inferred_statistical_features;v25_v27_soft_disagreement;trap_market_market_overpriced;triple_value_not_confirmed;engine_consensus_disagree,base_model_playable;value_sniper_override;strong_historical_sample,0.7584,0.8475,-0.0891,0.1783,True,DISAGREE,0.74,ai_features_inferred_from_history,MEDIUM,0.5359,,,,False,v28-pro-max,betting_brain_no_value_odds_below_minimum
3m11hvh2fzailt3ykd0uhzz84,2026-05-24,54c65mhi143utomzvvv3q2avh,0,0,0,0,,,,1.0,False,,0.0,,,,,,False,,,,,,,,,,False,AGREE,0.591,missing_full_ms_odds;lineup_probable_not_confirmed;lineup_projection_low_confidence;lineup_incomplete;missing_referee;ai_features_inferred_from_history,MEDIUM,,,,,False,v28-pro-max,no_bet_conditions_met
7kvvf6blnps2xk15100ccdedw,2026-05-24,4zwgbb66rif2spcoeeol2motx,5,0,3,0,BTTS,KG Var,1.33,0.2,True,False,-0.2,57.1,69.9,62.3,-0.1512,B,True,29.1,BET,,inferred_statistical_features;triple_value_not_confirmed,base_model_playable;value_sniper_override;strong_historical_sample,0.5707,0.7519,-0.1812,,False,AGREE,0.74,live_match_pre_match_features;ai_features_inferred_from_history,MEDIUM,0.6995,,1/1,,False,v28-pro-max,betting_brain_approved
7liir8zj32o7m2udr7cknb8d0,2026-05-24,4zwgbb66rif2spcoeeol2motx,3,0,2,0,OU25,Üst,1.33,0.2,True,True,0.066,61.4,65.4,58.3,-0.1437,B,True,27.0,BET,,inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed,base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample,0.6144,0.7519,-0.1375,0.0279,True,AGREE,0.74,live_match_pre_match_features;ai_features_inferred_from_history,MEDIUM,0.6995,,1/1,,False,v28-pro-max,betting_brain_approved
7l74ilyz7olljclexvn8tbjtg,2026-05-24,4zwgbb66rif2spcoeeol2motx,5,1,4,0,BTTS,KG Var,1.55,0.2,True,True,0.11,57.1,69.9,68.2,-0.0873,B,True,32.7,BET,,inferred_statistical_features;triple_value_not_confirmed,base_model_playable;value_sniper_override;strong_historical_sample,0.5707,0.6452,-0.0745,,False,AGREE,0.74,live_match_pre_match_features;ai_features_inferred_from_history,MEDIUM,0.6995,,1/1,,False,v28-pro-max,betting_brain_approved
8f6gex4eh119d2hh9y2zb5clw,2026-05-24,3is4bkgf3loxv9qfg3hm8zfqb,2,0,2,0,OU25,Üst,1.49,0.2,True,False,-0.2,66.5,65.4,77.9,0.0081,B,True,50.1,BET,,inferred_statistical_features;triple_value_not_confirmed,base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample,0.6651,0.6711,-0.006,0.1144,False,AGREE,0.74,live_match_pre_match_features;ai_features_inferred_from_history,LOW,0.5033,,1/1,,False,v28-pro-max,betting_brain_approved
8ee7ipt4u6kyk6baueedsdafo,2026-05-24,3is4bkgf3loxv9qfg3hm8zfqb,0,2,0,2,BTTS,KG Var,1.69,0.2,True,False,-0.2,54.3,69.9,64.7,-0.0792,B,True,27.6,BET,,inferred_statistical_features;triple_value_not_confirmed,base_model_playable;value_sniper_override;strong_historical_sample,0.5433,0.5917,-0.0484,,False,AGREE,0.74,live_match_pre_match_features;ai_features_inferred_from_history,MEDIUM,0.5033,,2/2,,False,v28-pro-max,betting_brain_approved
8fydg367drpc25r1bobxqj3f8,2026-05-24,3is4bkgf3loxv9qfg3hm8zfqb,3,1,2,1,OU25,Üst,1.61,0.2,True,True,0.122,50.1,57.7,50.7,-0.1072,B,True,35.0,BET,,inferred_statistical_features;triple_value_not_confirmed,base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample,0.5009,0.6211,-0.1202,0.0553,False,AGREE,0.74,live_match_pre_match_features;ai_features_inferred_from_history,MEDIUM,0.5033,,,,False,v28-pro-max,betting_brain_approved
8fkdhce1peguwgnsunwoln3f8,2026-05-24,3is4bkgf3loxv9qfg3hm8zfqb,0,2,0,2,OU25,Üst,1.24,0.0,False,False,0.0,61.4,65.4,61.1,-0.1548,PASS,True,16.8,WATCH_NO_VALUE,odds_below_minimum,inferred_statistical_features;triple_value_not_confirmed;historical_sample_too_low,base_model_playable;value_sniper_override;v25_v27_aligned,0.6144,0.8065,-0.1921,0.0942,False,AGREE,0.74,live_match_pre_match_features;ai_features_inferred_from_history,LOW,0.5033,,,,False,v28-pro-max,betting_brain_no_value_odds_below_minimum
9g5hqtjja6ceqhkpghwmoy6ms,2026-05-24,2y8bntiif3a9y6gtmauv30gt,2,0,1,0,OU25,Üst,1.71,0.2,True,False,-0.2,50.1,57.7,52.6,-0.0794,B,True,36.5,BET,,inferred_statistical_features;triple_value_not_confirmed,base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample,0.5009,0.5848,-0.0839,0.0481,False,DISAGREE,0.74,ai_features_inferred_from_history,MEDIUM,0.4782,,,,False,v28-pro-max,betting_brain_approved
8h6429zr5ijqcxc8gjxygjtw4,2026-05-24,3is4bkgf3loxv9qfg3hm8zfqb,3,0,1,0,MS,1,1.33,0.2,True,True,0.066,66.1,65.5,66.7,-0.1155,B,True,0.0,BET,,inferred_statistical_features;v25_v27_soft_disagreement;trap_market_market_overpriced;triple_value_not_confirmed;htft_reversal_prob_minor=0.11,base_model_playable;value_sniper_override;strong_historical_sample,0.6614,0.7519,-0.0905,0.2583,True,AGREE,0.74,live_match_pre_match_features;ai_features_inferred_from_history,MEDIUM,0.5033,0.0814,1/1,,False,v28-pro-max,betting_brain_approved
77knm2ibdtb7akzrbltwz7axg,2026-05-24,bly7ema5au6j40i0grhl0pnub,1,1,1,0,,,,1.0,False,,0.0,,,,,,False,,,,,,,,,,False,AGREE,0.726,missing_full_ms_odds;lineup_probable_not_confirmed;missing_referee;ai_features_inferred_from_history,MEDIUM,,,,,False,v28-pro-max,no_bet_conditions_met
8es4680yd87gtmomg2jk3isyc,2026-05-24,3is4bkgf3loxv9qfg3hm8zfqb,0,1,0,0,OU25,Üst,1.53,0.2,True,False,-0.2,59.5,65.4,67.8,-0.0259,B,True,42.4,BET,,inferred_statistical_features;triple_value_not_confirmed,base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample,0.5955,0.6536,-0.0581,0.0713,False,DISAGREE,0.74,live_match_pre_match_features;ai_features_inferred_from_history,MEDIUM,0.5033,,2/2,,False,v28-pro-max,betting_brain_approved
8dmcz3k1u4ze53nvrsoz7eoes,2026-05-24,3is4bkgf3loxv9qfg3hm8zfqb,1,1,1,0,BTTS,KG Var,1.26,0.0,False,True,0.0,54.9,69.9,57.6,-0.2005,PASS,True,3.3,WATCH_NO_VALUE,odds_below_minimum,inferred_statistical_features;triple_value_not_confirmed;historical_sample_too_low,base_model_playable;value_sniper_override,0.5488,0.7937,-0.2449,,False,AGREE,0.74,live_match_pre_match_features;ai_features_inferred_from_history,LOW,0.5033,,1/1,,False,v28-pro-max,betting_brain_no_value_odds_below_minimum
8gcbai6m7v7o8piqfram4qe50,2026-05-24,3is4bkgf3loxv9qfg3hm8zfqb,3,1,2,0,HTFT,1/1,4.59,0.0,False,True,0.0,27.3,27.3,24.6,0.1657,PASS,True,0.0,REJECT,calibrated_confidence_too_low;play_score_too_low;volatile_market_requires_exceptional_evidence,inferred_statistical_features;historical_sample_too_low,base_model_playable,0.2734,0.2179,0.0555,,False,DISAGREE,0.74,live_match_pre_match_features;ai_features_inferred_from_history,MEDIUM,0.5033,,1/1,,False,v28-pro-max,betting_brain_no_safe_pick
3azy3enp78au0zfugc3l1yf4k,2026-05-24,54c65mhi143utomzvvv3q2avh,2,0,1,0,,,,1.0,False,,0.0,,,,,,False,,,,,,,,,,False,AGREE,0.532,missing_full_ms_odds;lineup_probable_not_confirmed;lineup_projection_low_confidence;lineup_incomplete;missing_referee;ai_features_inferred_from_history,MEDIUM,,,,,False,v28-pro-max,no_bet_conditions_met
1d2fb7bt5f8xy5on24w1kj1g4,2026-05-24,54c65mhi143utomzvvv3q2avh,1,0,0,0,,,,1.0,False,,0.0,,,,,,False,,,,,,,,,,False,AGREE,0.532,missing_full_ms_odds;lineup_probable_not_confirmed;lineup_projection_low_confidence;lineup_incomplete;missing_referee;ai_features_inferred_from_history,LOW,,,,,False,v28-pro-max,no_bet_conditions_met
pw01xm8v3jlz13fpi3zq0ftg,2026-05-24,3umprqta6ipyann6qjjh07biz,1,1,0,0,,,,1.0,False,,0.0,,,,,,False,,,,,,,,,,False,AGREE,0.33,missing_full_ms_odds;lineup_unavailable;lineup_incomplete;missing_referee;ai_features_inferred_from_history,MEDIUM,,,,,False,v28-pro-max,no_bet_conditions_met
mjo9k4zr1x884vjlwea2y1hw,2026-05-24,3umprqta6ipyann6qjjh07biz,1,0,1,0,,,,1.0,False,,0.0,,,,,,False,,,,,,,,,,False,AGREE,0.33,missing_full_ms_odds;lineup_unavailable;lineup_incomplete;missing_referee;ai_features_inferred_from_history,MEDIUM,,,,,False,v28-pro-max,no_bet_conditions_met
8d8fm7wli7tfx8hm9w5l8nuhg,2026-05-24,3is4bkgf3loxv9qfg3hm8zfqb,1,1,1,0,BTTS,KG Var,1.72,0.2,True,True,0.144,53.7,69.9,65.2,-0.0712,B,True,28.0,BET,,inferred_statistical_features;triple_value_not_confirmed,base_model_playable;value_sniper_override;strong_historical_sample,0.5371,0.5814,-0.0443,,False,AGREE,0.74,live_match_pre_match_features;ai_features_inferred_from_history,MEDIUM,0.5033,,,,False,v28-pro-max,betting_brain_approved
oqsq3f0kvic8xfed8dp302z8,2026-05-24,3umprqta6ipyann6qjjh07biz,3,2,0,0,,,,1.0,False,,0.0,,,,,,False,,,,,,,,,,False,AGREE,0.33,missing_full_ms_odds;lineup_unavailable;lineup_incomplete;missing_referee;ai_features_inferred_from_history,MEDIUM,,,,,False,v28-pro-max,no_bet_conditions_met
o7tn4si7fxvq9c2mg0xs48wk,2026-05-24,3umprqta6ipyann6qjjh07biz,0,1,0,0,,,,1.0,False,,0.0,,,,,,False,,,,,,,,,,False,AGREE,0.33,missing_full_ms_odds;lineup_unavailable;lineup_incomplete;missing_referee;ai_features_inferred_from_history,MEDIUM,,,,,False,v28-pro-max,no_bet_conditions_met
eh9jfegscokidyczxfq691990,2026-05-24,3j81qr7yc4gdnakfwnxf95ovh,2,3,0,1,OU25,Üst,1.44,0.2,True,True,0.088,50.1,57.7,32.9,-0.2537,B,True,17.0,BET,,inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed,base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample,0.5009,0.6944,-0.1935,0.0596,True,AGREE,0.51,lineup_unavailable;lineup_incomplete;missing_referee;ai_features_inferred_from_history,MEDIUM,0.8771,,,,False,v28-pro-max,betting_brain_approved
dkhhkbwnxwl47e8hybv89mwb8,2026-05-24,5jd0k2txwnq69frs79eulba8j,1,2,0,1,OU25,Üst,1.23,0.0,False,True,0.0,61.4,65.4,61.2,-0.1185,PASS,True,11.4,WATCH_NO_VALUE,odds_below_minimum,base_model_not_playable;inferred_statistical_features;triple_value_not_confirmed,value_sniper_override;v25_v27_aligned;strong_historical_sample,0.6144,0.813,-0.1986,0.0179,False,AGREE,0.74,ai_features_inferred_from_history,LOW,0.9233,,1/1,,False,v28-pro-max,betting_brain_no_value_odds_below_minimum
1lknqdz9vmb3hnqu144zkkefo,2026-05-24,1r097lpxe0xn03ihb7wi98kao,1,0,1,0,BTTS,KG Var,1.78,0.2,True,False,-0.2,50.0,61.7,55.6,-0.088,B,True,29.3,BET,,inferred_statistical_features;triple_value_not_confirmed,base_model_playable;value_sniper_override;strong_historical_sample,0.5,0.5618,-0.0618,,False,AGREE,0.74,live_match_pre_match_features;ai_features_inferred_from_history,MEDIUM,0.7391,,1/1,,False,v28-pro-max,betting_brain_approved
3oazp9kfbyyiatn246k4to6xg,2026-05-24,9ynnnx1qmkizq1o3qr3v0nsuk,1,2,0,1,BTTS,KG Var,1.36,0.2,True,True,0.072,53.7,69.9,61.2,-0.1571,B,True,33.7,BET,,inferred_statistical_features;triple_value_not_confirmed,base_model_playable;value_sniper_override;strong_historical_sample,0.5371,0.7353,-0.1982,,False,AGREE,0.74,live_match_pre_match_features;ai_features_inferred_from_history,LOW,0.554,,2/2,,False,v28-pro-max,betting_brain_approved
8cr8t6qh0r6g0mv6ftq0ic1sk,2026-05-24,a9vrdkelbgif0gtu3wxsr75xo,2,1,0,1,OU25,Üst,1.46,0.2,True,True,0.092,61.4,65.4,68.1,-0.0182,B,True,47.8,BET,,inferred_statistical_features;triple_value_not_confirmed,base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample,0.6144,0.6849,-0.0705,0.0535,False,AGREE,0.74,ai_features_inferred_from_history,MEDIUM,0.6618,,,,False,v28-pro-max,betting_brain_approved
1 match_id match_date league_id score_home score_away ht_score_home ht_score_away market pick odds stake_units playable won unit_profit raw_confidence calibrated_confidence play_score ev_edge bet_grade is_value_sniper bb_score bb_action bb_vetoes bb_issues bb_positives bb_model_prob bb_implied_prob bb_model_market_gap bb_divergence bb_trap_market v27_consensus data_quality_score data_quality_flags risk_level odds_reliability htft_reversal_prob htft_top_pick league_name is_cup model_version decision_reason
2 5iam9c9dw3ggz3y1ohr9uh53o 2026-05-24 8nbwkj392b0xzssqpw9jwmzdn 0 0 0 0 1.0 False 0.0 False False AGREE 0.33 missing_full_ms_odds;lineup_unavailable;lineup_incomplete;missing_referee;ai_features_inferred_from_history MEDIUM False v28-pro-max no_bet_conditions_met
3 8c90p2ft4zxjdck8wlgq1a61g 2026-05-24 a9vrdkelbgif0gtu3wxsr75xo 2 2 0 0 OU25 Üst 1.41 0.2 True True 0.082 59.5 65.4 58.4 -0.1557 B True 26.4 BET inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample 0.5955 0.7092 -0.1137 0.0339 True DISAGREE 0.74 ai_features_inferred_from_history MEDIUM 0.6618 False v28-pro-max betting_brain_approved
4 9ljz1grea3a8jajif4e9b7bpw 2026-05-24 2wolc27r8z03itcvwp43e38c5 1 1 1 1 BTTS KG Var 1.62 0.2 True True 0.124 53.7 69.9 48.4 -0.1476 B True 1.6 BET inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed base_model_playable;value_sniper_override;strong_historical_sample 0.5371 0.6173 -0.0802 True AGREE 0.74 lineup_probable_not_confirmed;missing_referee;ai_features_inferred_from_history HIGH 0.5592 False v28-pro-max betting_brain_approved
5 1r7iq0nhg2b674jpcm92ragpg 2026-05-24 1zp1du9n4rj36p1ss9zbxtqfb 4 0 1 0 BTTS KG Var 1.89 0.2 True False -0.2 53.2 69.9 52.9 -0.0832 B True 0.0 BET inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed base_model_playable;value_sniper_override;strong_historical_sample 0.5317 0.5291 0.0026 True AGREE 0.74 lineup_probable_not_confirmed;missing_referee;ai_features_inferred_from_history HIGH 0.5353 False v28-pro-max betting_brain_approved
6 70dgok3yq76g076vemaps0178 2026-05-24 6lwpjhktjhl9g7x2w7njmzva6 2 1 1 0 BTTS KG Var 1.74 0.2 True True 0.148 51.9 68.4 60.4 -0.1255 B True 15.7 BET inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed base_model_playable;value_sniper_override;strong_historical_sample 0.5192 0.5747 -0.0555 True AGREE 0.74 ai_features_inferred_from_history MEDIUM 0.6164 False v28-pro-max betting_brain_approved
7 72q9d4uimmby6g6bor26taz9w 2026-05-24 6jgwiu2gq3dllmrwt45pfdn2z 2 0 2 0 1.0 False 0.0 False False AGREE 0.555 missing_full_ms_odds;lineup_probable_not_confirmed;lineup_projection_low_confidence;lineup_incomplete;missing_referee;ai_features_inferred_from_history LOW False v28-pro-max no_bet_conditions_met
8 67dd2t7043kv0yw1zj1buwdg4 2026-05-24 8n9w0n3i9kk05echhtmstn6o9 1 1 0 0 1.0 False 0.0 False False AGREE 0.698 missing_full_ms_odds;lineup_probable_not_confirmed;missing_referee;ai_features_inferred_from_history MEDIUM False v28-pro-max no_bet_conditions_met
9 ejdwfph35q57phtfz7jr8st1w 2026-05-24 8ey0ww2zsosdmwr8ehsorh6t7 0 2 0 0 BTTS KG Var 1.82 0.2 True False -0.2 53.2 69.9 63.5 -0.1097 B True 12.1 BET inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed base_model_playable;value_sniper_override;strong_historical_sample 0.5317 0.5495 -0.0178 True DISAGREE 0.74 live_match_pre_match_features;ai_features_inferred_from_history MEDIUM 0.51 X/X False v28-pro-max betting_brain_approved
10 68y8tlfnilw5trs1oqi4dhfkk 2026-05-24 8n9w0n3i9kk05echhtmstn6o9 2 2 1 1 1.0 False 0.0 False False DISAGREE 0.535 missing_full_ms_odds;lineup_probable_not_confirmed;lineup_projection_low_confidence;lineup_incomplete;missing_referee;ai_features_inferred_from_history MEDIUM False v28-pro-max no_bet_conditions_met
11 68fghiwdtwspk1m0ft5mspzx0 2026-05-24 8n9w0n3i9kk05echhtmstn6o9 1 2 1 2 1.0 False 0.0 False False DISAGREE 0.602 missing_full_ms_odds;lineup_probable_not_confirmed;lineup_projection_low_confidence;lineup_incomplete;missing_referee;ai_features_inferred_from_history MEDIUM False v28-pro-max no_bet_conditions_met
12 a1kqq0ggywfl6sl4srntxy2hg 2026-05-24 3ww12jab49q8q8mk9avdwjqgk 1 1 1 0 OU25 Üst 1.62 0.2 True False -0.2 61.4 65.4 60.6 -0.148 B True 27.0 BET inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample 0.6144 0.6173 -0.0029 0.0854 True AGREE 0.74 live_match_pre_match_features;ai_features_inferred_from_history MEDIUM 0.5961 False v28-pro-max betting_brain_approved
13 a1vpp4i6t61v7qm3dfy6iuj2s 2026-05-24 3ww12jab49q8q8mk9avdwjqgk 2 1 0 0 1.0 False 0.0 False False AGREE 0.74 missing_full_ms_odds;live_match_pre_match_features;ai_features_inferred_from_history LOW False v28-pro-max no_bet_conditions_met
14 28rk26zqah60qj7h78qh5beac 2026-05-24 663a54fmymndjeev47qm7d3nf 2 2 2 1 OU25 Üst 1.39 0.2 True True 0.078 61.1 65.4 57.5 -0.2039 B True 13.3 BET low_reliability_league;inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample 0.6107 0.7194 -0.1087 0.0577 True AGREE 0.74 lineup_probable_not_confirmed;missing_referee;ai_features_inferred_from_history MEDIUM 0.3522 False v28-pro-max betting_brain_approved
15 a26stt54g5ju5ecodpcbcqxw4 2026-05-24 3ww12jab49q8q8mk9avdwjqgk 2 2 1 1 BTTS KG Var 2.05 0.2 True True 0.21 53.7 69.9 70.6 0.0354 B True 33.5 BET inferred_statistical_features;triple_value_not_confirmed base_model_playable;value_sniper_override;usable_historical_sample 0.5371 0.4878 0.0493 False AGREE 0.74 live_match_pre_match_features;ai_features_inferred_from_history MEDIUM 0.5961 False v28-pro-max betting_brain_approved
16 318rrnl9b1vlenjd9hwahpvro 2026-05-24 9z5643nd06afqu01ea2wt8y4g 1 0 0 0 DC 1X 1.12 0.0 False True 0.0 73.5 73.5 72.9 -0.0553 PASS True 41.8 WATCH_NO_VALUE odds_below_minimum base_model_not_playable;inferred_statistical_features value_sniper_override;v25_v27_aligned;triple_value_confirmed;usable_historical_sample 0.7351 0.8929 -0.1578 0.031 False AGREE 0.51 lineup_unavailable;lineup_incomplete;missing_referee;ai_features_inferred_from_history MEDIUM 0.8734 False v28-pro-max betting_brain_no_value_odds_below_minimum
17 65tlfs3m6sc70261z37i90jys 2026-05-24 89ovpy1rarewwzqvi30bfdr8b 4 3 1 1 OU25 Üst 1.36 0.2 True True 0.072 60.2 65.4 53.5 -0.2107 B True 24.8 BET inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample 0.6018 0.7353 -0.1335 0.0982 True AGREE 0.74 live_match_pre_match_features;ai_features_inferred_from_history MEDIUM 0.7068 False v28-pro-max betting_brain_approved
18 ei6nilo5074tnb17kvv37oy6s 2026-05-24 3j81qr7yc4gdnakfwnxf95ovh 1 1 1 1 MS 2 1.11 0.0 False False 0.0 75.5 90.9 66.9 -0.0497 PASS True 0.0 WATCH_NO_VALUE odds_below_minimum base_model_not_playable;inferred_statistical_features;v25_v27_soft_disagreement;triple_value_not_confirmed value_sniper_override;strong_historical_sample 0.7554 0.9009 -0.1455 0.204 False AGREE 0.51 lineup_probable_not_confirmed;lineup_projection_low_confidence;lineup_incomplete;missing_referee;ai_features_inferred_from_history MEDIUM 0.8771 0.0 False v28-pro-max betting_brain_no_value_odds_below_minimum
19 cx8nb7w3nmell38mta1umh2qc 2026-05-24 54c65mhi143utomzvvv3q2avh 0 2 0 0 1.0 False 0.0 False False AGREE 0.726 missing_full_ms_odds;lineup_probable_not_confirmed;missing_referee;ai_features_inferred_from_history MEDIUM False v28-pro-max no_bet_conditions_met
20 53348iqniod61z7xurb4tx250 2026-05-24 477yyajzheg2z8u7uick0e13e 2 0 0 0 OU25 Üst 1.93 0.2 True False -0.2 59.5 65.4 71.3 0.0414 B True 45.8 BET inferred_statistical_features;triple_value_not_confirmed base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample 0.5955 0.5181 0.0774 0.1084 False AGREE 0.74 lineup_probable_not_confirmed;ai_features_inferred_from_history MEDIUM 0.4706 False v28-pro-max betting_brain_approved
21 2hw717s7fxi2v2w53kdsplhqs 2026-05-24 8najqkluatpaxvqws78b9s17c 1 1 0 0 DC 12 1.24 0.0 False False 0.0 70.0 70.0 61.2 -0.12 PASS True 0.0 WATCH_NO_VALUE odds_below_minimum base_model_not_playable;inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed value_sniper_override;v25_v27_aligned;strong_historical_sample 0.7001 0.8065 -0.1064 0.0075 True AGREE 0.51 lineup_unavailable;lineup_incomplete;missing_referee;ai_features_inferred_from_history HIGH 0.5082 False v28-pro-max betting_brain_no_value_odds_below_minimum
22 1q1s55dy4d4z4gs34qk6vx9n8 2026-05-24 cegl2ivkc25blcatxp4jmk1ec 1 0 1 0 OU25 Üst 2.03 0.2 True False -0.2 50.0 57.7 54.3 0.0546 B True 35.7 BET inferred_statistical_features;triple_value_not_confirmed base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample 0.5 0.4926 0.0074 0.0555 False AGREE 0.74 lineup_probable_not_confirmed;missing_referee;ai_features_inferred_from_history HIGH 0.5993 False v28-pro-max betting_brain_approved
23 648u8zd49cwcxpspmvinlmexg 2026-05-24 1eruend45vd20g9hbrpiggs5u 1 0 0 0 MS 1 1.32 0.2 True True 0.064 63.1 65.5 55.7 -0.0828 B True 39.4 BET inferred_statistical_features;triple_value_not_confirmed base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample 0.6313 0.7576 -0.1263 0.0683 False AGREE 0.74 lineup_probable_not_confirmed;missing_referee;ai_features_inferred_from_history MEDIUM 0.8083 0.0 False v28-pro-max betting_brain_approved
24 1psnufak57w8dfs9e5cvbmgwk 2026-05-24 cegl2ivkc25blcatxp4jmk1ec 3 0 2 0 BTTS KG Var 1.94 0.2 True False -0.2 53.7 69.9 53.1 -0.0765 B True 5.3 BET inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed base_model_playable;value_sniper_override;strong_historical_sample 0.5371 0.5155 0.0216 True AGREE 0.74 lineup_probable_not_confirmed;missing_referee;ai_features_inferred_from_history HIGH 0.5993 False v28-pro-max betting_brain_approved
25 921kqaviappxt0w1kfmq1ek2c 2026-05-24 byu00jvt1j6csyv4y1lkt2fm2 1 0 1 0 DC X2 1.18 0.0 False False 0.0 75.8 75.8 74.9 -0.1267 PASS True 0.0 WATCH_NO_VALUE odds_below_minimum inferred_statistical_features;v25_v27_soft_disagreement;trap_market_market_overpriced;triple_value_not_confirmed;engine_consensus_disagree base_model_playable;value_sniper_override;strong_historical_sample 0.7584 0.8475 -0.0891 0.1783 True DISAGREE 0.74 ai_features_inferred_from_history MEDIUM 0.5359 False v28-pro-max betting_brain_no_value_odds_below_minimum
26 3m11hvh2fzailt3ykd0uhzz84 2026-05-24 54c65mhi143utomzvvv3q2avh 0 0 0 0 1.0 False 0.0 False False AGREE 0.591 missing_full_ms_odds;lineup_probable_not_confirmed;lineup_projection_low_confidence;lineup_incomplete;missing_referee;ai_features_inferred_from_history MEDIUM False v28-pro-max no_bet_conditions_met
27 7kvvf6blnps2xk15100ccdedw 2026-05-24 4zwgbb66rif2spcoeeol2motx 5 0 3 0 BTTS KG Var 1.33 0.2 True False -0.2 57.1 69.9 62.3 -0.1512 B True 29.1 BET inferred_statistical_features;triple_value_not_confirmed base_model_playable;value_sniper_override;strong_historical_sample 0.5707 0.7519 -0.1812 False AGREE 0.74 live_match_pre_match_features;ai_features_inferred_from_history MEDIUM 0.6995 1/1 False v28-pro-max betting_brain_approved
28 7liir8zj32o7m2udr7cknb8d0 2026-05-24 4zwgbb66rif2spcoeeol2motx 3 0 2 0 OU25 Üst 1.33 0.2 True True 0.066 61.4 65.4 58.3 -0.1437 B True 27.0 BET inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample 0.6144 0.7519 -0.1375 0.0279 True AGREE 0.74 live_match_pre_match_features;ai_features_inferred_from_history MEDIUM 0.6995 1/1 False v28-pro-max betting_brain_approved
29 7l74ilyz7olljclexvn8tbjtg 2026-05-24 4zwgbb66rif2spcoeeol2motx 5 1 4 0 BTTS KG Var 1.55 0.2 True True 0.11 57.1 69.9 68.2 -0.0873 B True 32.7 BET inferred_statistical_features;triple_value_not_confirmed base_model_playable;value_sniper_override;strong_historical_sample 0.5707 0.6452 -0.0745 False AGREE 0.74 live_match_pre_match_features;ai_features_inferred_from_history MEDIUM 0.6995 1/1 False v28-pro-max betting_brain_approved
30 8f6gex4eh119d2hh9y2zb5clw 2026-05-24 3is4bkgf3loxv9qfg3hm8zfqb 2 0 2 0 OU25 Üst 1.49 0.2 True False -0.2 66.5 65.4 77.9 0.0081 B True 50.1 BET inferred_statistical_features;triple_value_not_confirmed base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample 0.6651 0.6711 -0.006 0.1144 False AGREE 0.74 live_match_pre_match_features;ai_features_inferred_from_history LOW 0.5033 1/1 False v28-pro-max betting_brain_approved
31 8ee7ipt4u6kyk6baueedsdafo 2026-05-24 3is4bkgf3loxv9qfg3hm8zfqb 0 2 0 2 BTTS KG Var 1.69 0.2 True False -0.2 54.3 69.9 64.7 -0.0792 B True 27.6 BET inferred_statistical_features;triple_value_not_confirmed base_model_playable;value_sniper_override;strong_historical_sample 0.5433 0.5917 -0.0484 False AGREE 0.74 live_match_pre_match_features;ai_features_inferred_from_history MEDIUM 0.5033 2/2 False v28-pro-max betting_brain_approved
32 8fydg367drpc25r1bobxqj3f8 2026-05-24 3is4bkgf3loxv9qfg3hm8zfqb 3 1 2 1 OU25 Üst 1.61 0.2 True True 0.122 50.1 57.7 50.7 -0.1072 B True 35.0 BET inferred_statistical_features;triple_value_not_confirmed base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample 0.5009 0.6211 -0.1202 0.0553 False AGREE 0.74 live_match_pre_match_features;ai_features_inferred_from_history MEDIUM 0.5033 False v28-pro-max betting_brain_approved
33 8fkdhce1peguwgnsunwoln3f8 2026-05-24 3is4bkgf3loxv9qfg3hm8zfqb 0 2 0 2 OU25 Üst 1.24 0.0 False False 0.0 61.4 65.4 61.1 -0.1548 PASS True 16.8 WATCH_NO_VALUE odds_below_minimum inferred_statistical_features;triple_value_not_confirmed;historical_sample_too_low base_model_playable;value_sniper_override;v25_v27_aligned 0.6144 0.8065 -0.1921 0.0942 False AGREE 0.74 live_match_pre_match_features;ai_features_inferred_from_history LOW 0.5033 False v28-pro-max betting_brain_no_value_odds_below_minimum
34 9g5hqtjja6ceqhkpghwmoy6ms 2026-05-24 2y8bntiif3a9y6gtmauv30gt 2 0 1 0 OU25 Üst 1.71 0.2 True False -0.2 50.1 57.7 52.6 -0.0794 B True 36.5 BET inferred_statistical_features;triple_value_not_confirmed base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample 0.5009 0.5848 -0.0839 0.0481 False DISAGREE 0.74 ai_features_inferred_from_history MEDIUM 0.4782 False v28-pro-max betting_brain_approved
35 8h6429zr5ijqcxc8gjxygjtw4 2026-05-24 3is4bkgf3loxv9qfg3hm8zfqb 3 0 1 0 MS 1 1.33 0.2 True True 0.066 66.1 65.5 66.7 -0.1155 B True 0.0 BET inferred_statistical_features;v25_v27_soft_disagreement;trap_market_market_overpriced;triple_value_not_confirmed;htft_reversal_prob_minor=0.11 base_model_playable;value_sniper_override;strong_historical_sample 0.6614 0.7519 -0.0905 0.2583 True AGREE 0.74 live_match_pre_match_features;ai_features_inferred_from_history MEDIUM 0.5033 0.0814 1/1 False v28-pro-max betting_brain_approved
36 77knm2ibdtb7akzrbltwz7axg 2026-05-24 bly7ema5au6j40i0grhl0pnub 1 1 1 0 1.0 False 0.0 False False AGREE 0.726 missing_full_ms_odds;lineup_probable_not_confirmed;missing_referee;ai_features_inferred_from_history MEDIUM False v28-pro-max no_bet_conditions_met
37 8es4680yd87gtmomg2jk3isyc 2026-05-24 3is4bkgf3loxv9qfg3hm8zfqb 0 1 0 0 OU25 Üst 1.53 0.2 True False -0.2 59.5 65.4 67.8 -0.0259 B True 42.4 BET inferred_statistical_features;triple_value_not_confirmed base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample 0.5955 0.6536 -0.0581 0.0713 False DISAGREE 0.74 live_match_pre_match_features;ai_features_inferred_from_history MEDIUM 0.5033 2/2 False v28-pro-max betting_brain_approved
38 8dmcz3k1u4ze53nvrsoz7eoes 2026-05-24 3is4bkgf3loxv9qfg3hm8zfqb 1 1 1 0 BTTS KG Var 1.26 0.0 False True 0.0 54.9 69.9 57.6 -0.2005 PASS True 3.3 WATCH_NO_VALUE odds_below_minimum inferred_statistical_features;triple_value_not_confirmed;historical_sample_too_low base_model_playable;value_sniper_override 0.5488 0.7937 -0.2449 False AGREE 0.74 live_match_pre_match_features;ai_features_inferred_from_history LOW 0.5033 1/1 False v28-pro-max betting_brain_no_value_odds_below_minimum
39 8gcbai6m7v7o8piqfram4qe50 2026-05-24 3is4bkgf3loxv9qfg3hm8zfqb 3 1 2 0 HTFT 1/1 4.59 0.0 False True 0.0 27.3 27.3 24.6 0.1657 PASS True 0.0 REJECT calibrated_confidence_too_low;play_score_too_low;volatile_market_requires_exceptional_evidence inferred_statistical_features;historical_sample_too_low base_model_playable 0.2734 0.2179 0.0555 False DISAGREE 0.74 live_match_pre_match_features;ai_features_inferred_from_history MEDIUM 0.5033 1/1 False v28-pro-max betting_brain_no_safe_pick
40 3azy3enp78au0zfugc3l1yf4k 2026-05-24 54c65mhi143utomzvvv3q2avh 2 0 1 0 1.0 False 0.0 False False AGREE 0.532 missing_full_ms_odds;lineup_probable_not_confirmed;lineup_projection_low_confidence;lineup_incomplete;missing_referee;ai_features_inferred_from_history MEDIUM False v28-pro-max no_bet_conditions_met
41 1d2fb7bt5f8xy5on24w1kj1g4 2026-05-24 54c65mhi143utomzvvv3q2avh 1 0 0 0 1.0 False 0.0 False False AGREE 0.532 missing_full_ms_odds;lineup_probable_not_confirmed;lineup_projection_low_confidence;lineup_incomplete;missing_referee;ai_features_inferred_from_history LOW False v28-pro-max no_bet_conditions_met
42 pw01xm8v3jlz13fpi3zq0ftg 2026-05-24 3umprqta6ipyann6qjjh07biz 1 1 0 0 1.0 False 0.0 False False AGREE 0.33 missing_full_ms_odds;lineup_unavailable;lineup_incomplete;missing_referee;ai_features_inferred_from_history MEDIUM False v28-pro-max no_bet_conditions_met
43 mjo9k4zr1x884vjlwea2y1hw 2026-05-24 3umprqta6ipyann6qjjh07biz 1 0 1 0 1.0 False 0.0 False False AGREE 0.33 missing_full_ms_odds;lineup_unavailable;lineup_incomplete;missing_referee;ai_features_inferred_from_history MEDIUM False v28-pro-max no_bet_conditions_met
44 8d8fm7wli7tfx8hm9w5l8nuhg 2026-05-24 3is4bkgf3loxv9qfg3hm8zfqb 1 1 1 0 BTTS KG Var 1.72 0.2 True True 0.144 53.7 69.9 65.2 -0.0712 B True 28.0 BET inferred_statistical_features;triple_value_not_confirmed base_model_playable;value_sniper_override;strong_historical_sample 0.5371 0.5814 -0.0443 False AGREE 0.74 live_match_pre_match_features;ai_features_inferred_from_history MEDIUM 0.5033 False v28-pro-max betting_brain_approved
45 oqsq3f0kvic8xfed8dp302z8 2026-05-24 3umprqta6ipyann6qjjh07biz 3 2 0 0 1.0 False 0.0 False False AGREE 0.33 missing_full_ms_odds;lineup_unavailable;lineup_incomplete;missing_referee;ai_features_inferred_from_history MEDIUM False v28-pro-max no_bet_conditions_met
46 o7tn4si7fxvq9c2mg0xs48wk 2026-05-24 3umprqta6ipyann6qjjh07biz 0 1 0 0 1.0 False 0.0 False False AGREE 0.33 missing_full_ms_odds;lineup_unavailable;lineup_incomplete;missing_referee;ai_features_inferred_from_history MEDIUM False v28-pro-max no_bet_conditions_met
47 eh9jfegscokidyczxfq691990 2026-05-24 3j81qr7yc4gdnakfwnxf95ovh 2 3 0 1 OU25 Üst 1.44 0.2 True True 0.088 50.1 57.7 32.9 -0.2537 B True 17.0 BET inferred_statistical_features;trap_market_market_overpriced;triple_value_not_confirmed base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample 0.5009 0.6944 -0.1935 0.0596 True AGREE 0.51 lineup_unavailable;lineup_incomplete;missing_referee;ai_features_inferred_from_history MEDIUM 0.8771 False v28-pro-max betting_brain_approved
48 dkhhkbwnxwl47e8hybv89mwb8 2026-05-24 5jd0k2txwnq69frs79eulba8j 1 2 0 1 OU25 Üst 1.23 0.0 False True 0.0 61.4 65.4 61.2 -0.1185 PASS True 11.4 WATCH_NO_VALUE odds_below_minimum base_model_not_playable;inferred_statistical_features;triple_value_not_confirmed value_sniper_override;v25_v27_aligned;strong_historical_sample 0.6144 0.813 -0.1986 0.0179 False AGREE 0.74 ai_features_inferred_from_history LOW 0.9233 1/1 False v28-pro-max betting_brain_no_value_odds_below_minimum
49 1lknqdz9vmb3hnqu144zkkefo 2026-05-24 1r097lpxe0xn03ihb7wi98kao 1 0 1 0 BTTS KG Var 1.78 0.2 True False -0.2 50.0 61.7 55.6 -0.088 B True 29.3 BET inferred_statistical_features;triple_value_not_confirmed base_model_playable;value_sniper_override;strong_historical_sample 0.5 0.5618 -0.0618 False AGREE 0.74 live_match_pre_match_features;ai_features_inferred_from_history MEDIUM 0.7391 1/1 False v28-pro-max betting_brain_approved
50 3oazp9kfbyyiatn246k4to6xg 2026-05-24 9ynnnx1qmkizq1o3qr3v0nsuk 1 2 0 1 BTTS KG Var 1.36 0.2 True True 0.072 53.7 69.9 61.2 -0.1571 B True 33.7 BET inferred_statistical_features;triple_value_not_confirmed base_model_playable;value_sniper_override;strong_historical_sample 0.5371 0.7353 -0.1982 False AGREE 0.74 live_match_pre_match_features;ai_features_inferred_from_history LOW 0.554 2/2 False v28-pro-max betting_brain_approved
51 8cr8t6qh0r6g0mv6ftq0ic1sk 2026-05-24 a9vrdkelbgif0gtu3wxsr75xo 2 1 0 1 OU25 Üst 1.46 0.2 True True 0.092 61.4 65.4 68.1 -0.0182 B True 47.8 BET inferred_statistical_features;triple_value_not_confirmed base_model_playable;value_sniper_override;v25_v27_aligned;strong_historical_sample 0.6144 0.6849 -0.0705 0.0535 False AGREE 0.74 ai_features_inferred_from_history MEDIUM 0.6618 False v28-pro-max betting_brain_approved
@@ -0,0 +1,220 @@
{
"args": {
"days": 3,
"max_matches": 50,
"start": null,
"end": null,
"progress_interval": 50
},
"aggregate": {
"overall": {
"n_total": 50,
"n_playable_settled": 27,
"wins": 15,
"losses": 12,
"hit_rate_pct": 55.56,
"unit_profit": -0.862,
"staked": 5.4,
"roi_pct": -15.96
},
"by_market": {
"OU25": {
"n_total": 13,
"n_playable_settled": 13,
"wins": 7,
"losses": 6,
"hit_rate_pct": 53.85,
"unit_profit": -0.6,
"staked": 2.6,
"roi_pct": -23.08
},
"BTTS": {
"n_total": 12,
"n_playable_settled": 12,
"wins": 6,
"losses": 6,
"hit_rate_pct": 50.0,
"unit_profit": -0.392,
"staked": 2.4,
"roi_pct": -16.33
},
"MS": {
"n_total": 2,
"n_playable_settled": 2,
"wins": 2,
"losses": 0,
"hit_rate_pct": 100.0,
"unit_profit": 0.13,
"staked": 0.4,
"roi_pct": 32.5
}
},
"by_confidence": {
"65-70": {
"n_total": 22,
"n_playable_settled": 22,
"wins": 13,
"losses": 9,
"hit_rate_pct": 59.09,
"unit_profit": -0.472,
"staked": 4.4,
"roi_pct": -10.73
},
"55-60": {
"n_total": 4,
"n_playable_settled": 4,
"wins": 2,
"losses": 2,
"hit_rate_pct": 50.0,
"unit_profit": -0.19,
"staked": 0.8,
"roi_pct": -23.75
},
"60-65": {
"n_total": 1,
"n_playable_settled": 1,
"wins": 0,
"losses": 1,
"hit_rate_pct": 0.0,
"unit_profit": -0.2,
"staked": 0.2,
"roi_pct": -100.0
}
},
"by_odds": {
"1.3-1.5": {
"n_total": 11,
"n_playable_settled": 11,
"wins": 9,
"losses": 2,
"hit_rate_pct": 81.82,
"unit_profit": 0.28,
"staked": 2.2,
"roi_pct": 12.73
},
"1.5-1.8": {
"n_total": 10,
"n_playable_settled": 10,
"wins": 5,
"losses": 5,
"hit_rate_pct": 50.0,
"unit_profit": -0.352,
"staked": 2.0,
"roi_pct": -17.6
},
"1.8-2.2": {
"n_total": 6,
"n_playable_settled": 6,
"wins": 1,
"losses": 5,
"hit_rate_pct": 16.67,
"unit_profit": -0.79,
"staked": 1.2,
"roi_pct": -65.83
}
},
"by_grade": {
"B": {
"n_total": 27,
"n_playable_settled": 27,
"wins": 15,
"losses": 12,
"hit_rate_pct": 55.56,
"unit_profit": -0.862,
"staked": 5.4,
"roi_pct": -15.96
}
},
"by_competition": {
"league": {
"n_total": 27,
"n_playable_settled": 27,
"wins": 15,
"losses": 12,
"hit_rate_pct": 55.56,
"unit_profit": -0.862,
"staked": 5.4,
"roi_pct": -15.96
}
}
},
"loss_diagnostics": {
"n_losses": 12,
"total_loss_units": -2.4,
"patterns": {
"high_htft_reversal_prob (>=0.20)": [
0,
0.0
],
"cup_match": [
0,
0.0
],
"low_league_reliability (<0.45)": [
0,
0.0
],
"v27_disagree": [
3,
25.0
],
"trap_market_flagged": [
4,
33.33
],
"low_calibrated_conf (<55)": [
0,
0.0
],
"high_odds_underdog (>=2.5)": [
0,
0.0
],
"low_data_quality (<0.55)": [
0,
0.0
],
"high_risk_level": [
3,
25.0
],
"inferred_features": [
12,
100.0
]
},
"by_market": [
[
"BTTS",
6
],
[
"OU25",
6
]
],
"by_league": [
[
null,
12
]
],
"top_bb_issues_in_losses": [
[
"inferred_statistical_features",
12
],
[
"triple_value_not_confirmed",
12
],
[
"trap_market_market_overpriced",
4
]
],
"top_bb_vetoes_in_losses": []
},
"recommendations": [],
"errors_sample": []
}
@@ -0,0 +1,71 @@
==============================================================================
DIAGNOSTIC BACKTEST REPORT
==============================================================================
Generated: 2026-05-25T02:44:37
Sample window: start=-3d, end=now
Max matches: 50
Excluded days: ['2026-04-29', '2026-05-03']
OVERALL
------------------------------------------------------------------------------
n_total : 50
n_playable_settled : 27
wins : 15
losses : 12
hit_rate_pct : 55.56
unit_profit : -0.862
staked : 5.4
roi_pct : -15.96
PER MARKET
------------------------------------------------------------------------------
market n hit% profit roi%
OU25 13 53.85 -0.6 -23.08
BTTS 12 50.0 -0.392 -16.33
MS 2 100.0 0.13 32.5
PER CALIBRATED CONFIDENCE BAND
------------------------------------------------------------------------------
band n hit% roi%
55-60 4 50.0 -23.75
60-65 1 0.0 -100.0
65-70 22 59.09 -10.73
PER ODDS BAND
------------------------------------------------------------------------------
band n hit% roi%
1.3-1.5 11 81.82 12.73
1.5-1.8 10 50.0 -17.6
1.8-2.2 6 16.67 -65.83
LEAGUE vs CUP
------------------------------------------------------------------------------
league n= 27 hit=55.56% roi=-15.96%
LOSS DIAGNOSTICS
------------------------------------------------------------------------------
total losses: 12
total lost units: -2.4
By market: [('BTTS', 6), ('OU25', 6)]
Loss patterns (count, % of losses):
high_htft_reversal_prob (>=0.20) 0 (0.0%)
cup_match 0 (0.0%)
low_league_reliability (<0.45) 0 (0.0%)
v27_disagree 3 (25.0%)
trap_market_flagged 4 (33.33%)
low_calibrated_conf (<55) 0 (0.0%)
high_odds_underdog (>=2.5) 0 (0.0%)
low_data_quality (<0.55) 0 (0.0%)
high_risk_level 3 (25.0%)
inferred_features 12 (100.0%)
Top betting_brain issues seen in losses:
inferred_statistical_features 12
triple_value_not_confirmed 12
trap_market_market_overpriced 4
Top betting_brain vetoes (in losses — i.e. veto fired but bet still went through value-sniper override):
RECOMMENDATIONS
------------------------------------------------------------------------------
(none surfaced — sample too small or no clear pattern)
==============================================================================
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,335 @@
{
"args": {
"days": 14,
"max_matches": 1000,
"start": null,
"end": null,
"progress_interval": 50
},
"aggregate": {
"overall": {
"n_total": 1000,
"n_playable_settled": 524,
"wins": 287,
"losses": 237,
"hit_rate_pct": 54.77,
"unit_profit": -17.897,
"staked": 107.0,
"roi_pct": -16.73
},
"by_market": {
"OU25": {
"n_total": 236,
"n_playable_settled": 236,
"wins": 134,
"losses": 102,
"hit_rate_pct": 56.78,
"unit_profit": -6.271,
"staked": 48.5,
"roi_pct": -12.93
},
"BTTS": {
"n_total": 205,
"n_playable_settled": 205,
"wins": 105,
"losses": 100,
"hit_rate_pct": 51.22,
"unit_profit": -8.89,
"staked": 41.1,
"roi_pct": -21.63
},
"MS": {
"n_total": 76,
"n_playable_settled": 76,
"wins": 44,
"losses": 32,
"hit_rate_pct": 57.89,
"unit_profit": -2.396,
"staked": 16.0,
"roi_pct": -14.98
},
"OU35": {
"n_total": 3,
"n_playable_settled": 3,
"wins": 0,
"losses": 3,
"hit_rate_pct": 0.0,
"unit_profit": -0.6,
"staked": 0.6,
"roi_pct": -100.0
},
"DC": {
"n_total": 4,
"n_playable_settled": 4,
"wins": 4,
"losses": 0,
"hit_rate_pct": 100.0,
"unit_profit": 0.26,
"staked": 0.8,
"roi_pct": 32.5
}
},
"by_confidence": {
"65-70": {
"n_total": 420,
"n_playable_settled": 420,
"wins": 233,
"losses": 187,
"hit_rate_pct": 55.48,
"unit_profit": -14.057,
"staked": 85.1,
"roi_pct": -16.52
},
"60-65": {
"n_total": 33,
"n_playable_settled": 33,
"wins": 16,
"losses": 17,
"hit_rate_pct": 48.48,
"unit_profit": -1.61,
"staked": 6.6,
"roi_pct": -24.39
},
"55-60": {
"n_total": 52,
"n_playable_settled": 52,
"wins": 28,
"losses": 24,
"hit_rate_pct": 53.85,
"unit_profit": -0.668,
"staked": 10.5,
"roi_pct": -6.36
},
"50-55": {
"n_total": 5,
"n_playable_settled": 5,
"wins": 2,
"losses": 3,
"hit_rate_pct": 40.0,
"unit_profit": -0.64,
"staked": 1.3,
"roi_pct": -49.23
},
"45-50": {
"n_total": 8,
"n_playable_settled": 8,
"wins": 4,
"losses": 4,
"hit_rate_pct": 50.0,
"unit_profit": -0.382,
"staked": 1.9,
"roi_pct": -20.11
},
"70-80": {
"n_total": 6,
"n_playable_settled": 6,
"wins": 4,
"losses": 2,
"hit_rate_pct": 66.67,
"unit_profit": -0.54,
"staked": 1.6,
"roi_pct": -33.75
}
},
"by_odds": {
"1.3-1.5": {
"n_total": 241,
"n_playable_settled": 241,
"wins": 148,
"losses": 93,
"hit_rate_pct": 61.41,
"unit_profit": -7.408,
"staked": 49.0,
"roi_pct": -15.12
},
"1.5-1.8": {
"n_total": 221,
"n_playable_settled": 221,
"wins": 115,
"losses": 106,
"hit_rate_pct": 52.04,
"unit_profit": -6.926,
"staked": 44.3,
"roi_pct": -15.63
},
"1.8-2.2": {
"n_total": 56,
"n_playable_settled": 56,
"wins": 23,
"losses": 33,
"hit_rate_pct": 41.07,
"unit_profit": -2.789,
"staked": 12.2,
"roi_pct": -22.86
},
"2.2-3.0": {
"n_total": 5,
"n_playable_settled": 5,
"wins": 1,
"losses": 4,
"hit_rate_pct": 20.0,
"unit_profit": -0.574,
"staked": 1.3,
"roi_pct": -44.15
},
"3.0-5.0": {
"n_total": 1,
"n_playable_settled": 1,
"wins": 0,
"losses": 1,
"hit_rate_pct": 0.0,
"unit_profit": -0.2,
"staked": 0.2,
"roi_pct": -100.0
}
},
"by_grade": {
"B": {
"n_total": 518,
"n_playable_settled": 518,
"wins": 285,
"losses": 233,
"hit_rate_pct": 55.02,
"unit_profit": -16.931,
"staked": 105.3,
"roi_pct": -16.08
},
"A": {
"n_total": 6,
"n_playable_settled": 6,
"wins": 2,
"losses": 4,
"hit_rate_pct": 33.33,
"unit_profit": -0.966,
"staked": 1.7,
"roi_pct": -56.82
}
},
"by_competition": {
"league": {
"n_total": 524,
"n_playable_settled": 524,
"wins": 287,
"losses": 237,
"hit_rate_pct": 54.77,
"unit_profit": -17.897,
"staked": 107.0,
"roi_pct": -16.73
}
}
},
"loss_diagnostics": {
"n_losses": 237,
"total_loss_units": -48.7,
"patterns": {
"high_htft_reversal_prob (>=0.20)": [
0,
0.0
],
"cup_match": [
0,
0.0
],
"low_league_reliability (<0.45)": [
42,
17.72
],
"v27_disagree": [
60,
25.32
],
"trap_market_flagged": [
81,
34.18
],
"low_calibrated_conf (<55)": [
7,
2.95
],
"high_odds_underdog (>=2.5)": [
4,
1.69
],
"low_data_quality (<0.55)": [
40,
16.88
],
"high_risk_level": [
20,
8.44
],
"inferred_features": [
0,
0.0
]
},
"by_market": [
[
"OU25",
102
],
[
"BTTS",
100
],
[
"MS",
32
],
[
"OU35",
3
]
],
"by_league": [
[
null,
237
]
],
"top_bb_issues_in_losses": [
[
"triple_value_not_confirmed",
230
],
[
"trap_market_market_overpriced",
81
],
[
"low_reliability_league",
40
],
[
"v25_v27_soft_disagreement",
10
],
[
"engine_consensus_disagree",
5
],
[
"historical_sample_too_low",
3
],
[
"very_low_reliability_league",
2
],
[
"htft_reversal_prob_minor=0.13",
1
]
],
"top_bb_vetoes_in_losses": []
},
"recommendations": [
{
"type": "raise_confidence_threshold",
"confidence_band": "65-70",
"evidence": "n=420, roi=-16.52%",
"suggested_fix": "Raise MIN_BET_SCORE or market_min_conf above 65"
}
],
"errors_sample": []
}
@@ -0,0 +1,86 @@
==============================================================================
DIAGNOSTIC BACKTEST REPORT
==============================================================================
Generated: 2026-05-25T03:56:49
Sample window: start=-14d, end=now
Max matches: 1000
Excluded days: ['2026-04-29', '2026-05-03']
OVERALL
------------------------------------------------------------------------------
n_total : 1000
n_playable_settled : 524
wins : 287
losses : 237
hit_rate_pct : 54.77
unit_profit : -17.897
staked : 107.0
roi_pct : -16.73
PER MARKET
------------------------------------------------------------------------------
market n hit% profit roi%
OU25 236 56.78 -6.271 -12.93
BTTS 205 51.22 -8.89 -21.63
MS 76 57.89 -2.396 -14.98
DC 4 100.0 0.26 32.5
OU35 3 0.0 -0.6 -100.0
PER CALIBRATED CONFIDENCE BAND
------------------------------------------------------------------------------
band n hit% roi%
45-50 8 50.0 -20.11
50-55 5 40.0 -49.23
55-60 52 53.85 -6.36
60-65 33 48.48 -24.39
65-70 420 55.48 -16.52
70-80 6 66.67 -33.75
PER ODDS BAND
------------------------------------------------------------------------------
band n hit% roi%
1.3-1.5 241 61.41 -15.12
1.5-1.8 221 52.04 -15.63
1.8-2.2 56 41.07 -22.86
2.2-3.0 5 20.0 -44.15
3.0-5.0 1 0.0 -100.0
LEAGUE vs CUP
------------------------------------------------------------------------------
league n= 524 hit=54.77% roi=-16.73%
LOSS DIAGNOSTICS
------------------------------------------------------------------------------
total losses: 237
total lost units: -48.7
By market: [('OU25', 102), ('BTTS', 100), ('MS', 32), ('OU35', 3)]
Loss patterns (count, % of losses):
high_htft_reversal_prob (>=0.20) 0 (0.0%)
cup_match 0 (0.0%)
low_league_reliability (<0.45) 42 (17.72%)
v27_disagree 60 (25.32%)
trap_market_flagged 81 (34.18%)
low_calibrated_conf (<55) 7 (2.95%)
high_odds_underdog (>=2.5) 4 (1.69%)
low_data_quality (<0.55) 40 (16.88%)
high_risk_level 20 (8.44%)
inferred_features 0 (0.0%)
Top betting_brain issues seen in losses:
triple_value_not_confirmed 230
trap_market_market_overpriced 81
low_reliability_league 40
v25_v27_soft_disagreement 10
engine_consensus_disagree 5
historical_sample_too_low 3
very_low_reliability_league 2
htft_reversal_prob_minor=0.13 1
Top betting_brain vetoes (in losses — i.e. veto fired but bet still went through value-sniper override):
RECOMMENDATIONS
------------------------------------------------------------------------------
• [raise_confidence_threshold]
confidence_band: 65-70
evidence: n=420, roi=-16.52%
suggested_fix: Raise MIN_BET_SCORE or market_min_conf above 65
==============================================================================
@@ -0,0 +1,38 @@
{
"BTTS": {
"min_calibrated_confidence": 65,
"min_ev_edge": -1.0,
"max_ev_edge": 0.1,
"min_odds": 1.4,
"max_odds": 10.0,
"min_odds_reliability": 0.55,
"require_v27_agree": true,
"expected_n_bets": 54,
"expected_hit_pct": 55.56,
"expected_roi_pct": -10.96
},
"MS": {
"min_calibrated_confidence": 0,
"min_ev_edge": -0.05,
"max_ev_edge": 0.15,
"min_odds": 1.2,
"max_odds": 10.0,
"min_odds_reliability": 0.0,
"require_v27_agree": true,
"expected_n_bets": 21,
"expected_hit_pct": 61.9,
"expected_roi_pct": 8.23
},
"OU25": {
"min_calibrated_confidence": 0,
"min_ev_edge": -1.0,
"max_ev_edge": 0.15,
"min_odds": 1.8,
"max_odds": 10.0,
"min_odds_reliability": 0.0,
"require_v27_agree": false,
"expected_n_bets": 20,
"expected_hit_pct": 65.0,
"expected_roi_pct": 28.91
}
}
+227
View File
@@ -0,0 +1,227 @@
"""
Deep root-cause analysis on diagnostic_backtest CSV.
Tests specific hypotheses with hard numbers and proposes actionable
filter rules with estimated impact (units saved, ROI shift).
"""
import sys, os, glob
import pandas as pd
import numpy as np
REPORTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "reports")
def latest_csv():
files = sorted(glob.glob(os.path.join(REPORTS_DIR, "diagnostic_backtest_*.csv")),
key=os.path.getmtime, reverse=True)
return files[0] if files else None
def fmt_pct(x):
return f"{x:>6.2f}%" if pd.notna(x) else " ----"
def cell(df, label, mask):
sub = df[mask]
n = len(sub)
if n == 0:
return f" {label:<60} n=0"
wins = (sub["won"] == True).sum()
losses = (sub["won"] == False).sum()
settled = wins + losses
hr = 100.0 * wins / settled if settled else 0
profit = sub["unit_profit"].sum()
staked = sub["stake_units"].sum()
roi = 100.0 * profit / staked if staked else 0
return (f" {label:<60} n={n:>4} hit={hr:>6.2f}% "
f"profit={profit:>+7.2f}u roi={roi:>+7.2f}%")
def hypothesis_block(title, rows):
print(f"\n{'' * 78}")
print(f" {title}")
print(f"{'' * 78}")
for row in rows:
print(row)
def main():
csv_path = latest_csv()
if not csv_path:
print("No backtest CSV found")
return
print(f"Reading {csv_path}")
df = pd.read_csv(csv_path)
print(f"Loaded {len(df)} rows")
# Filter only playable + settled
pdf = df[(df["playable"] == True) & (df["won"].notna())].copy()
pdf["won"] = pdf["won"].astype(bool)
print(f"Playable + settled: {len(pdf)}")
overall_hr = (pdf["won"].sum() / len(pdf)) * 100
overall_roi = 100.0 * pdf["unit_profit"].sum() / pdf["stake_units"].sum()
print(f"\nOVERALL: hit={overall_hr:.2f}% roi={overall_roi:.2f}%")
# ─────────────────────────────────────────────────────────────────────
# H1: TRIPLE VALUE CONFIRMATION
# ─────────────────────────────────────────────────────────────────────
triple_confirmed_mask = ~pdf["bb_issues"].fillna("").str.contains(
"triple_value_not_confirmed", na=False
)
hypothesis_block(
"H1: TRIPLE VALUE CONFIRMED vs NOT CONFIRMED",
[
cell(pdf, "triple_value CONFIRMED", triple_confirmed_mask),
cell(pdf, "triple_value NOT CONFIRMED", ~triple_confirmed_mask),
]
)
# ─────────────────────────────────────────────────────────────────────
# H2: TRAP MARKET FLAG
# ─────────────────────────────────────────────────────────────────────
trap_mask = pdf["bb_trap_market"] == True
hypothesis_block(
"H2: TRAP MARKET FLAG (model says band rate < implied → market overpriced)",
[
cell(pdf, "trap_market_flag = TRUE (model warned)", trap_mask),
cell(pdf, "trap_market_flag = FALSE", ~trap_mask),
]
)
# ─────────────────────────────────────────────────────────────────────
# H3: V25/V27 CONSENSUS
# ─────────────────────────────────────────────────────────────────────
agree_mask = pdf["v27_consensus"] == "AGREE"
disagree_mask = pdf["v27_consensus"] == "DISAGREE"
hypothesis_block(
"H3: V25 ↔ V27 CONSENSUS",
[
cell(pdf, "AGREE", agree_mask),
cell(pdf, "DISAGREE", disagree_mask),
cell(pdf, "neither/null", ~(agree_mask | disagree_mask)),
]
)
# ─────────────────────────────────────────────────────────────────────
# H4: ODDS RELIABILITY (league quality)
# ─────────────────────────────────────────────────────────────────────
pdf["rel_band"] = pd.cut(
pdf["odds_reliability"].fillna(0.35),
[0, 0.30, 0.45, 0.55, 1.0],
labels=["<0.30 verylow", "0.30-0.45 low", "0.45-0.55 mid", ">=0.55 high"]
)
hypothesis_block(
"H4: LEAGUE ODDS RELIABILITY",
[cell(pdf, str(b), pdf["rel_band"] == b) for b in pdf["rel_band"].cat.categories]
)
# ─────────────────────────────────────────────────────────────────────
# H5: CALIBRATOR IMPACT (raw vs calibrated)
# ─────────────────────────────────────────────────────────────────────
pdf["calib_delta"] = pdf["calibrated_confidence"] - pdf["raw_confidence"]
pdf["delta_band"] = pd.cut(
pdf["calib_delta"].fillna(0),
[-100, -10, -3, 3, 10, 100],
labels=["cal<<raw (-10+)", "cal<raw (-3..-10)", "≈equal (±3)",
"cal>raw (3..10)", "cal>>raw (+10+)"]
)
hypothesis_block(
"H5: CALIBRATOR DELTA (calibrated_conf - raw_conf)",
[cell(pdf, str(b), pdf["delta_band"] == b) for b in pdf["delta_band"].cat.categories]
)
# ─────────────────────────────────────────────────────────────────────
# H6: EV EDGE
# ─────────────────────────────────────────────────────────────────────
pdf["edge_band"] = pd.cut(
pdf["ev_edge"].fillna(0),
[-10, -0.05, 0.0, 0.05, 0.10, 0.20, 10],
labels=["edge<-5%", "-5%-0%", "0-5%", "5-10%", "10-20%", ">20%"]
)
hypothesis_block(
"H6: EV EDGE (model_prob - implied_prob)",
[cell(pdf, str(b), pdf["edge_band"] == b) for b in pdf["edge_band"].cat.categories]
)
# ─────────────────────────────────────────────────────────────────────
# H7: ODDS x MARKET cross
# ─────────────────────────────────────────────────────────────────────
pdf["odds_band"] = pd.cut(
pdf["odds"].fillna(0),
[0, 1.30, 1.50, 1.80, 2.20, 3.00, 100],
labels=["<1.30", "1.30-1.50", "1.50-1.80", "1.80-2.20", "2.20-3.00", ">3.00"]
)
print(f"\n{'' * 78}")
print(f" H7: ODDS BAND × MARKET (per cell hit% / roi% / n)")
print(f"{'' * 78}")
pivot_n = pdf.pivot_table(index="market", columns="odds_band",
values="match_id", aggfunc="count", fill_value=0,
observed=False)
pivot_roi = pdf.pivot_table(index="market", columns="odds_band",
values="unit_profit", aggfunc="sum", fill_value=0,
observed=False)
pivot_stake = pdf.pivot_table(index="market", columns="odds_band",
values="stake_units", aggfunc="sum", fill_value=0,
observed=False)
pivot_roi_pct = (100.0 * pivot_roi / pivot_stake.replace(0, np.nan)).round(1)
print("\n Bet count per cell:")
print(pivot_n.to_string())
print("\n ROI% per cell:")
print(pivot_roi_pct.to_string())
# ─────────────────────────────────────────────────────────────────────
# H8: COMBINED FILTER SIMULATION
# ─────────────────────────────────────────────────────────────────────
print(f"\n{'' * 78}")
print(" H8: COMBINED FILTER SIMULATION (what if we add rules)")
print(f"{'' * 78}")
def simulate(filter_name, keep_mask):
kept = pdf[keep_mask]
rejected = pdf[~keep_mask]
if len(kept) == 0:
return f" {filter_name:<55} → 0 bet remain"
kept_hr = 100.0 * kept["won"].sum() / len(kept)
kept_profit = kept["unit_profit"].sum()
kept_staked = kept["stake_units"].sum()
kept_roi = 100.0 * kept_profit / kept_staked if kept_staked else 0
saved = -rejected["unit_profit"].sum() # money we WOULD HAVE LOST
return (f" {filter_name:<55} keep={len(kept):>3} hit={kept_hr:>5.1f}% "
f"roi={kept_roi:>+6.2f}% saved={saved:>+6.2f}u")
print(simulate("BASELINE (no extra filter)", pd.Series([True] * len(pdf), index=pdf.index)))
print(simulate("REJECT triple_value_not_confirmed",
~pdf["bb_issues"].fillna("").str.contains("triple_value_not_confirmed")))
print(simulate("REJECT trap_market_flag",
~(pdf["bb_trap_market"] == True)))
print(simulate("REJECT v27 DISAGREE",
pdf["v27_consensus"] != "DISAGREE"))
print(simulate("REJECT odds_reliability < 0.45",
pdf["odds_reliability"].fillna(1.0) >= 0.45))
print(simulate("REJECT odds in 1.80-2.20",
(pdf["odds"].fillna(0) < 1.80) | (pdf["odds"].fillna(0) >= 2.20)))
print(simulate("REJECT ev_edge < 0",
pdf["ev_edge"].fillna(0) >= 0))
print(simulate("REJECT ev_edge < 0.05",
pdf["ev_edge"].fillna(0) >= 0.05))
print()
print(" COMBINED rules:")
# Stack 1: drop triple_not_confirmed + trap_market + DISAGREE
s1 = (
~pdf["bb_issues"].fillna("").str.contains("triple_value_not_confirmed")
& ~(pdf["bb_trap_market"] == True)
& (pdf["v27_consensus"] != "DISAGREE")
)
print(simulate("STACK1: !triple_not_conf & !trap & !disagree", s1))
# Stack 2: + edge>=0
s2 = s1 & (pdf["ev_edge"].fillna(0) >= 0)
print(simulate("STACK2: STACK1 + edge >= 0", s2))
# Stack 3: + reliability>=0.45
s3 = s2 & (pdf["odds_reliability"].fillna(1.0) >= 0.45)
print(simulate("STACK3: STACK2 + reliability >= 0.45", s3))
# Stack 4: + odds outside 1.80-2.20
s4 = s3 & ((pdf["odds"].fillna(0) < 1.80) | (pdf["odds"].fillna(0) >= 2.20))
print(simulate("STACK4: STACK3 + odds NOT in 1.80-2.20", s4))
print(f"\n{'' * 78}")
print("DONE.")
if __name__ == "__main__":
main()
+134
View File
@@ -0,0 +1,134 @@
"""
Compare two diagnostic_backtest CSV outputs side-by-side.
Used to validate that a filter change actually improved ROI vs the
baseline run — and to detect overfitting (in-sample success but
out-of-sample collapse).
Usage:
python scripts/compare_backtests.py <baseline.csv> <validation.csv>
python scripts/compare_backtests.py (auto-picks 2 most recent CSVs)
"""
import sys, os, glob
import pandas as pd
from typing import Dict
REPORTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "reports")
def load(path: str) -> pd.DataFrame:
df = pd.read_csv(path)
df["won_bool"] = df["won"].map(
{True: True, False: False, "True": True, "False": False, 1: True, 0: False}
)
return df
def stats(df: pd.DataFrame, mask=None) -> Dict:
if mask is not None:
df = df[mask]
playable = df[(df["playable"] == True) & (df["won_bool"].notna())]
if len(playable) == 0:
return {"n_total": len(df), "n_playable": 0, "hit": 0, "profit": 0,
"staked": 0, "roi": 0}
wins = playable["won_bool"].sum()
profit = playable["unit_profit"].sum()
staked = playable["stake_units"].sum()
return {
"n_total": int(len(df)),
"n_playable": int(len(playable)),
"wins": int(wins),
"losses": int(len(playable) - wins),
"hit": round(100.0 * wins / len(playable), 2),
"profit": round(profit, 2),
"staked": round(staked, 2),
"roi": round(100.0 * profit / staked, 2) if staked else 0,
}
def line(label: str, a: Dict, b: Dict, suffix: str = ""):
fields = ["n_total", "n_playable", "hit", "profit", "staked", "roi"]
parts = [f"{label:<28}"]
for f in fields:
va = a.get(f, "-")
vb = b.get(f, "-")
parts.append(f"{f}: {str(va):>8}{str(vb):>8}")
print(" " + " | ".join(parts) + suffix)
def main():
if len(sys.argv) == 3:
a_path, b_path = sys.argv[1], sys.argv[2]
else:
files = sorted(glob.glob(os.path.join(REPORTS_DIR, "diagnostic_backtest_*.csv")),
key=os.path.getmtime, reverse=True)
if len(files) < 2:
print("Need at least 2 backtest CSVs in reports/. Pass paths manually.")
return
b_path, a_path = files[0], files[1] # newest first as "validation"
print(f"Baseline A: {os.path.basename(a_path)}")
print(f"Validation B: {os.path.basename(b_path)}")
a = load(a_path)
b = load(b_path)
print(f"\n{'=' * 100}")
print(f" OVERALL")
print(f"{'=' * 100}")
line("ALL", stats(a), stats(b))
print(f"\n{'' * 100}")
print(f" PER MARKET")
print(f"{'' * 100}")
markets = sorted(set(a["market"].dropna().unique()) | set(b["market"].dropna().unique()))
for m in markets:
line(f"market={m}",
stats(a, a["market"] == m),
stats(b, b["market"] == m))
# New veto family check — did MUTED_MARKETS actually mute?
print(f"\n{'' * 100}")
print(f" NEW VETO IMPACT (look for new veto names in betting_brain.vetoes)")
print(f"{'' * 100}")
new_vetoes = ["market_muted_by_backtest", "negative_ev_edge", "ev_edge_too_high_trap",
"outside_envelope_edge_low", "outside_envelope_edge_high",
"outside_envelope_odds_low", "outside_envelope_v27_must_agree"]
for veto in new_vetoes:
a_hits = a["bb_vetoes"].fillna("").str.contains(veto).sum()
b_hits = b["bb_vetoes"].fillna("").str.contains(veto).sum()
print(f" {veto:<45} A={a_hits:>4} B={b_hits:>4}")
# Top issue tags
print(f"\n{'' * 100}")
print(f" BTTS MUTE CHECK — should be ~0 playable in validation")
print(f"{'' * 100}")
a_btts_play = ((a["market"] == "BTTS") & (a["playable"] == True)).sum()
b_btts_play = ((b["market"] == "BTTS") & (b["playable"] == True)).sum()
print(f" BTTS playable bets: A={a_btts_play} → B={b_btts_play} "
f"(should be 0 in B if MUTE works)")
# Verdict
print(f"\n{'=' * 100}")
a_s = stats(a)
b_s = stats(b)
roi_delta = b_s["roi"] - a_s["roi"]
if b_s["n_playable"] < 20:
verdict = "TOO FEW BETS — sample insufficient"
elif roi_delta > 5 and b_s["roi"] > 0:
verdict = "✅ FILTERS WORK — ROI improved AND positive"
elif roi_delta > 5:
verdict = "🟡 PARTIAL — ROI improved but still negative"
elif roi_delta > 0:
verdict = "🟡 SLIGHT IMPROVEMENT"
elif roi_delta < -5:
verdict = "❌ OVERFITTING — validation ROI collapsed"
else:
verdict = "❌ NO MATERIAL CHANGE"
print(f" VERDICT: {verdict}")
print(f" ROI: {a_s['roi']}% → {b_s['roi']}% (Δ {roi_delta:+.2f}pp)")
print(f"{'=' * 100}")
if __name__ == "__main__":
main()
+674
View File
@@ -0,0 +1,674 @@
"""
Diagnostic Backtest
===================
Run the full V28 orchestrator (in-process — no HTTP) on a window of completed
matches, capture the recommendation + key signal features + the actual outcome,
and produce a *diagnostic* report: not just "what was the hit rate" but
"which feature clusters drive the losing bets".
Outputs:
- reports/diagnostic_backtest_YYYYMMDD.csv (per-bet detail)
- reports/diagnostic_backtest_YYYYMMDD.json (aggregate metrics)
- reports/diagnostic_backtest_YYYYMMDD.txt (human-readable summary)
Usage:
python scripts/diagnostic_backtest.py --days 14 --max-matches 2000
python scripts/diagnostic_backtest.py --start 2026-05-10 --end 2026-05-24
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import traceback
from collections import defaultdict, Counter
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
import psycopg2
from psycopg2.extras import RealDictCursor
# Path bootstrap so we can import the ai-engine package from anywhere
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
AI_ENGINE_DIR = os.path.dirname(SCRIPT_DIR)
sys.path.insert(0, AI_ENGINE_DIR)
from data.db import get_clean_dsn
from services.single_match_orchestrator import get_single_match_orchestrator
REPORTS_DIR = os.path.join(AI_ENGINE_DIR, "reports")
os.makedirs(REPORTS_DIR, exist_ok=True)
# Days with confirmed feeder gaps — exclude from sample
EXCLUDED_DATES = {"2026-05-03", "2026-04-29"}
# ── Outcome resolution ────────────────────────────────────────────────
def _norm_pick(pick: Optional[str]) -> str:
return str(pick or "").strip().casefold()
def resolve_outcome(market: str, pick: str, sh: int, sa: int,
htsh: Optional[int], htsa: Optional[int]) -> Optional[bool]:
"""Mirror of prediction-settlement.market-resolver.ts (TS side).
Returns True/False on settle, None if cannot resolve."""
m = (market or "").upper().replace(" ", "").replace("-", "_")
p = _norm_pick(pick)
if m in ("MS", "ML", "1X2"):
outcome = "1" if sh > sa else "2" if sa > sh else "x"
return p in {outcome, outcome.upper(), outcome.lower(), "0" if outcome == "x" else outcome}
if m in ("HT", "IY"):
if htsh is None or htsa is None:
return None
outcome = "1" if htsh > htsa else "2" if htsa > htsh else "x"
return p in {outcome, "0" if outcome == "x" else outcome}
if m in ("OU05", "OU15", "OU25", "OU35", "OU45", "TOTAL"):
line = {"OU05": 0.5, "OU15": 1.5, "OU25": 2.5, "OU35": 3.5,
"OU45": 4.5, "TOTAL": 2.5}[m]
total = sh + sa
if total == line:
return None
is_over = total > line
if "over" in p or "üst" in p or "ust" in p:
return is_over
if "alt" in p or "under" in p:
return not is_over
return None
if m in ("OU05_HT", "OU15_HT", "OU25_HT", "HT_OU05", "HT_OU15", "HT_OU25"):
if htsh is None or htsa is None:
return None
line = {"OU05_HT": 0.5, "OU15_HT": 1.5, "OU25_HT": 2.5,
"HT_OU05": 0.5, "HT_OU15": 1.5, "HT_OU25": 2.5}[m]
total = htsh + htsa
if total == line:
return None
is_over = total > line
if "over" in p or "üst" in p or "ust" in p:
return is_over
if "alt" in p or "under" in p:
return not is_over
return None
if m in ("BTTS", "KG"):
both = sh > 0 and sa > 0
if "yes" in p or "var" in p:
return both
if "no" in p or "yok" in p:
return not both
return None
if m in ("HTFT", "IYMS"):
if htsh is None or htsa is None or "/" not in p:
return None
ht_p, ft_p = p.split("/", 1)
ht_actual = "1" if htsh > htsa else "2" if htsa > htsh else "x"
ft_actual = "1" if sh > sa else "2" if sa > sh else "x"
return ht_p.strip() == ht_actual and ft_p.strip() == ft_actual
if m in ("DC", "CIFTE_SANS"):
ft = "1" if sh > sa else "2" if sa > sh else "X"
raw = p.upper().replace("-", "").replace("/", "")
if raw in ("1X", "X1"):
pair = ["1", "X"]
elif raw in ("X2", "2X"):
pair = ["X", "2"]
elif raw in ("12", "21"):
pair = ["1", "2"]
else:
return None
return ft in pair
if m in ("OE", "TEKCIFT"):
is_odd = (sh + sa) % 2 == 1
if "tek" in p or "odd" in p:
return is_odd
if "cift" in p or "çift" in p or "even" in p:
return not is_odd
return None
return None
def compute_unit_profit(won: Optional[bool], stake: float, odds: Optional[float]) -> float:
if won is None:
return 0.0
if not won:
return -abs(stake) if stake else -1.0
if not odds or odds <= 1.0:
return 0.0
return round(stake * (odds - 1.0), 4)
# ── Data fetch ────────────────────────────────────────────────────────
def fetch_match_window(args) -> List[Dict]:
dsn = get_clean_dsn()
if "?schema=" in dsn:
dsn = dsn.split("?schema=")[0]
if args.start and args.end:
start = datetime.strptime(args.start, "%Y-%m-%d")
end = datetime.strptime(args.end, "%Y-%m-%d") + timedelta(days=1)
else:
end = datetime.now(timezone.utc).replace(tzinfo=None)
start = end - timedelta(days=args.days)
start_ms = int(start.timestamp() * 1000)
end_ms = int(end.timestamp() * 1000)
excluded = sorted(EXCLUDED_DATES)
excluded_clause = ""
if excluded:
ex_csv = ",".join(f"'{d}'" for d in excluded)
excluded_clause = (
f" AND to_timestamp(mst_utc/1000)::date "
f"NOT IN ({ex_csv})"
)
with psycopg2.connect(dsn) as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT id AS match_id,
score_home, score_away,
ht_score_home, ht_score_away,
league_id,
to_timestamp(mst_utc/1000)::date AS match_date
FROM matches
WHERE sport='football'
AND status='FT'
AND score_home IS NOT NULL
AND score_away IS NOT NULL
AND mst_utc >= %s
AND mst_utc < %s
{excluded_clause}
ORDER BY mst_utc DESC
LIMIT %s
""",
(start_ms, end_ms, args.max_matches),
)
return cur.fetchall()
# ── Per-bet capture ───────────────────────────────────────────────────
def capture_bet_row(match: Dict, package: Dict) -> Dict[str, Any]:
"""Distill orchestrator response + ground truth into one analytic row."""
main = package.get("main_pick") or {}
bb = main.get("betting_brain") or {}
advice = package.get("bet_advice") or {}
v27 = package.get("v27_engine") or {}
triple = (v27.get("triple_value") or {})
risk = package.get("risk") or {}
quality = package.get("data_quality") or {}
htft_payload = ((package.get("market_board") or {}).get("HTFT") or {})
htft_probs = htft_payload.get("probs") or {}
sh, sa = match["score_home"], match["score_away"]
htsh, htsa = match["ht_score_home"], match["ht_score_away"]
market = main.get("market")
pick = main.get("pick")
odds_val = _f(main.get("odds"))
stake = _f(main.get("stake_units"), 1.0)
playable = bool(main.get("playable")) and bool(advice.get("playable"))
won = resolve_outcome(market, pick, sh, sa, htsh, htsa) if market and pick else None
profit = compute_unit_profit(won, stake, odds_val) if playable else 0.0
# Reversal context (only meaningful for MS picks)
rev_prob = None
if market == "MS" and pick in ("1", "2"):
if pick == "1":
rev_prob = _f(htft_probs.get("1/2"), 0.0) + _f(htft_probs.get("1/X"), 0.0)
else:
rev_prob = _f(htft_probs.get("2/1"), 0.0) + _f(htft_probs.get("2/X"), 0.0)
return {
"match_id": match["match_id"],
"match_date": str(match["match_date"]),
"league_id": match.get("league_id"),
"score_home": sh,
"score_away": sa,
"ht_score_home": htsh,
"ht_score_away": htsa,
"market": market,
"pick": pick,
"odds": odds_val,
"stake_units": stake,
"playable": playable,
"won": won,
"unit_profit": profit,
"raw_confidence": _f(main.get("raw_confidence")),
"calibrated_confidence": _f(main.get("calibrated_confidence")),
"play_score": _f(main.get("play_score")),
"ev_edge": _f(main.get("ev_edge")),
"bet_grade": main.get("bet_grade"),
"is_value_sniper": bool(main.get("is_value_sniper")),
"bb_score": _f(bb.get("score")),
"bb_action": bb.get("action"),
"bb_vetoes": ";".join(bb.get("vetoes") or []),
"bb_issues": ";".join(bb.get("issues") or []),
"bb_positives": ";".join(bb.get("positives") or []),
"bb_model_prob": _f(bb.get("model_prob")),
"bb_implied_prob": _f(bb.get("implied_prob")),
"bb_model_market_gap": _f(bb.get("model_market_gap")),
"bb_divergence": _f(bb.get("divergence")),
"bb_trap_market": bool(bb.get("trap_market_flag")),
"v27_consensus": v27.get("consensus"),
"data_quality_score": _f(quality.get("score")),
"data_quality_flags": ";".join(quality.get("flags") or []),
"risk_level": (risk.get("level") if isinstance(risk, dict) else None),
"odds_reliability": _f(main.get("odds_reliability")),
"htft_reversal_prob": rev_prob,
"htft_top_pick": _argmax(htft_probs),
"league_name": (package.get("match_info") or {}).get("league_name"),
"is_cup": _is_cup((package.get("match_info") or {}).get("league_name") or ""),
"model_version": package.get("model_version"),
"decision_reason": main.get("pick_reason") or advice.get("reason"),
}
def _f(x: Any, default: Optional[float] = None) -> Optional[float]:
try:
return float(x) if x is not None else default
except (TypeError, ValueError):
return default
def _argmax(d: Dict[str, Any]) -> Optional[str]:
best, val = None, -1.0
for k, v in d.items():
fv = _f(v, 0.0) or 0.0
if fv > val:
best, val = k, fv
return best
_CUP_KEYWORDS = ("kupa", "cup", "coupe", "copa", "coppa", "pokal", "trophy",
"shield", "ziraat", "süper kupa", "super cup", "beker", "taça", "taca")
def _is_cup(name: str) -> bool:
n = (name or "").lower()
return any(kw in n for kw in _CUP_KEYWORDS)
# ── Aggregation helpers ────────────────────────────────────────────────
def _bucket(value: Optional[float], edges: List[float]) -> Optional[str]:
if value is None:
return None
for i, edge in enumerate(edges):
if value < edge:
if i == 0:
return f"<{edge}"
return f"{edges[i-1]}-{edge}"
return f">={edges[-1]}"
def _summary_stats(rows: List[Dict]) -> Dict[str, Any]:
if not rows:
return {"n": 0}
settled = [r for r in rows if r["playable"] and r["won"] is not None]
won = sum(1 for r in settled if r["won"])
lost = sum(1 for r in settled if not r["won"])
profit = sum(float(r["unit_profit"]) for r in settled)
staked = sum(float(r["stake_units"]) for r in settled)
return {
"n_total": len(rows),
"n_playable_settled": len(settled),
"wins": won,
"losses": lost,
"hit_rate_pct": round(100.0 * won / len(settled), 2) if settled else None,
"unit_profit": round(profit, 3),
"staked": round(staked, 3),
"roi_pct": round(100.0 * profit / staked, 2) if staked else None,
}
def aggregate(rows: List[Dict]) -> Dict[str, Any]:
out: Dict[str, Any] = {"overall": _summary_stats(rows)}
by = lambda key_fn: defaultdict(list)
market_buckets = by(None)
conf_buckets = by(None)
odds_buckets = by(None)
grade_buckets = by(None)
cup_buckets = by(None)
motivation_buckets = by(None)
for r in rows:
if r["playable"]:
market_buckets[r["market"] or "?"].append(r)
conf_buckets[_bucket(r["calibrated_confidence"],
[45, 50, 55, 60, 65, 70, 80])].append(r)
odds_buckets[_bucket(r["odds"], [1.3, 1.5, 1.8, 2.2, 3.0, 5.0])].append(r)
grade_buckets[r["bet_grade"] or "?"].append(r)
cup_buckets["cup" if r["is_cup"] else "league"].append(r)
out["by_market"] = {k: _summary_stats(v) for k, v in market_buckets.items()}
out["by_confidence"] = {k: _summary_stats(v) for k, v in conf_buckets.items() if k}
out["by_odds"] = {k: _summary_stats(v) for k, v in odds_buckets.items() if k}
out["by_grade"] = {k: _summary_stats(v) for k, v in grade_buckets.items()}
out["by_competition"] = {k: _summary_stats(v) for k, v in cup_buckets.items()}
return out
def loss_diagnostics(rows: List[Dict]) -> Dict[str, Any]:
losses = [r for r in rows if r["playable"] and r["won"] is False]
if not losses:
return {"n_losses": 0}
n = len(losses)
def share(predicate) -> Tuple[int, float]:
c = sum(1 for r in losses if predicate(r))
return c, round(100.0 * c / n, 2)
diagnostics = {
"n_losses": n,
"total_loss_units": round(sum(float(r["unit_profit"]) for r in losses), 3),
"patterns": {
"high_htft_reversal_prob (>=0.20)": share(
lambda r: (r.get("htft_reversal_prob") or 0) >= 0.20
),
"cup_match": share(lambda r: r["is_cup"]),
"low_league_reliability (<0.45)": share(
lambda r: (r.get("odds_reliability") or 1) < 0.45
),
"v27_disagree": share(lambda r: r.get("v27_consensus") == "DISAGREE"),
"trap_market_flagged": share(lambda r: r.get("bb_trap_market")),
"low_calibrated_conf (<55)": share(
lambda r: (r.get("calibrated_confidence") or 0) < 55
),
"high_odds_underdog (>=2.5)": share(
lambda r: (r.get("odds") or 0) >= 2.5
),
"low_data_quality (<0.55)": share(
lambda r: (r.get("data_quality_score") or 1) < 0.55
),
"high_risk_level": share(
lambda r: r.get("risk_level") in ("HIGH", "EXTREME")
),
"inferred_features": share(
lambda r: "ai_features_inferred_from_history" in (r.get("data_quality_flags") or "")
),
},
"by_market": Counter(r["market"] for r in losses).most_common(),
"by_league": Counter(r.get("league_name") for r in losses).most_common(10),
}
# Top issue tags from betting_brain across losses
issue_counter = Counter()
veto_counter = Counter()
for r in losses:
for tag in (r.get("bb_issues") or "").split(";"):
if tag:
issue_counter[tag] += 1
for tag in (r.get("bb_vetoes") or "").split(";"):
if tag:
veto_counter[tag] += 1
diagnostics["top_bb_issues_in_losses"] = issue_counter.most_common(15)
diagnostics["top_bb_vetoes_in_losses"] = veto_counter.most_common(15)
return diagnostics
# ── Recommendations ────────────────────────────────────────────────────
def make_recommendations(rows: List[Dict], agg: Dict[str, Any],
diag: Dict[str, Any]) -> List[Dict[str, Any]]:
recs: List[Dict[str, Any]] = []
overall = agg.get("overall") or {}
if not overall.get("n_playable_settled"):
return recs
# Cross-reference market hit rate vs overall — flag chronic losers.
overall_hit = overall.get("hit_rate_pct") or 0.0
for market, stats in (agg.get("by_market") or {}).items():
n = stats.get("n_playable_settled") or 0
hit = stats.get("hit_rate_pct")
roi = stats.get("roi_pct")
if n < 30:
continue
if hit is not None and roi is not None and roi < -10 and hit < overall_hit - 10:
recs.append({
"type": "drop_market",
"market": market,
"evidence": f"hit={hit}%, roi={roi}%, n={n} — chronic loser",
"suggested_fix": f"Add veto in betting_brain when market=={market} unless overwhelming evidence",
"estimated_loss_prevented_units": round(-(stats.get("unit_profit") or 0), 2),
})
# Confidence band tuning — flag bands where ROI < 0 despite passing eşik
for band, stats in (agg.get("by_confidence") or {}).items():
n = stats.get("n_playable_settled") or 0
roi = stats.get("roi_pct")
if n >= 40 and roi is not None and roi < -8:
recs.append({
"type": "raise_confidence_threshold",
"confidence_band": band,
"evidence": f"n={n}, roi={roi}%",
"suggested_fix": f"Raise MIN_BET_SCORE or market_min_conf above {band.split('-')[0]}",
})
# Loss diagnostic — if cup matches dominate losses, recommend cup-aware filter
patterns = (diag.get("patterns") or {})
cup_share = patterns.get("cup_match", (0, 0))[1]
if cup_share >= 25:
recs.append({
"type": "cup_match_filter",
"evidence": f"{cup_share}% of losses are cup matches",
"suggested_fix": "Tighten betting_brain thresholds for is_cup_match=True picks",
})
rev_share = patterns.get("high_htft_reversal_prob (>=0.20)", (0, 0))[1]
if rev_share >= 15:
recs.append({
"type": "tighten_reversal_check",
"evidence": f"{rev_share}% of losses had HTFT reversal prob >=0.20 (already partial fix)",
"suggested_fix": "Lower reversal threshold in betting_brain from 0.25 to 0.20 for veto trigger",
})
rel_share = patterns.get("low_league_reliability (<0.45)", (0, 0))[1]
if rel_share >= 20:
recs.append({
"type": "league_reliability_filter",
"evidence": f"{rel_share}% of losses in low-reliability leagues (<0.45)",
"suggested_fix": "Add hard veto when odds_reliability<0.45 for non-value-sniper picks",
})
return recs
# ── CSV / report writers ───────────────────────────────────────────────
def write_csv(rows: List[Dict], path: str):
if not rows:
return
import csv
fields = list(rows[0].keys())
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
for r in rows:
w.writerow(r)
def write_text_summary(rows: List[Dict], agg: Dict, diag: Dict,
recs: List[Dict], path: str, args):
lines: List[str] = []
push = lines.append
push("=" * 78)
push("DIAGNOSTIC BACKTEST REPORT")
push("=" * 78)
push(f"Generated: {datetime.now().isoformat(timespec='seconds')}")
push(f"Sample window: start={args.start or f'-{args.days}d'}, end={args.end or 'now'}")
push(f"Max matches: {args.max_matches}")
push(f"Excluded days: {sorted(EXCLUDED_DATES)}")
push("")
push("OVERALL")
push("-" * 78)
overall = agg.get("overall") or {}
for k in ("n_total", "n_playable_settled", "wins", "losses",
"hit_rate_pct", "unit_profit", "staked", "roi_pct"):
push(f" {k:25}: {overall.get(k)}")
push("")
push("PER MARKET")
push("-" * 78)
push(f" {'market':<8} {'n':>6} {'hit%':>7} {'profit':>9} {'roi%':>7}")
for market, s in sorted((agg.get("by_market") or {}).items(),
key=lambda kv: -(kv[1].get("n_playable_settled") or 0)):
push(f" {market:<8} {s.get('n_playable_settled',0):>6} "
f"{str(s.get('hit_rate_pct','')):>7} "
f"{str(s.get('unit_profit','')):>9} "
f"{str(s.get('roi_pct','')):>7}")
push("")
push("PER CALIBRATED CONFIDENCE BAND")
push("-" * 78)
push(f" {'band':<10} {'n':>6} {'hit%':>7} {'roi%':>7}")
for band, s in sorted((agg.get("by_confidence") or {}).items()):
push(f" {band:<10} {s.get('n_playable_settled',0):>6} "
f"{str(s.get('hit_rate_pct','')):>7} "
f"{str(s.get('roi_pct','')):>7}")
push("")
push("PER ODDS BAND")
push("-" * 78)
push(f" {'band':<10} {'n':>6} {'hit%':>7} {'roi%':>7}")
for band, s in sorted((agg.get("by_odds") or {}).items()):
push(f" {band:<10} {s.get('n_playable_settled',0):>6} "
f"{str(s.get('hit_rate_pct','')):>7} "
f"{str(s.get('roi_pct','')):>7}")
push("")
push("LEAGUE vs CUP")
push("-" * 78)
for k, s in (agg.get("by_competition") or {}).items():
push(f" {k:<8} n={s.get('n_playable_settled',0):>4} "
f"hit={s.get('hit_rate_pct','-')}% roi={s.get('roi_pct','-')}%")
push("")
push("LOSS DIAGNOSTICS")
push("-" * 78)
push(f" total losses: {diag.get('n_losses')}")
push(f" total lost units: {diag.get('total_loss_units')}")
push(f" By market: {diag.get('by_market')}")
push(" Loss patterns (count, % of losses):")
for pattern, (c, pct) in (diag.get("patterns") or {}).items():
push(f" {pattern:<55} {c:>4} ({pct}%)")
push(" Top betting_brain issues seen in losses:")
for issue, c in (diag.get("top_bb_issues_in_losses") or []):
push(f" {issue:<55} {c}")
push(" Top betting_brain vetoes (in losses — i.e. veto fired but bet still went through value-sniper override):")
for veto, c in (diag.get("top_bb_vetoes_in_losses") or []):
push(f" {veto:<55} {c}")
push("")
push("RECOMMENDATIONS")
push("-" * 78)
if not recs:
push(" (none surfaced — sample too small or no clear pattern)")
for r in recs:
push(f" • [{r['type']}]")
for k, v in r.items():
if k == "type":
continue
push(f" {k}: {v}")
push("")
push("=" * 78)
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
# ── Main loop ─────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--days", type=int, default=14,
help="Backwards window from now (default 14)")
parser.add_argument("--max-matches", type=int, default=2000,
help="Hard cap on matches processed (default 2000)")
parser.add_argument("--start", help="Start date YYYY-MM-DD (overrides --days)")
parser.add_argument("--end", help="End date YYYY-MM-DD")
parser.add_argument("--progress-interval", type=int, default=50)
args = parser.parse_args()
print("=" * 70)
print("DIAGNOSTIC BACKTEST")
print("=" * 70)
print(f"Loading orchestrator...")
orch = get_single_match_orchestrator()
# Warm V25 + V27 + basketball loaders so the first match doesn't pay it
try:
orch._get_v25_predictor()
except Exception as e:
print(f" v25 warmup: {e}")
try:
orch._get_v27_predictor()
except Exception as e:
print(f" v27 warmup: {e}")
print(f"Fetching match window...")
matches = fetch_match_window(args)
n = len(matches)
print(f" {n} matches selected")
if not matches:
print("No matches to process. Exiting.")
return
rows: List[Dict[str, Any]] = []
errors: List[Tuple[str, str]] = []
t0 = time.time()
for i, m in enumerate(matches, start=1):
mid = str(m["match_id"])
try:
pkg = orch.analyze_match(mid)
if pkg is None:
continue
row = capture_bet_row(m, pkg)
rows.append(row)
except KeyboardInterrupt:
print("\nInterrupted, writing partial results...")
break
except Exception as e:
errors.append((mid, str(e)))
if len(errors) <= 5:
traceback.print_exc()
if i % args.progress_interval == 0:
elapsed = time.time() - t0
rate = i / elapsed
eta = (n - i) / rate if rate else 0
playable_so_far = sum(1 for r in rows if r["playable"])
print(f" [{i}/{n}] rate={rate:.1f}/s eta={eta/60:.1f}min "
f"playable={playable_so_far} errors={len(errors)}")
print(f"\nProcessed {len(rows)} rows in {(time.time()-t0):.1f}s "
f"({len(errors)} errors)")
# Aggregate
print("Aggregating...")
agg = aggregate(rows)
diag = loss_diagnostics(rows)
recs = make_recommendations(rows, agg, diag)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
csv_path = os.path.join(REPORTS_DIR, f"diagnostic_backtest_{stamp}.csv")
json_path = os.path.join(REPORTS_DIR, f"diagnostic_backtest_{stamp}.json")
txt_path = os.path.join(REPORTS_DIR, f"diagnostic_backtest_{stamp}.txt")
write_csv(rows, csv_path)
with open(json_path, "w", encoding="utf-8") as f:
json.dump({"args": vars(args), "aggregate": agg, "loss_diagnostics": diag,
"recommendations": recs, "errors_sample": errors[:20]},
f, indent=2, default=str)
write_text_summary(rows, agg, diag, recs, txt_path, args)
print(f"\nOutputs:")
print(f" CSV: {csv_path}")
print(f" JSON: {json_path}")
print(f" TXT: {txt_path}")
print("\nOverall:", agg.get("overall"))
if __name__ == "__main__":
main()
+254
View File
@@ -0,0 +1,254 @@
"""
Filter Optimizer
================
Grid-search over filter thresholds (per market) using the existing
diagnostic_backtest CSV. Finds the (confidence, edge, odds, reliability)
combination that maximizes ROI while keeping bet volume reasonable.
No re-prediction needed — pure offline simulation on the bets already
captured. Output: per-market optimal thresholds + projected ROI lift +
JSON patch ready to drop into config/market_thresholds.json.
Usage:
python scripts/optimize_filters.py
python scripts/optimize_filters.py --csv reports/diagnostic_backtest_X.csv
python scripts/optimize_filters.py --min-bets 20 --apply
"""
import argparse
import json
import os
import sys
import glob
import itertools
from typing import List, Dict, Tuple, Optional
import pandas as pd
import numpy as np
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
AI_ENGINE_DIR = os.path.dirname(SCRIPT_DIR)
sys.path.insert(0, AI_ENGINE_DIR)
REPORTS_DIR = os.path.join(AI_ENGINE_DIR, "reports")
CONFIG_PATH = os.path.join(AI_ENGINE_DIR, "config", "market_thresholds.json")
def latest_csv() -> Optional[str]:
files = sorted(glob.glob(os.path.join(REPORTS_DIR, "diagnostic_backtest_*.csv")),
key=os.path.getmtime, reverse=True)
return files[0] if files else None
def load_backtest(path: str) -> pd.DataFrame:
df = pd.read_csv(path)
# Keep only playable + settled bets — these are what the SYSTEM
# actually placed and got an outcome on.
pdf = df[(df["playable"] == True) & (df["won"].notna())].copy()
pdf["won"] = pdf["won"].astype(bool)
pdf["calibrated_confidence"] = pdf["calibrated_confidence"].fillna(0)
pdf["ev_edge"] = pdf["ev_edge"].fillna(0)
pdf["odds"] = pdf["odds"].fillna(0)
pdf["odds_reliability"] = pdf["odds_reliability"].fillna(0.35)
return pdf
def evaluate(pdf: pd.DataFrame, mask) -> Dict:
kept = pdf[mask]
if len(kept) == 0:
return {"n": 0, "hit_pct": 0, "profit": 0, "staked": 0, "roi_pct": 0}
wins = kept["won"].sum()
profit = kept["unit_profit"].sum()
staked = kept["stake_units"].sum()
return {
"n": int(len(kept)),
"hit_pct": round(100.0 * wins / len(kept), 2),
"profit": round(profit, 3),
"staked": round(staked, 3),
"roi_pct": round(100.0 * profit / staked, 2) if staked else 0,
}
def grid_search_market(
market_df: pd.DataFrame,
market: str,
min_bets: int = 15,
) -> List[Dict]:
"""Try a wide grid of (min_conf, min_edge, max_edge, min_odds, max_odds,
min_reliability) combinations. Return all candidates with n >= min_bets,
sorted by ROI descending."""
conf_options = [0, 45, 50, 55, 60, 65, 70]
min_edge_options = [-1.0, -0.05, 0.0, 0.03, 0.05, 0.08]
max_edge_options = [10.0, 0.30, 0.20, 0.15, 0.10]
min_odds_options = [1.20, 1.30, 1.40, 1.50, 1.60, 1.80]
max_odds_options = [10.0, 3.0, 2.5, 2.2, 2.0]
rel_options = [0.0, 0.30, 0.45, 0.55]
consensus_options = ["any", "agree_or_null"]
candidates: List[Dict] = []
for mc, mine, maxe, mino, maxo, mrel, cons in itertools.product(
conf_options, min_edge_options, max_edge_options,
min_odds_options, max_odds_options, rel_options, consensus_options,
):
if mine >= maxe or mino >= maxo:
continue
mask = (
(market_df["calibrated_confidence"] >= mc)
& (market_df["ev_edge"] >= mine)
& (market_df["ev_edge"] <= maxe)
& (market_df["odds"] >= mino)
& (market_df["odds"] <= maxo)
& (market_df["odds_reliability"] >= mrel)
)
if cons == "agree_or_null":
mask &= market_df["v27_consensus"] != "DISAGREE"
result = evaluate(market_df, mask)
if result["n"] >= min_bets:
candidates.append({
"market": market,
"min_conf": mc,
"min_edge": mine,
"max_edge": maxe,
"min_odds": mino,
"max_odds": maxo,
"min_reliability": mrel,
"consensus": cons,
**result,
})
candidates.sort(key=lambda r: (r["roi_pct"], r["n"]), reverse=True)
return candidates
def baseline(pdf: pd.DataFrame, market: str) -> Dict:
m = pdf[pdf["market"] == market]
return evaluate(m, pd.Series([True] * len(m), index=m.index))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--csv", default=None, help="Override CSV path")
parser.add_argument("--min-bets", type=int, default=15,
help="Min bet count to consider a config valid")
parser.add_argument("--top-k", type=int, default=3,
help="Show top K configs per market")
parser.add_argument("--apply", action="store_true",
help="Patch config/market_thresholds.json with winners")
args = parser.parse_args()
csv_path = args.csv or latest_csv()
if not csv_path or not os.path.exists(csv_path):
print("No backtest CSV found.")
return
print(f"Loading: {csv_path}")
pdf = load_backtest(csv_path)
print(f"Playable + settled bets: {len(pdf)}")
markets = sorted(pdf["market"].dropna().unique())
print(f"Markets: {markets}\n")
all_winners: Dict[str, Dict] = {}
for market in markets:
market_df = pdf[pdf["market"] == market]
n_total = len(market_df)
base = baseline(pdf, market)
print(f"\n{'=' * 78}")
print(f"MARKET: {market} (n={n_total} baseline_roi={base['roi_pct']}%)")
print(f"{'=' * 78}")
if n_total < args.min_bets * 2:
print(f" Sample too small to grid-search reliably (n={n_total}). Skip.")
continue
candidates = grid_search_market(market_df, market, args.min_bets)
if not candidates:
print(f" No config kept >= {args.min_bets} bets. Skip.")
continue
# Pareto-ish: show top-K by ROI but also one that keeps higher bet count
winners = candidates[:args.top_k]
keep_high_volume = None
for c in candidates:
if c["n"] >= max(40, n_total // 3) and c["roi_pct"] > base["roi_pct"]:
keep_high_volume = c
break
print(f" {'rank':<5}{'n':>5}{'hit%':>7}{'roi%':>8} "
f"{'min_conf':>9}{'min_edge':>10}{'max_edge':>10}"
f"{'min_odds':>10}{'max_odds':>10}{'min_rel':>9}{'cons':>15}")
for i, w in enumerate(winners, 1):
print(f" {i:<5}{w['n']:>5}{w['hit_pct']:>7}{w['roi_pct']:>+8}"
f" {w['min_conf']:>9}{w['min_edge']:>+10.3f}{w['max_edge']:>+10.3f}"
f"{w['min_odds']:>10.2f}{w['max_odds']:>10.2f}"
f"{w['min_reliability']:>9.2f}{w['consensus']:>15}")
if keep_high_volume and keep_high_volume not in winners:
print(f" high {keep_high_volume['n']:>5}{keep_high_volume['hit_pct']:>7}"
f"{keep_high_volume['roi_pct']:>+8}"
f" {keep_high_volume['min_conf']:>9}"
f"{keep_high_volume['min_edge']:>+10.3f}"
f"{keep_high_volume['max_edge']:>+10.3f}"
f"{keep_high_volume['min_odds']:>10.2f}"
f"{keep_high_volume['max_odds']:>10.2f}"
f"{keep_high_volume['min_reliability']:>9.2f}"
f"{keep_high_volume['consensus']:>15}")
# Pick a "good" recommendation: best ROI with n >= min_bets
# If best ROI is still negative, flag the market as unprofitable.
best = winners[0]
all_winners[market] = best
if best["roi_pct"] <= 0:
print(f" ⚠️ Best config still loses money (ROI={best['roi_pct']}%) "
f"— consider muting this market entirely.")
else:
print(f" ✅ Best config: ROI={best['roi_pct']}% on {best['n']} bets "
f"(vs baseline {base['roi_pct']}% on {n_total}).")
# ─── Aggregate impact ────────────────────────────────────────────────
print(f"\n{'=' * 78}")
print("AGGREGATE IMPACT (if we apply each market's best config)")
print(f"{'=' * 78}")
total_old_bets = total_old_profit = total_old_staked = 0
total_new_bets = total_new_profit = total_new_staked = 0
for market, win in all_winners.items():
base = baseline(pdf, market)
total_old_bets += base["n"]
total_old_profit += base["profit"]
total_old_staked += base["staked"]
total_new_bets += win["n"]
total_new_profit += win["profit"]
total_new_staked += win["staked"]
base_roi = 100.0 * total_old_profit / total_old_staked if total_old_staked else 0
new_roi = 100.0 * total_new_profit / total_new_staked if total_new_staked else 0
print(f" Baseline: {total_old_bets:>4} bets, "
f"profit={total_old_profit:+.2f}u, ROI={base_roi:+.2f}%")
print(f" Optimized: {total_new_bets:>4} bets, "
f"profit={total_new_profit:+.2f}u, ROI={new_roi:+.2f}%")
print(f" Δ: {total_new_bets - total_old_bets:+d} bets, "
f"{total_new_profit - total_old_profit:+.2f}u, "
f"{new_roi - base_roi:+.2f}pp")
# ─── Write JSON patch ────────────────────────────────────────────────
patch_path = os.path.join(REPORTS_DIR, "filter_optimization_patch.json")
patch = {market: {
"min_calibrated_confidence": win["min_conf"],
"min_ev_edge": win["min_edge"],
"max_ev_edge": win["max_edge"],
"min_odds": win["min_odds"],
"max_odds": win["max_odds"],
"min_odds_reliability": win["min_reliability"],
"require_v27_agree": win["consensus"] == "agree_or_null",
"expected_n_bets": win["n"],
"expected_hit_pct": win["hit_pct"],
"expected_roi_pct": win["roi_pct"],
} for market, win in all_winners.items()}
with open(patch_path, "w", encoding="utf-8") as f:
json.dump(patch, f, indent=2)
print(f"\nPatch saved: {patch_path}")
if args.apply:
print("\n--apply flag set. Patching not implemented yet — "
"review the patch JSON and update config/market_thresholds.json manually.")
if __name__ == "__main__":
main()
+54
View File
@@ -0,0 +1,54 @@
"""Smoke test for the score-coherence filter using the LAFC vs Sounders
1-0 scenario from production. Verifies that markets that contradict the
predicted score are correctly excluded from the coherent set, and that
the markets the model got right are all included.
"""
import os, sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from services.betting_brain import BettingBrain
brain = BettingBrain()
pkg = {
"score_prediction": {"ft": "1-0", "ht": "0-0"},
}
coh = brain._score_consistent_markets(pkg)
print(f"Predicted: 1-0 (HT 0-0)")
print(f"Coherent set size: {len(coh)}")
print()
# Each pick the system actually offered for the LAFC match, with whether
# it was the *actual* winning pick.
test_picks = [
("MS", "1", True, "correct"),
("MS", "2", False, "wrong"),
("MS", "X", False, "wrong"),
("DC", "1X", True, "correct"),
("DC", "12", True, "correct"),
("DC", "X2", False, "wrong"),
("OU25", "Üst", False, "WRONG — system featured this"),
("OU25", "Alt", True, "correct"),
("OU35", "Alt", True, "correct"),
("OU35", "Üst", False, "wrong"),
("BTTS", "Var", False, "wrong"),
("BTTS", "Yok", True, "correct"),
("HT", "X", True, "correct"),
("HT", "1", False, "wrong"),
("HTFT", "X/1", True, "correct"),
("HTFT", "1/1", False, "wrong (HT was 0-0)"),
("HT_OU05", "Üst", False, "wrong"),
("HT_OU05", "Alt", True, "correct"),
("OE", "Çift", False, "wrong (1 is odd)"),
("OE", "Tek", True, "correct"),
]
print(f"{'market':<10}{'pick':<10}{'real-win?':<12}{'in-coherent?':<14}{'match?'}")
print("-" * 60)
ok = 0
for market, pick, would_win, note in test_picks:
in_coh = (market, pick) in coh
match = "" if in_coh == would_win else "✗ MISMATCH"
if in_coh == would_win: ok += 1
print(f"{market:<10}{pick:<10}{str(would_win):<12}{str(in_coh):<14}{match} {note}")
print()
print(f"Result: {ok}/{len(test_picks)} picks correctly classified")
+201 -1
View File
@@ -39,6 +39,31 @@ class BettingBrain:
SNIPER_BLOCKED_MARKETS = {"HT", "HTFT", "OE", "CARDS", "HT_OU05", "HT_OU15"}
# Markets that lose money under every filter combination per the
# diagnostic backtest (1000 matches). Until calibration is rebuilt for
# these specifically, force NO_BET. Re-evaluate after each backtest run.
MUTED_MARKETS = {"BTTS"}
# Per-market optimal filter envelopes derived from the diagnostic
# backtest grid search (reports/filter_optimization_patch.json). Any
# pick falling OUTSIDE this envelope is vetoed. Tightens the playable
# band to the ROI-positive zone identified empirically.
#
# Each entry: {min_conf, min_edge, max_edge, min_odds, max_odds,
# min_reliability, require_v27_agree}
MARKET_OPTIMAL_FILTERS = {
"MS": {
"min_edge": -0.05, "max_edge": 0.15,
"min_odds": 1.20, "max_odds": 10.0,
"min_reliability": 0.0, "require_v27_agree": True,
},
"OU25": {
"min_edge": -1.0, "max_edge": 0.15,
"min_odds": 1.80, "max_odds": 10.0,
"min_reliability": 0.0, "require_v27_agree": False,
},
}
MARKET_PRIORS = {
"DC": 4.0,
"OU15": 3.0,
@@ -86,6 +111,36 @@ class BettingBrain:
watchlist.sort(key=self._candidate_sort_key, reverse=True)
no_value.sort(key=self._candidate_sort_key, reverse=True)
# ── SCORE COHERENCE FILTER ──────────────────────────────────────
# If the model also produced a score prediction (e.g. 1-0), pick
# main_pick from the subset of candidates that would WIN at that
# score. Stops the system from recommending OU25 Üst while also
# predicting 1-0 (only 1 goal). Falls back to original list if no
# coherent candidate exists.
coherent_set = self._score_consistent_markets(guarded)
coherent_flag = False
if coherent_set:
def is_coherent(row: Dict[str, Any]) -> bool:
m = str(row.get("market") or "")
p = str(row.get("pick") or "")
return (m, p) in coherent_set
approved_coh = [r for r in approved if is_coherent(r)]
watchlist_coh = [r for r in watchlist if is_coherent(r)]
if approved_coh:
approved = approved_coh
coherent_flag = True
elif watchlist_coh:
# No coherent BET candidates — at least promote a coherent
# watch over an incoherent BET.
watchlist = watchlist_coh + [r for r in watchlist if not is_coherent(r)]
coherent_flag = True
# Tag every row so the UI/diagnostics can see what happened
for row in judged_rows.values():
row.setdefault("betting_brain", {})
row["betting_brain"]["score_coherent"] = is_coherent(row)
original_main = guarded.get("main_pick") or {}
main_pick = None
decision = "NO_BET"
@@ -142,10 +197,11 @@ class BettingBrain:
rejected = [d for d in decisions if d.get("action") == "REJECT"]
guarded["betting_brain"] = {
"version": "judge-v1",
"version": "judge-v2-score-coherent",
"decision": decision,
"reason": decision_reason,
"main_pick_key": main_key or None,
"score_coherent_filter_applied": coherent_flag,
"approved_count": len(approved),
"watchlist_count": len(watchlist),
"rejected_count": len(rejected),
@@ -243,6 +299,44 @@ class BettingBrain:
if play_score < 50.0 and not is_value_sniper:
vetoes.append("play_score_too_low")
# ── HARD EV-EDGE VETO ───────────────────────────────────────────
# Diagnostic backtest (1000 maç, 524 settled bet) gösterdi ki
# ev_edge < 0 olan bahisler %76 of all picks ve ROI yaklaşık -%16.
# ev_edge < 0 = "model market'in altında olasılık veriyor" = vig'i
# yiyemeyeceğimiz negative-EV bahis. Hard veto: oynama.
# Sniper override hâlâ geçer (yüksek convicted alternatif pick'ler).
if ev_edge < 0.0 and not is_value_sniper:
vetoes.append("negative_ev_edge")
issues.append(f"ev_edge={ev_edge:.3f}_below_zero")
# Trap edge: bizim diagnostic backtest'te ev_edge >= 0.20 olan tüm
# bahisler kaybediyordu (n=10, hepsi -%25+ ROI). Model market'i bu
# kadar yanlış buluyorsa muhtemelen modelin kendisinin yanlış olduğu
# bir senaryo (eksik info, tuhaf maç, vs.) — oynama.
if ev_edge >= 0.20 and not is_value_sniper:
vetoes.append("ev_edge_too_high_trap")
issues.append(f"ev_edge={ev_edge:.3f}_trap_range")
# ── MUTED MARKETS (grid search showed no profitable config) ──
if market in self.MUTED_MARKETS and not is_value_sniper:
vetoes.append("market_muted_by_backtest")
issues.append(f"market_{market}_muted")
# ── PER-MARKET OPTIMAL ENVELOPE (from grid search) ──
envelope = self.MARKET_OPTIMAL_FILTERS.get(market)
if envelope and not is_value_sniper:
if ev_edge < envelope["min_edge"]:
vetoes.append("outside_envelope_edge_low")
if ev_edge > envelope["max_edge"]:
vetoes.append("outside_envelope_edge_high")
if odds and odds < envelope["min_odds"]:
vetoes.append("outside_envelope_odds_low")
if odds and odds > envelope["max_odds"]:
vetoes.append("outside_envelope_odds_high")
if odds_rel < envelope["min_reliability"]:
vetoes.append("outside_envelope_reliability_low")
if envelope["require_v27_agree"] and consensus != "AGREE":
vetoes.append("outside_envelope_v27_must_agree")
if divergence is not None:
if divergence >= self.HARD_DIVERGENCE and not is_value_sniper:
score -= 42.0
@@ -635,6 +729,112 @@ class BettingBrain:
return self._safe_float(ou25.get(key)) if key else None
return None
def _score_consistent_markets(self, package: Dict[str, Any]) -> Optional[set]:
"""Build the set of (market, pick) tuples that WOULD WIN if the
model's own score prediction came true. We use this as a coherence
gate: if the model is confident about a 1-0 outcome but also wants
to play OU25 Üst, those two beliefs contradict each other — and the
score prediction is the more informative one because it aggregates
all market signals into a single most-likely scenario.
Returns None if the score prediction is missing or malformed; in
that case we skip the coherence check.
"""
score_pred = package.get("score_prediction") or {}
ft_raw = str(score_pred.get("ft") or score_pred.get("full_time") or "").strip()
ht_raw = str(score_pred.get("ht") or score_pred.get("half_time") or "").strip()
def parse(s: str) -> Optional[Tuple[int, int]]:
for sep in ("-", ":", ""):
if sep in s:
parts = s.split(sep, 1)
try:
return int(parts[0].strip()), int(parts[1].strip())
except (ValueError, IndexError):
return None
return None
ft = parse(ft_raw)
if ft is None:
return None
ht = parse(ht_raw)
fh, fa = ft
total = fh + fa
consistent: set = set()
# MS / 1X2 — single outcome
if fh > fa:
consistent.add(("MS", "1"))
consistent.add(("ML", "1"))
elif fh < fa:
consistent.add(("MS", "2"))
consistent.add(("ML", "2"))
else:
consistent.add(("MS", "X"))
consistent.add(("ML", "X"))
# DC — two of three legs win at any score
if fh >= fa:
consistent.add(("DC", "1X"))
if fh <= fa:
consistent.add(("DC", "X2"))
if fh != fa:
consistent.add(("DC", "12"))
# Over/Under main lines
for line, market in ((0.5, "OU05"), (1.5, "OU15"),
(2.5, "OU25"), (3.5, "OU35"), (4.5, "OU45")):
if total > line:
for p in ("Üst", "Ust", "Over", "OVER"):
consistent.add((market, p))
elif total < line:
for p in ("Alt", "Under", "UNDER"):
consistent.add((market, p))
# total == line → push, neither side wins → don't add
# BTTS — both teams score
if fh > 0 and fa > 0:
for p in ("Var", "KG Var", "Yes", "YES"):
consistent.add(("BTTS", p))
else:
for p in ("Yok", "KG Yok", "No", "NO"):
consistent.add(("BTTS", p))
# OE — total goals odd/even
if total % 2 == 1:
for p in ("Tek", "Odd", "ODD"):
consistent.add(("OE", p))
else:
for p in ("Çift", "Cift", "Even", "EVEN"):
consistent.add(("OE", p))
# HT-only markets (need HT score)
if ht is not None:
hh, ha = ht
ht_total = hh + ha
if hh > ha:
consistent.add(("HT", "1"))
elif hh < ha:
consistent.add(("HT", "2"))
else:
consistent.add(("HT", "X"))
for line, market in ((0.5, "HT_OU05"), (1.5, "HT_OU15"), (2.5, "HT_OU25")):
if ht_total > line:
for p in ("Üst", "Ust", "Over"):
consistent.add((market, p))
elif ht_total < line:
for p in ("Alt", "Under"):
consistent.add((market, p))
# HTFT — single combo
ht_o = "1" if hh > ha else "2" if hh < ha else "X"
ft_o = "1" if fh > fa else "2" if fh < fa else "X"
consistent.add(("HTFT", f"{ht_o}/{ft_o}"))
consistent.add(("HTFT", f"{ht_o}{ft_o}"))
return consistent
def _triple_value(self, package: Dict[str, Any], key: Optional[str]) -> Optional[Dict[str, Any]]:
if not key:
return None
+13 -1
View File
@@ -449,6 +449,12 @@ class DataLoaderMixin:
return 1.5, 1.2
return weighted_for / total_weight, weighted_against / total_weight
# Approximate European season window — Eredivisie/PL/La Liga start late
# July / mid-August, end May. Using 300 days as a buffer covers most
# competitions while excluding "career points" from previous seasons.
# When a proper seasons table lands this should query season boundaries.
_SEASON_LOOKBACK_MS = 300 * 24 * 60 * 60 * 1000
def _estimate_league_position(
self,
cur: RealDictCursor,
@@ -458,6 +464,7 @@ class DataLoaderMixin:
) -> int:
if not team_id or not league_id:
return 10
season_start_ms = before_date_ms - self._SEASON_LOOKBACK_MS
try:
cur.execute(
"""
@@ -478,6 +485,7 @@ class DataLoaderMixin:
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND m.mst_utc < %s
AND m.mst_utc >= %s
UNION ALL
SELECT
m.away_team_id AS team_id,
@@ -492,11 +500,15 @@ class DataLoaderMixin:
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND m.mst_utc < %s
AND m.mst_utc >= %s
) tm
GROUP BY tm.team_id
ORDER BY points DESC
""",
(league_id, before_date_ms, league_id, before_date_ms),
(
league_id, before_date_ms, season_start_ms,
league_id, before_date_ms, season_start_ms,
),
)
rows = cur.fetchall()
if not rows:
@@ -225,20 +225,43 @@ class FeatureBuilderMixin:
if enrichment_failures:
print(f"⚠️ Enrichment partial failures for {data.match_id}: {', '.join(enrichment_failures)}")
# ── Cup game detection (used by upset engine + elo dampening below) ──
_league_name_lower = (getattr(data, 'league_name', '') or '').lower()
_cup_keywords = ("kupa", "cup", "coupe", "copa", "coppa", "pokal",
"trophy", "shield", "ziraat", "süper kupa", "super cup",
"beker", "taça", "taca")
_is_cup_match = any(kw in _league_name_lower for kw in _cup_keywords)
# ── League size hint: top European leagues 18-20 teams, lower 16-24 ──
# We don't have a per-league team count, so fall back to 20 (standard).
# When standings infra lands this should pull from seasons table.
_league_total_teams = 20
# Upset engine features
upset_atmosphere, upset_motivation, upset_fatigue = 0.0, 0.0, 0.0
try:
upset_engine = get_upset_engine()
# Use the real position estimates from data_loader; fall back to mid-
# table (10) only when the loader couldn't compute one. Hardcoding 10
# for every team made motivation_score collapse to 0 for everyone.
_home_pos = getattr(data, 'home_position', None)
_away_pos = getattr(data, 'away_position', None)
if _home_pos is None or _home_pos <= 0:
_home_pos = 10
if _away_pos is None or _away_pos <= 0:
_away_pos = 10
upset_feats = upset_engine.get_features(
home_team_name=getattr(data, 'home_team_name', '') or '',
home_team_id=data.home_team_id,
away_team_name=getattr(data, 'away_team_name', '') or '',
league_name=getattr(data, 'league_name', '') or '',
home_position=10,
away_position=10,
home_position=_home_pos,
away_position=_away_pos,
match_date_ms=data.match_date_ms,
is_cup_match=_is_cup_match,
home_days_rest=int(home_rest),
away_days_rest=int(away_rest),
total_teams=_league_total_teams,
)
upset_atmosphere = upset_feats.get('upset_atmosphere', 0.0)
upset_motivation = upset_feats.get('upset_motivation', 0.0)
@@ -276,15 +299,10 @@ class FeatureBuilderMixin:
is_season_start = 1.0 if match_month in (7, 8, 9) else 0.0
is_season_end = 1.0 if match_month in (5, 6) else 0.0
# ── Cup game detection: dampen home advantage in feature space ──
_league_name = (getattr(data, 'league_name', '') or '').lower()
_cup_keywords = ("kupa", "cup", "coupe", "copa", "coppa", "pokal",
"trophy", "shield", "ziraat", "süper kupa", "super cup")
_is_cup = any(kw in _league_name for kw in _cup_keywords)
# ── Derived / Interaction features (V27) ──
# Cup games: home ELO advantage is ~30% weaker (rotation, lower motivation)
elo_diff = (home_elo - away_elo) * (0.70 if _is_cup else 1.0)
# Uses _is_cup_match computed earlier (before upset engine call).
elo_diff = (home_elo - away_elo) * (0.70 if _is_cup_match else 1.0)
form_elo_diff = home_form_elo_val - away_form_elo_val
attack_vs_defense_home = data.home_goals_avg - data.away_conceded_avg
attack_vs_defense_away = data.away_goals_avg - data.home_conceded_avg
+261
View File
@@ -0,0 +1,261 @@
# SESSION HANDOFF — iddaai sistem durumu
**Son güncelleme**: 2026-05-25 ~20:30
**Hedef**: Başka makinede / yeni Claude session'ında bu doc tek başına okunup işin nerede kaldığı anlaşılabilmeli.
---
## 🎯 Üst-seviye hedef
Sistem **maç başı-1 saat** kullanıcı tetiklemesiyle çalışacak. Bahis uzmanı seviyesinde:
- **main_pick + value_pick** (sistemin önerdiği)
- **Tüm market olasılıkları** (MS, HT, OU05-45, BTTS, OE, DC, HTFT, HCAP, Cards, Corners)
- **Net HT + FT skoru** + **Top-5 olası skor dağılımı**
- **Evidence panel**: lineup impact, son 5 maç, h2h, hakem profili, benzer-oran-band geçmişi
Ürün modeli: hem user kendi bahisini oynar, hem sistem para kazanırsa abonelik satılır.
Hedef ROI: **≥%10**. Günde **3-5 kaliteli bahis**.
Detaylı requirements doc: bu dosyanın altında, "Requirements Spec" bölümü.
---
## 🟢 Şu an arka planda KOŞAN işler
### 1. Validation backtest (LOCAL — bu laptop)
- **Script**: `ai-engine/scripts/diagnostic_backtest.py`
- **Komut**: `python scripts/diagnostic_backtest.py --start 2026-05-01 --end 2026-05-14 --max-matches 1500`
- **Log**: `ai-engine/validation_full.log` (OneDrive senkronize)
- **Çıkış**: bittiğinde `ai-engine/reports/diagnostic_backtest_<timestamp>.{csv,json,txt}`
- **Tahmini bitiş**: 2026-05-25 ~22:00 (yaklaşık)
- **Amaç**: Yeni kodla (calibrator + ev_edge veto + envelope + coherence + BTTS mute) **out-of-sample** doğrulama
- **Risk**: Laptop uyursa ölür. Bitmesini beklemen lazım VEYA partial sonuçla devam.
```powershell
# Status check (kendin)
$log='C:\Users\fahri\OneDrive\المستندات\GitHub\iddaai\iddaai-be\ai-engine\validation_full.log'
Select-String $log 'rate=|Outputs:' | Select-Object -Last 3 | ForEach-Object {$_.Line}
```
### 2. Feeder historical scan (REMOTE — Pi server)
- **Konum**: SSH @ haruncan@95.70.252.214:2222 → docker container `iddaai-be` → pm2
- **PM2 process**: `feeder-historical` (id=1)
- **Log rotation**: pm2-logrotate kurulu (max 30MB/dosya, 3 dosya, gzip)
- **Davranış**: 2026-05-03'ten geriye 2023-06-01'e kadar mackolik'ten odds/lineup patch
- **Otomatik restart**: 502 olunca 30 sn delay sonra restart (max 1000 kez)
- **Beklenen süre**: 24-72 saat
```bash
# Status (kendin SSH'la)
sudo docker exec iddaai-be pm2 list
sudo docker exec iddaai-be pm2 logs feeder-historical --lines 30 --nostream
```
---
## 📝 Bu seansta yapılan KOD değişiklikleri
Hepsi local repo'da, OneDrive senkronize edecek, başka makinede pull etmesen de açtığında orada olacak.
### A. Settlement / data layer
| Dosya | Değişiklik |
|---|---|
| `iddaai-be/prisma.config.ts` | `.env` fallback ekledim (`.env.local` üstüne) — `prisma generate` çalışsın diye |
| `iddaai-be/src/tasks/prediction-settlement.market-resolver.ts` | DC parser ayraçsız "1X/X2/12" kabul ediyor + HT_OU05/HT_OU15/HT_OU25 resolver eklendi |
| `iddaai-be/src/tasks/feature-enrichment.task.ts` **(YENİ)** | Cron 08:15 — eksik football_ai_features row insert + odds_movement SQL backfill |
| `iddaai-be/src/tasks/python-enrichment.task.ts` **(YENİ)** | Cron 08:25 — Python `enrich_ai_features.py` subprocess |
| `iddaai-be/src/tasks/tasks.module.ts` | İki yeni task register |
| `iddaai-be/src/scripts/run-feature-enrichment.ts` **(YENİ)** | Manuel one-shot trigger |
### B. AI engine — betting brain
`iddaai-be/ai-engine/services/betting_brain.py` — büyük revizyon:
- **HARD_MIN_SAMPLES = 50** floor (calibrator bypass <50 sample)
- **`ev_edge < 0.0` HARD VETO** (`negative_ev_edge`)
- **`ev_edge >= 0.20` HARD VETO** (`ev_edge_too_high_trap`)
- **`MUTED_MARKETS = {"BTTS"}`** — backtest no profitable config bulduğu için
- **`MARKET_OPTIMAL_FILTERS`** — MS ve OU25 için grid-search'ten gelen optimal envelope
- **`_score_consistent_markets()`** — skor tahminine uymayan picks elimine
- **`judge()` score coherence filter** — main_pick coherent set'ten seçilir
- **HTFT reversal cross-check** — Man City 1/2 senaryosu
### C. AI engine — model & calibration
| Dosya | Değişiklik |
|---|---|
| `ai-engine/models/calibration.py` | HARD_MIN_SAMPLES floor + sample-weighted blend formülü değişti |
| `ai-engine/models/calibration/*.pkl` | **10 calibrator retrain** (ms_home/draw/away, ou15/25/35, btts, ht_home/draw/away) — 4989-5000 sample her biri |
### D. AI engine — orchestrator feature builder
`ai-engine/services/orchestrator/feature_builder.py`:
- Hardcoded `home_position=10, away_position=10` → real `data.home_position` kullanılıyor
- Cup detection upper'a taşındı, `is_cup_match` UpsetEngine'e geçiyor
- Total teams parametresi UpsetEngine'e geçiyor
`ai-engine/services/orchestrator/data_loader.py`:
- `_estimate_league_position` artık **sezon filtresi** (son 300 gün) kullanıyor
### E. AI engine — scripts (yeni)
| Dosya | Ne yapıyor |
|---|---|
| `ai-engine/scripts/diagnostic_backtest.py` | Per-bet diagnostic backtest (CSV+JSON+TXT output) |
| `ai-engine/scripts/analyze_backtest_csv.py` | Backtest CSV üzerinde root-cause hipotez testleri |
| `ai-engine/scripts/optimize_filters.py` | Grid search per-market optimal threshold |
| `ai-engine/scripts/compare_backtests.py` | İki CSV karşılaştırması verdict ile |
| `ai-engine/scripts/test_score_coherence.py` | Coherence filter smoke test (LAFC senaryosu) |
### F. Social poster modülü (NestJS)
| Dosya | Değişiklik |
|---|---|
| `src/modules/social-poster/social-poster.service.ts` | Cron 15→10 dk, window 10-60, MAX_POSTS_PER_RUN, getHealthStatus() |
| `src/modules/social-poster/image-renderer.service.ts` | SEO filename + metadata sidecar (.json) |
| `src/modules/social-poster/caption-generator.service.ts` | SEO hashtag stratejisi (12 küratör tag) |
| `src/modules/social-poster/social-poster.controller.ts` | `/health` public + `/preview-png/:matchId` + `/run-now` endpoints |
| `mds/SOCIAL_POSTER_SETUP.md` **(YENİ)** | Env vars + API key alma adımları + test komutları |
### G. Modern image rendering (deneme)
| Dosya | Açıklama |
|---|---|
| `src/scripts/render-social-card-v3.ts` | satori + resvg-js ile modern HTML→PNG rendering (Twemoji top + bayrak) |
| `src/modules/social-poster/assets/*.svg` | Twemoji futbol/basket/bayrak SVG'leri |
### H. Yapılan DB değişiklikleri (idempotent — tekrar koşturulursa sorun yok)
| İşlem | Etki |
|---|---|
| `football_ai_features` 4008+ satır backfill | Son 60 günün FT maçları için feature row var artık (calculator_ver=feature_enrichment_task_v1) |
| Python enrichment koştu | h2h, referee, possession, league_avg, implied_* hepsi gerçek değerlerle dolu (181,614+ satır enriched) |
| Calibrator dosyaları yazıldı | `ai-engine/models/calibration/*.pkl` overwritten |
---
## 📂 Önemli dosya konumları (OneDrive synced)
```
iddaai-be/
├── mds/
│ ├── SESSION_HANDOFF.md ← BU DOSYA
│ └── SOCIAL_POSTER_SETUP.md ← social poster env+keys
├── ai-engine/
│ ├── reports/ ← BACKTEST CIKTILARI
│ │ ├── diagnostic_backtest_*.csv,json,txt
│ │ └── filter_optimization_patch.json
│ ├── validation_full.log ← validation backtest canlı log
│ ├── diagnostic_backtest_run.log ← önceki backtest log
│ ├── enrichment_run3.log ← enrichment koşma log
│ └── calibration_run.log ← calibrator retrain log
├── public/predictions/ ← render edilmiş social card PNG/JSON
└── src/scripts/ ← tüm yeni script'ler
```
---
## 🔑 Erişim bilgileri
### Pi sunucu (feeder + prod stack)
- **SSH**: `haruncan@95.70.252.214:2222`
- **Şifre**: `M594xH%$iM&4MM`
- **Plink kullan**: `~/plink.exe -ssh -P 2222 -pw '<password>' -hostkey 'SHA256:iq0YVI/4J897sf9dkksI7QzetpLCD0l57ZMX4UissI8' haruncan@95.70.252.214`
- **Docker**: `iddaai-be`, `iddaai-ai-engine`, `iddaai-fe`, `iddaai-postgres`, `iddaai-redis`, `gitea`
### DB (uzak Postgres @ Pi)
- **SSH tunnel function**: `iddaai-db` PowerShell fonksiyonu (yerel makinedeki profile'da kayıtlı)
- **Tunnel: localhost:5432 → Pi:5432**
- **Connection string**: `postgresql://iddaai_user:IddaA1_S4crET!@localhost:5432/iddaai_db?schema=public`
- **MCP**: Claude'un postgres MCP'si bu tunnel üzerinden çalışıyor (restricted mode, read-only)
---
## 📊 BACKTEST sonuçları geçmişi
### Backtest #1 — In-sample grid search (2026-05-11 → 05-24, 1000 maç)
- **CSV**: `ai-engine/reports/diagnostic_backtest_20260525_035649.csv`
- **TXT**: `ai-engine/reports/diagnostic_backtest_20260525_035649.txt`
- **Toplam playable**: 524 bet
- **Hit rate**: %54.77
- **ROI**: **%16.73** (baseline kötü)
- **Grid-search'ten çıkan optimal filtreler (in-sample)**:
- MS: edge [-5%, +15%], V27 AGREE zorunlu → +%8.23 (21 bet)
- OU25: odds ≥ 1.80, edge ≤ +15% → +%28.91 (20 bet)
- BTTS: tüm config'lerde kayıp → MUTE
- **Aggregate optimize**: 95 bet, ROI +%2.16 (in-sample)
### Backtest #2 — Validation (2026-05-01 → 05-14, KOŞUYOR)
- **Bitince konum**: `ai-engine/reports/diagnostic_backtest_<yeni_timestamp>.{csv,json,txt}`
- **Karşılaştırma çalıştır**: `python scripts/compare_backtests.py` (otomatik en yeni 2'yi alır)
- **Beklenen sonuç**: ROI ≥ 0 → out-of-sample doğrulama BAŞARILI; in-sample overfit değil
---
## ❓ Backtest BİTTİĞİNDE yapılacak (yeni session'da bu kısımdan başla)
### 1. Sonucu oku
```powershell
cd C:\Users\fahri\OneDrive\المستندات\GitHub\iddaai\iddaai-be\ai-engine
Get-ChildItem reports\diagnostic_backtest_*.txt | Sort-Object LastWriteTime -Descending | Select-Object -First 1 | Get-Content
```
### 2. Karşılaştır
```powershell
python scripts\compare_backtests.py
```
Bu otomatik en yeni 2 backtest'i karşılaştırır, **VERDICT** verir:
- ✅ "FILTERS WORK" → ROI pozitif AND improved
- 🟡 "PARTIAL" → improved ama hâlâ negatif
- ❌ "OVERFITTING" → validation ROI collapse
### 3. Karara göre 2 yol
**Eğer ROI ≥ +%2 ve overfit yok:**
- `/sc:design` ile UI/API contract → Sprint 1
- Sprint 1: top-5 skor + evidence panel + "why" cümlesi
- Test edip prod'a aç
**Eğer ROI negatif veya overfit:**
- `analyze_backtest_csv.py` ile loss diagnostic
- Hangi market hâlâ kötü → tighten filter veya mute
- Calibrator recalibrate (özellikle BTTS dışındakiler için yeni sample)
- Tekrar backtest
---
## ⚠️ Bilinen açık problemler / sorular
1. **Coherence filter validate edilmedi production-side** — smoke test 20/20 ama gerçek production data ile karşılaştırma yok
2. **Lineup-overlap last-5 hesabı** — yazılmadı, requirements doc'ta F8 var
3. **Skor top-5 distribution** — Poisson zaten hesaplıyor, surface edilmedi (UI tarafı)
4. **"Why" cümlesi main_pick'te** — boş, doldurulması gerek
5. **Cards/Corners/RED CARD model** — yok, "henüz desteklenmiyor" placeholder ile bırak (kullanıcı onayladı: mevcut market'ler sağlamlaşsın)
6. **Orphan match_id 51 satır**`prediction_runs` içinde, `matches`'ta yok. Sample noise, geçiştirilebilir.
7. **opening_value feeder bug**`odds_movement_*` SQL yazıyor ama tüm değerler 0 (opening == closing). Feeder upstream sorun. Düşük öncelik.
---
## 🚦 Yeni Claude session'ında ilk komut
```
Bu projeye yeni bağlandım. Lütfen aşağıdaki dosyayı oku ve bana proje durumunu özet ver:
C:\Users\fahri\OneDrive\المستندات\GitHub\iddaai\iddaai-be\mds\SESSION_HANDOFF.md
Sonra validation backtest'in sonucuna bak:
- C:\Users\fahri\OneDrive\المستندات\GitHub\iddaai\iddaai-be\ai-engine\reports\
içindeki en yeni diagnostic_backtest_*.txt dosyasını oku
- compare_backtests.py script'ini koş, verdict göster
- Verdict'e göre sonraki adımı öner
```
Buradan devam eder. Tüm context bu doc'ta + dosyalarda + DB'de.
---
## 🛠️ Requirements spec (sıkıştırılmış)
**Ürün**: UI-tetikli per-match analiz, bahis uzmanı seviyesi
**Trigger**: User tıklar, on-demand
**Output**: main_pick + value_pick + tüm market olasılıkları + tek HT/FT skoru + top-5 skor dağılımı + evidence panel
**Kapsam**: Mevcut market'ler sağlamlaştırılır, yeni market eklenmez (kullanıcı onayı)
**Quality bar**: Calibration sapması ±2-5pp per market, NaN yok, response <3sn
**Validation**: Out-of-sample backtest (1500 maç, May 1-14) — KOŞUYOR
---
**SON NOT**: Backtest'in TAMAMLANMASINI bekle (~22:00). Laptop'u kapatma. Bittiğinde OneDrive senkronize eder, başka makinede otomatik orada olur. Yeni session'da bu dosyayı oku, sonuçlara bak, devam et.
+212
View File
@@ -0,0 +1,212 @@
# Social Poster — Setup & Operations
Otomatik tahmin kartı üretip Twitter / Facebook / Instagram'a postlayan modül.
Cron her **10 dakikada bir** çalışır, **yaklaşan 10-60 dk içindeki maçları**
yakalar, AI Engine'den tahmin alır, 1080×1080 görsel üretir, caption üretir,
3 platforma post eder.
## 1) ENV değişkenleri
`.env`'ye ekle:
```bash
# Master switch — false ise cron çalışmaz, manual endpoint'ler de boş döner.
SOCIAL_POSTER_ENABLED=true
# Hangi sporlar (virgüllü liste). Varsayılan: football,basketball
SOCIAL_POSTER_SPORTS=football,basketball
# Yaklaşan maç penceresi (dakika). Varsayılan 10-60.
SOCIAL_POSTER_WINDOW_MIN=10
SOCIAL_POSTER_WINDOW_MAX=60
# Tek cron koşusunda kaç maç post edilir (rate-limit koruması). Varsayılan 5.
SOCIAL_POSTER_MAX_PER_RUN=5
# Public base URL — Instagram media upload için fotoğrafın HTTPS'ten erişilebilir
# olması ŞART. Localhost ile IG çalışmaz; production domain veya ngrok kullan.
APP_BASE_URL=https://api.iddaai.com
# AI Engine URL (orchestrator)
AI_ENGINE_URL=http://localhost:8000
# ─── Twitter / X ───
TWITTER_API_KEY=...
TWITTER_API_SECRET=...
TWITTER_ACCESS_TOKEN=...
TWITTER_ACCESS_SECRET=...
# ─── Meta (Facebook + Instagram) ───
META_PAGE_ACCESS_TOKEN=... # FB Page'in long-lived access token'ı
META_PAGE_ID=... # FB Page numeric ID
META_IG_USER_ID=... # IG Business account numeric ID
META_GRAPH_API_VERSION=v25.0 # opsiyonel
# ─── Caption AI (opsiyonel — yoksa template caption kullanılır) ───
ENABLE_GEMINI=true
GEMINI_API_KEY=...
GEMINI_MODEL=gemini-1.5-flash
# Veya local Ollama:
OLLAMA_BASE_URL=http://localhost:11434
SOCIAL_POSTER_OLLAMA_MODEL=llama3.1
```
## 2) API anahtarlarını alma
### Twitter / X
1. https://developer.x.com → Project + App oluştur
2. App'ın "Keys and tokens" → "API Key", "API Secret" al
3. User authentication settings → "Read and write and Direct Message"
4. "Access Token and Secret" generate et (bu hesap adına post eder)
5. **Free tier**: 1500 tweet/ay, 50 post/24 saat — 10 dk'lık cron'la günde
~144 koşu × 5 post = 720 potansiyel post → free tier yetmez, **Basic plan
($200/ay)** lazım. Cron interval'i 30 dk'ya alıp 50/gün kalmak istersen
`@Cron("*/30 * * * *")` olarak değiştir.
### Meta (Facebook + Instagram)
1. https://developers.facebook.com → App oluştur (type: Business)
2. Facebook Page bağla (mevcut sayfan yoksa oluştur)
3. Instagram Business hesabını Facebook Page'e bağla
4. Graph API Explorer'dan **page access token** al (User token değil!)
5. Long-lived token'a çevir (60 gün geçerli, refresh edilebilir)
6. **Page ID**: `https://graph.facebook.com/me/accounts?access_token=...`
7. **IG User ID**: `graph.facebook.com/{pageId}?fields=instagram_business_account&access_token=...`
8. Required permissions: `pages_show_list`, `pages_manage_posts`,
`pages_read_engagement`, `instagram_basic`, `instagram_content_publish`
### Gemini (caption AI — opsiyonel)
- https://aistudio.google.com → API key (free tier yeterli, günde ~1500 istek)
- `ENABLE_GEMINI=true` + `GEMINI_API_KEY=...`
- Gemini yoksa template caption kullanılır (yine SEO'lu, sadece daha statik)
## 3) Test komutları
```bash
# Servisi başlat
npm run start:dev
# Health endpoint — auth gerekmez
curl http://localhost:3005/social-poster/health | jq
# Manuel preview (görsel + JSON) — superadmin token gerekir
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:3005/social-poster/preview/<matchId>
# Görseli tarayıcıda direkt göster
open http://localhost:3005/social-poster/preview-png/<matchId>?token=$TOKEN
# Manuel post (tek maç, tüm platformlara) — superadmin token
curl -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:3005/social-poster/post/<matchId>
# Cron'u beklemeden full sweep koş — superadmin token
curl -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:3005/social-poster/run-now
```
## 4) SEO özellikleri
### Image dosya adı (SEO)
Eskiden: `prediction_basketball_xyz12345_1716595200000.jpg` (opaque)
Yeni: `sampiyonlar-ligi-unicaja-malaga-vs-aek-20260525.jpg` (Google indexable)
### Yan dosya: metadata sidecar
Her görsel için aynı dizinde `.json`:
- `title`, `description`, `og:*`, `schema.org SportsEvent`, `picks[]`
- Sayfada `<head>` Open Graph + Twitter Cards bu dosyadan beslenir
- Schema.org markup zengin sonuç (Google rich snippet) sağlar
### Caption (SEO + hashtags)
Her post 12'ye kadar küratör hashtag içerir:
- Marka: `#MaçTahmini #İddaa #BugünMaç`
- Spor: `#Futbol #Basketbol #FutbolTahmin`
- Lig: `#SüperLig #PremierLeague #ŞampiyonlarLigi #EuroLeague #NBA`
- Bölge: `#Türkiye #İngiltere #İspanya`
- Takım: `#Galatasaray #Fenerbahçe`
- Gün: `#PazarTahmini #CumartesiTahmini`
- Market: `#AltÜst #KGVar #ÇifteŞans #MaçSonucu #Handikap`
LLM (Gemini) caption üretiyorsa hashtag'leri çıkarır; sistem kendi
hashtag set'ini ekler. Tutarlı index için tek kaynak.
## 5) İzleme
```bash
# Health endpoint — periyodik monitor
curl http://localhost:3005/social-poster/health | jq
# Sample output:
{
"enabled": true,
"sports": ["football", "basketball"],
"window_min_minutes": 10,
"window_max_minutes": 60,
"max_posts_per_run": 5,
"top_leagues_loaded": 42,
"posted_match_count": 137,
"last_run_at": "2026-05-25T03:10:00.123Z",
"last_run_result": { "posted": 4, "skipped": 1, "errors": 0 },
"twitter_available": true,
"meta_facebook_available": true,
"meta_instagram_available": true,
"ai_engine_url": "http://localhost:8000",
"app_base_url": "https://api.iddaai.com"
}
```
`posted_match_count` `storage/social-poster-posted.json`'dan okunur, son 500
match ID hafızada — aynı maçı 2 kere post etmez.
## 6) Rate limit ipuçları
| Platform | Free limit | Tedbir |
|---|---|---|
| Twitter | 50 post/24 saat | `SOCIAL_POSTER_MAX_PER_RUN=2` + cron `*/30` → günde ~96 |
| Facebook | ~200 post/saat (Page) | Default config rahat |
| Instagram | 25 post/24 saat | `MAX_PER_RUN=1` + cron `*/60` → günde 24, sınırın hemen altında |
IG en sıkı sınır — production için **IG ayrı cron'da daha seyrek post**
yapılması önerilir (kod henüz tek cron, ileride ayrılabilir).
## 7) Hangi maçlar seçilir?
`top_leagues.json` dosyasındaki league_id'ler içinden:
- Şu anda 10-60 dakika sonra başlayacak
- Daha önce post edilmemiş
- `sport: football, basketball` filtresi geçen
`top_leagues.json` yoksa **tüm liglerden** maç seçer (hacmi yüksek tutar).
Sadece premium ligler postlamak istersen dosyayı doldur.
## 8) Görsel formatı
- **Boyut**: 1080×1080 (Instagram square — Twitter da kabul ediyor)
- **Format**: JPEG, quality 94
- **Tema**: Sport'a göre değişir — football yeşil, basketball turuncu
- **İçerik**: Lig logosu + ülke bayrağı, takım logoları + adları, HT skor,
FT skor, top 3 tahmin (confidence ile), risk badge
Card layout `image-renderer.service.ts` içinde — value-pick yıldız ile
işaretli, scenario top 3 listelenir, footer alt'ta tarih + brand.
## 9) Sık sorular
**Q: Görseller nereye yazılıyor?**
`public/predictions/` (gitignored). ServeStatic ile `/predictions/<file>.jpg`
URL'inden erişilir.
**Q: Eski görseller temizleniyor mu?**
Hayır — manuel temizlik gerekir. Cron eklemek istersen `LimitResetterTask`
örneği var.
**Q: AI Engine çalışmıyorsa ne olur?**
Cron tahmin alamaz, log'a hata düşer, devam eder. Sonraki koşuda dener.
**Q: Bir maç 2 kere post ediliyor mu?**
Hayır — `postedMatchIds` set'i Match ID bazında dedup yapar, dosyaya yazılır
(restart-safe).
**Q: Caption Gemini olmadan ne kadar iyi?**
Template caption tüm bilgileri + 12 hashtag içerir. SEO açısından yeterli,
sadece anlatım daha statik. Gemini ile her post için özgün metin.
+417
View File
@@ -27,6 +27,7 @@
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^6.19.3",
"@resvg/resvg-js": "^2.6.2",
"axios": "^1.13.6",
"bcrypt": "^6.0.0",
"bullmq": "^5.66.4",
@@ -49,6 +50,8 @@
"prisma": "^6.19.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"satori": "^0.26.0",
"satori-html": "^0.3.2",
"twitter-api-v2": "^1.29.0",
"zod": "^4.3.5"
},
@@ -3912,12 +3915,243 @@
"@redis/client": "^1.0.0"
}
},
"node_modules/@resvg/resvg-js": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
"integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==",
"license": "MPL-2.0",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@resvg/resvg-js-android-arm-eabi": "2.6.2",
"@resvg/resvg-js-android-arm64": "2.6.2",
"@resvg/resvg-js-darwin-arm64": "2.6.2",
"@resvg/resvg-js-darwin-x64": "2.6.2",
"@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2",
"@resvg/resvg-js-linux-arm64-gnu": "2.6.2",
"@resvg/resvg-js-linux-arm64-musl": "2.6.2",
"@resvg/resvg-js-linux-x64-gnu": "2.6.2",
"@resvg/resvg-js-linux-x64-musl": "2.6.2",
"@resvg/resvg-js-win32-arm64-msvc": "2.6.2",
"@resvg/resvg-js-win32-ia32-msvc": "2.6.2",
"@resvg/resvg-js-win32-x64-msvc": "2.6.2"
}
},
"node_modules/@resvg/resvg-js-android-arm-eabi": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz",
"integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==",
"cpu": [
"arm"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-android-arm64": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz",
"integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-darwin-arm64": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz",
"integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-darwin-x64": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz",
"integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-linux-arm-gnueabihf": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz",
"integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==",
"cpu": [
"arm"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-linux-arm64-gnu": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz",
"integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-linux-arm64-musl": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz",
"integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-linux-x64-gnu": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz",
"integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-linux-x64-musl": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz",
"integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-win32-arm64-msvc": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz",
"integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-win32-ia32-msvc": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz",
"integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==",
"cpu": [
"ia32"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-win32-x64-msvc": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz",
"integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"hasInstallScript": true
},
"node_modules/@shuding/opentype.js": {
"version": "1.4.0-beta.0",
"resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz",
"integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==",
"license": "MIT",
"dependencies": {
"fflate": "^0.7.3",
"string.prototype.codepointat": "^0.2.1"
},
"bin": {
"ot": "bin/ot"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.34.47",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz",
@@ -6535,6 +6769,15 @@
"node": ">=6"
}
},
"node_modules/camelize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001762",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
@@ -7086,6 +7329,36 @@
"node": ">= 8"
}
},
"node_modules/css-background-parser": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz",
"integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==",
"license": "MIT"
},
"node_modules/css-box-shadow": {
"version": "1.0.0-3",
"resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz",
"integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==",
"license": "MIT"
},
"node_modules/css-color-keywords": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
"integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
"license": "ISC",
"engines": {
"node": ">=4"
}
},
"node_modules/css-gradient-parser": {
"version": "0.0.17",
"resolved": "https://registry.npmjs.org/css-gradient-parser/-/css-gradient-parser-0.0.17.tgz",
"integrity": "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
@@ -7102,6 +7375,17 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-to-react-native": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
"integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
"license": "MIT",
"dependencies": {
"camelize": "^1.0.0",
"css-color-keywords": "^1.0.0",
"postcss-value-parser": "^4.0.2"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
@@ -7433,6 +7717,15 @@
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/emoji-regex-xs": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz",
"integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/empathic": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
@@ -8152,6 +8445,12 @@
"node": "^12.20 || >= 14.13"
}
},
"node_modules/fflate": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz",
"integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -8794,6 +9093,18 @@
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"dev": true
},
"node_modules/hex-rgb": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz",
"integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==",
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/hookified": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.0.tgz",
@@ -10097,6 +10408,25 @@
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.33.tgz",
"integrity": "sha512-r9kw4OA6oDO4dPXkOrXTkArQAafIKAU71hChInV4FxZ69dxCfbwQGDPzqR5/vea94wU705/3AZroEbSoeVWrQw=="
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -11030,6 +11360,12 @@
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
},
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -11042,6 +11378,16 @@
"node": ">=6"
}
},
"node_modules/parse-css-color": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz",
"integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.1.4",
"hex-rgb": "^4.1.0"
}
},
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@@ -11438,6 +11784,12 @@
"node": ">=4"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -12012,6 +12364,37 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/satori": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/satori/-/satori-0.26.0.tgz",
"integrity": "sha512-tkMFrfIs3l2mQ2JEcyW0ADTy3zGggFRFzi6Ef8YozQSFsFKEqaSO1Y8F9wJg4//PJGQauMalHGTUEkPrFwhVPA==",
"license": "MPL-2.0",
"dependencies": {
"@shuding/opentype.js": "1.4.0-beta.0",
"css-background-parser": "^0.1.0",
"css-box-shadow": "1.0.0-3",
"css-gradient-parser": "^0.0.17",
"css-to-react-native": "^3.0.0",
"emoji-regex-xs": "^2.0.1",
"escape-html": "^1.0.3",
"linebreak": "^1.1.0",
"parse-css-color": "^0.2.1",
"postcss-value-parser": "^4.2.0",
"yoga-layout": "^3.2.1"
},
"engines": {
"node": ">=16"
}
},
"node_modules/satori-html": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/satori-html/-/satori-html-0.3.2.tgz",
"integrity": "sha512-wjTh14iqADFKDK80e51/98MplTGfxz2RmIzh0GqShlf4a67+BooLywF17TvJPD6phO0Hxm7Mf1N5LtRYvdkYRA==",
"license": "MIT",
"dependencies": {
"ultrahtml": "^1.2.0"
}
},
"node_modules/schema-utils": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
@@ -12488,6 +12871,12 @@
"node": ">=8"
}
},
"node_modules/string.prototype.codepointat": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz",
"integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==",
"license": "MIT"
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -12858,6 +13247,12 @@
"real-require": "^0.2.0"
}
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
@@ -13260,6 +13655,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ultrahtml": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz",
"integrity": "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==",
"license": "MIT"
},
"node_modules/undici": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz",
@@ -13274,6 +13675,16 @@
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -13858,6 +14269,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yoga-layout": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT"
},
"node_modules/zod": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
+3
View File
@@ -60,6 +60,7 @@
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^6.19.3",
"@resvg/resvg-js": "^2.6.2",
"axios": "^1.13.6",
"bcrypt": "^6.0.0",
"bullmq": "^5.66.4",
@@ -82,6 +83,8 @@
"prisma": "^6.19.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"satori": "^0.26.0",
"satori-html": "^0.3.2",
"twitter-api-v2": "^1.29.0",
"zod": "^4.3.5"
},
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><circle fill="#F4900C" cx="18" cy="18" r="18"/><path fill="#231F20" d="M36 17h-8.981c.188-5.506 1.943-9.295 4.784-10.546-.445-.531-.926-1.027-1.428-1.504-2.83 1.578-5.145 5.273-5.354 12.049H19V0h-2v17h-6.021c-.208-6.776-2.523-10.471-5.353-12.049-.502.476-.984.972-1.428 1.503C7.039 7.705 8.793 11.494 8.981 17H0v2h8.981c-.188 5.506-1.942 9.295-4.783 10.546.445.531.926 1.027 1.428 1.504 2.831-1.578 5.145-5.273 5.353-12.05H17v17h2V19h6.021c.209 6.776 2.523 10.471 5.354 12.05.502-.476.984-.973 1.428-1.504-2.841-1.251-4.595-5.04-4.784-10.546H36v-2z"/></svg>

After

Width:  |  Height:  |  Size: 617 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFCD05" d="M0 27c0 2.209 1.791 4 4 4h28c2.209 0 4-1.791 4-4v-4H0v4z"/><path fill="#ED1F24" d="M0 14h36v9H0z"/><path fill="#141414" d="M32 5H4C1.791 5 0 6.791 0 9v5h36V9c0-2.209-1.791-4-4-4z"/></svg>

After

Width:  |  Height:  |  Size: 271 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#C60A1D" d="M36 27c0 2.209-1.791 4-4 4H4c-2.209 0-4-1.791-4-4V9c0-2.209 1.791-4 4-4h28c2.209 0 4 1.791 4 4v18z"/><path fill="#FFC400" d="M0 12h36v12H0z"/><path fill="#EA596E" d="M9 17v3c0 1.657 1.343 3 3 3s3-1.343 3-3v-3H9z"/><path fill="#F4A2B2" d="M12 16h3v3h-3z"/><path fill="#DD2E44" d="M9 16h3v3H9z"/><ellipse fill="#EA596E" cx="12" cy="14.5" rx="3" ry="1.5"/><ellipse fill="#FFAC33" cx="12" cy="13.75" rx="3" ry=".75"/><path fill="#99AAB5" d="M7 16h1v7H7zm9 0h1v7h-1z"/><path fill="#66757F" d="M6 22h3v1H6zm9 0h3v1h-3zm-8-7h1v1H7zm9 0h1v1h-1z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#039" d="M32 5H4C1.791 5 0 6.791 0 9v18c0 2.209 1.791 4 4 4h28c2.209 0 4-1.791 4-4V9c0-2.209-1.791-4-4-4z"/><path d="M18.539 9.705l.849-.617h-1.049l-.325-.998-.324.998h-1.049l.849.617-.325.998.849-.617.849.617zm0 17.333l.849-.617h-1.049l-.325-.998-.324.998h-1.049l.849.617-.325.998.849-.617.849.617zm-8.666-8.667l.849-.617h-1.05l-.324-.998-.325.998H7.974l.849.617-.324.998.849-.617.849.617zm1.107-4.285l.849-.617h-1.05l-.324-.998-.324.998h-1.05l.849.617-.324.998.849-.617.849.617zm0 8.619l.849-.617h-1.05l-.324-.998-.324.998h-1.05l.849.617-.324.998.849-.617.849.617zm3.226-11.839l.849-.617h-1.05l-.324-.998-.324.998h-1.05l.849.617-.324.998.849-.617.849.617zm0 15.067l.849-.617h-1.05l-.324-.998-.324.998h-1.05l.849.617-.324.998.849-.616.849.616zm11.921-7.562l-.849-.617h1.05l.324-.998.325.998h1.049l-.849.617.324.998-.849-.617-.849.617zm-1.107-4.285l-.849-.617h1.05l.324-.998.324.998h1.05l-.849.617.324.998-.849-.617-.849.617zm0 8.619l-.849-.617h1.05l.324-.998.324.998h1.05l-.849.617.324.998-.849-.617-.849.617zm-3.226-11.839l-.849-.617h1.05l.324-.998.324.998h1.05l-.849.617.324.998-.849-.617-.849.617zm0 15.067l-.849-.617h1.05l.324-.998.324.998h1.05l-.849.617.324.998-.849-.616-.849.616z" fill="#FC0"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#ED2939" d="M36 27c0 2.209-1.791 4-4 4h-8V5h8c2.209 0 4 1.791 4 4v18z"/><path fill="#002495" d="M4 5C1.791 5 0 6.791 0 9v18c0 2.209 1.791 4 4 4h8V5H4z"/><path fill="#EEE" d="M12 5h12v26H12z"/></svg>

After

Width:  |  Height:  |  Size: 270 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#00247D" d="M0 9.059V13h5.628zM4.664 31H13v-5.837zM23 25.164V31h8.335zM0 23v3.941L5.63 23zM31.337 5H23v5.837zM36 26.942V23h-5.631zM36 13V9.059L30.371 13zM13 5H4.664L13 10.837z"/><path fill="#CF1B2B" d="M25.14 23l9.712 6.801c.471-.479.808-1.082.99-1.749L28.627 23H25.14zM13 23h-2.141l-9.711 6.8c.521.53 1.189.909 1.938 1.085L13 23.943V23zm10-10h2.141l9.711-6.8c-.521-.53-1.188-.909-1.937-1.085L23 12.057V13zm-12.141 0L1.148 6.2C.677 6.68.34 7.282.157 7.949L7.372 13h3.487z"/><path fill="#EEE" d="M36 21H21v10h2v-5.836L31.335 31H32c1.117 0 2.126-.461 2.852-1.199L25.14 23h3.487l7.215 5.052c.093-.337.158-.686.158-1.052v-.058L30.369 23H36v-2zM0 21v2h5.63L0 26.941V27c0 1.091.439 2.078 1.148 2.8l9.711-6.8H13v.943l-9.914 6.941c.294.07.598.116.914.116h.664L13 25.163V31h2V21H0zM36 9c0-1.091-.439-2.078-1.148-2.8L25.141 13H23v-.943l9.915-6.942C32.62 5.046 32.316 5 32 5h-.663L23 10.837V5h-2v10h15v-2h-5.629L36 9.059V9zM13 5v5.837L4.664 5H4c-1.118 0-2.126.461-2.852 1.2l9.711 6.8H7.372L.157 7.949C.065 8.286 0 8.634 0 9v.059L5.628 13H0v2h15V5h-2z"/><path fill="#CF1B2B" d="M21 15V5h-6v10H0v6h15v10h6V21h15v-6z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#CE2B37" d="M36 27c0 2.209-1.791 4-4 4h-8V5h8c2.209 0 4 1.791 4 4v18z"/><path fill="#009246" d="M4 5C1.791 5 0 6.791 0 9v18c0 2.209 1.791 4 4 4h8V5H4z"/><path fill="#EEE" d="M12 5h12v26H12z"/></svg>

After

Width:  |  Height:  |  Size: 270 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#E30917" d="M36 27c0 2.209-1.791 4-4 4H4c-2.209 0-4-1.791-4-4V9c0-2.209 1.791-4 4-4h28c2.209 0 4 1.791 4 4v18z"/><path fill="#EEE" d="M16 24c-3.314 0-6-2.685-6-6 0-3.314 2.686-6 6-6 1.31 0 2.52.425 3.507 1.138-1.348-1.524-3.312-2.491-5.507-2.491-4.061 0-7.353 3.292-7.353 7.353 0 4.062 3.292 7.354 7.353 7.354 2.195 0 4.16-.967 5.507-2.492C18.521 23.575 17.312 24 16 24zm3.913-5.77l2.44.562.22 2.493 1.288-2.146 2.44.561-1.644-1.888 1.287-2.147-2.303.98-1.644-1.889.22 2.494z"/></svg>

After

Width:  |  Height:  |  Size: 556 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#B22334" d="M35.445 7C34.752 5.809 33.477 5 32 5H18v2h17.445zM0 25h36v2H0zm18-8h18v2H18zm0-4h18v2H18zM0 21h36v2H0zm4 10h28c1.477 0 2.752-.809 3.445-2H.555c.693 1.191 1.968 2 3.445 2zM18 9h18v2H18z"/><path fill="#EEE" d="M.068 27.679c.017.093.036.186.059.277.026.101.058.198.092.296.089.259.197.509.333.743L.555 29h34.89l.002-.004c.135-.233.243-.483.332-.741.034-.099.067-.198.093-.301.023-.09.042-.182.059-.275.041-.22.069-.446.069-.679H0c0 .233.028.458.068.679zM0 23h36v2H0zm0-4v2h36v-2H18zm18-4h18v2H18zm0-4h18v2H18zM0 9c0-.233.03-.457.068-.679C.028 8.542 0 8.767 0 9zm.555-2l-.003.005L.555 7zM.128 8.044c.025-.102.06-.199.092-.297-.034.098-.066.196-.092.297zM18 9h18c0-.233-.028-.459-.069-.68-.017-.092-.035-.184-.059-.274-.027-.103-.059-.203-.094-.302-.089-.258-.197-.507-.332-.74.001-.001 0-.003-.001-.004H18v2z"/><path fill="#3C3B6E" d="M18 5H4C1.791 5 0 6.791 0 9v10h18V5z"/><path fill="#FFF" d="M2.001 7.726l.618.449-.236.725L3 8.452l.618.448-.236-.725L4 7.726h-.764L3 7l-.235.726zm2 2l.618.449-.236.725.617-.448.618.448-.236-.725L6 9.726h-.764L5 9l-.235.726zm4 0l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L9 9l-.235.726zm4 0l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L13 9l-.235.726zm-8 4l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L5 13l-.235.726zm4 0l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L9 13l-.235.726zm4 0l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L13 13l-.235.726zm-6-6l.618.449-.236.725L7 8.452l.618.448-.236-.725L8 7.726h-.764L7 7l-.235.726zm4 0l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L11 7l-.235.726zm4 0l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L15 7l-.235.726zm-12 4l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L3 11l-.235.726zM6.383 12.9L7 12.452l.618.448-.236-.725.618-.449h-.764L7 11l-.235.726h-.764l.618.449zm3.618-1.174l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L11 11l-.235.726zm4 0l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L15 11l-.235.726zm-12 4l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L3 15l-.235.726zM6.383 16.9L7 16.452l.618.448-.236-.725.618-.449h-.764L7 15l-.235.726h-.764l.618.449zm3.618-1.174l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L11 15l-.235.726zm4 0l.618.449-.236.725.617-.448.618.448-.236-.725.618-.449h-.764L15 15l-.235.726z"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><circle fill="#F5F8FA" cx="18" cy="18" r="18"/><path d="M18 11c-.552 0-1-.448-1-1V3c0-.552.448-1 1-1s1 .448 1 1v7c0 .552-.448 1-1 1zm-6.583 4.5c-.1 0-.202-.015-.302-.047l-8.041-2.542c-.527-.167-.819-.728-.652-1.255.166-.527.73-.818 1.255-.652l8.042 2.542c.527.167.819.729.652 1.255-.136.426-.53.699-.954.699zm13.625-.291c-.434 0-.833-.285-.96-.722-.154-.531.151-1.085.682-1.239l6.75-1.958c.531-.153 1.085.153 1.238.682.154.531-.151 1.085-.682 1.239l-6.75 1.958c-.092.027-.186.04-.278.04zm2.001 14.958c-.306 0-.606-.14-.803-.403l-5.459-7.333c-.33-.442-.238-1.069.205-1.399.442-.331 1.069-.238 1.399.205l5.459 7.333c.33.442.238 1.069-.205 1.399-.179.134-.389.198-.596.198zm-18.294-.083c-.197 0-.395-.058-.57-.179-.454-.316-.565-.938-.25-1.392l5.125-7.375c.315-.454.938-.566 1.392-.251.454.315.565.939.25 1.392l-5.125 7.375c-.194.281-.506.43-.822.43zM3.5 27.062c-.44 0-.844-.293-.965-.738L.347 18.262c-.145-.533.17-1.082.704-1.227.535-.141 1.083.171 1.227.704l2.188 8.062c.145.533-.17 1.082-.704 1.226-.088.025-.176.035-.262.035zM22 34h-9c-.552 0-1-.447-1-1s.448-1 1-1h9c.553 0 1 .447 1 1s-.447 1-1 1zm10.126-6.875c-.079 0-.16-.009-.24-.029-.536-.132-.864-.674-.731-1.21l2.125-8.625c.133-.536.679-.862 1.21-.732.536.132.864.674.731 1.211l-2.125 8.625c-.113.455-.521.76-.97.76zM30.312 7.688c-.17 0-.342-.043-.5-.134L22.25 3.179c-.478-.277-.642-.888-.364-1.367.275-.478.886-.643 1.366-.365l7.562 4.375c.478.277.642.888.364 1.367-.185.32-.521.499-.866.499zm-24.811 0c-.312 0-.618-.145-.813-.417-.322-.45-.22-1.074.229-1.396l6.188-4.438c.449-.322 1.074-.219 1.396.229.322.449.219 1.074-.229 1.396L6.083 7.5c-.177.126-.38.188-.582.188z" fill="#CCD6DD"/><path d="M25.493 13.516l-7.208-5.083c-.348-.245-.814-.243-1.161.006l-7.167 5.167c-.343.248-.494.684-.375 1.091l2.5 8.583c.124.426.515.72.96.72H22c.43 0 .81-.274.948-.681l2.917-8.667c.141-.419-.011-.881-.372-1.136zM1.292 19.542c.058 0 .117-.005.175-.016.294-.052.55-.233.697-.494l3.375-6c.051-.091.087-.188.108-.291L6.98 6.2c.06-.294-.016-.6-.206-.832C6.584 5.135 6.3 5 6 5h-.428C2.145 8.277 0 12.884 0 18c0 .266.028.525.04.788l.602.514c.182.156.413.24.65.24zm9.325-16.547c.106.219.313.373.553.412l6.375 1.042c.04.006.081.01.121.01.04 0 .081-.003.122-.01l6.084-1c.2-.033.38-.146.495-.314.116-.168.158-.375.118-.575l-.292-1.443C22.26.407 20.18 0 18 0c-2.425 0-4.734.486-6.845 1.356l-.521.95c-.117.213-.123.47-.017.689zm20.517 2.724l-1.504-.095c-.228-.013-.455.076-.609.249-.152.173-.218.402-.175.63l1.167 6.198c.017.086.048.148.093.224 1.492 2.504 3.152 5.301 3.381 5.782.024.084.062.079.114.151.14.195.372.142.612.142h.007c.198 0 .323.094 1.768-.753.001-.083.012-.164.012-.247 0-4.753-1.856-9.064-4.866-12.281zM14.541 33.376c.011-.199-.058-.395-.191-.544l-4.5-5c-.06-.066-.131-.122-.211-.163-5.885-3.069-5.994-3.105-6.066-3.13-.078-.025-.161-.039-.242-.039-.537 0-.695.065-1.185 2.024 2.236 4.149 6.053 7.316 10.644 8.703l1.5-1.333c.149-.132.239-.319.251-.518zm17.833-8.567c-.189-.08-.405-.078-.592.005l-6.083 2.667c-.106.046-.2.116-.274.205l-4.25 5.083c-.129.154-.19.352-.172.552.02.2.117.384.272.51.683.559 1.261 1.03 1.767 1.44 4.437-1.294 8.154-4.248 10.454-8.146l-.712-1.889c-.072-.193-.221-.347-.41-.427z" fill="#31373D"/></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -134,36 +134,106 @@ Sadece post metnini yaz, başka hiçbir şey ekleme.`;
}
private ensureHashtags(text: string, card: PredictionCardDto): string {
// If no hashtags in text, add them
if (!text.includes("#")) {
const leagueTag = card.leagueName
.replace(/\s+/g, "")
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
const homeTag = card.homeTeam.replace(/\s+/g, "");
const awayTag = card.awayTeam.replace(/\s+/g, "");
const sportTag = card.sport === "basketball" ? "Basketbol" : "Futbol";
text += `\n\n#${leagueTag} #${homeTag} #${awayTag} #${sportTag}`;
// Always strip any LLM-emitted hashtags then append our curated SEO set,
// so we control the canonical tags the post is indexed under.
const stripped = text.replace(/(^|\s)#[^\s#]+/g, "").trim();
const tags = this.buildSeoHashtags(card);
return `${stripped}\n\n${tags}`.trim();
}
return text.trim();
/**
* Build the canonical SEO hashtag set for a prediction card.
* Combines: league, country, teams, sport, betting keywords, day-of-week,
* top-pick market, and evergreen brand tags. Capped at ~12 tags
* (over-tagging gets penalised on X / IG).
*/
private buildSeoHashtags(card: PredictionCardDto): string {
const slug = (s: string) =>
(s || "")
.replace(/[^\p{L}\p{N}]/gu, "")
.replace(/\s+/g, "");
const tags = new Set<string>();
const push = (t: string) => {
if (t && t.length > 1 && t.length <= 30) tags.add(`#${t}`);
};
// Brand / vertical
push("MaçTahmini");
push("İddaa");
push("BugünMaç");
push(card.sport === "basketball" ? "Basketbol" : "Futbol");
push(card.sport === "basketball" ? "BasketTahmin" : "FutbolTahmin");
// Region / league
if (card.countryName) push(slug(card.countryName));
if (card.leagueName) {
push(slug(card.leagueName));
// Common popular shorthands
const ln = card.leagueName.toLowerCase();
if (ln.includes("süper")) push("SüperLig");
if (ln.includes("premier")) push("PremierLeague");
if (ln.includes("şampiyonlar")) push("ŞampiyonlarLigi");
if (ln.includes("euroleague")) push("EuroLeague");
if (ln.includes("nba")) push("NBA");
}
// Teams
push(slug(card.homeTeam));
push(slug(card.awayTeam));
if (card.homeTeam && card.awayTeam) {
push(slug(`${card.homeTeam}${card.awayTeam}`));
}
// Day of week (Turkish)
const dayTags = ["Pazar", "Pazartesi", "Salı", "Çarşamba",
"Perşembe", "Cuma", "Cumartesi"];
push(`${dayTags[new Date().getDay()]}Tahmini`);
// Market-driven tags
const top = card.topPicks?.[0];
if (top) {
const m = (top.marketEn || "").toLowerCase();
if (m.includes("over") || m.includes("under")) push("AltÜst");
if (m.includes("both teams")) push("KGVar");
if (m.includes("double chance")) push("ÇifteŞans");
if (m.includes("match result")) push("MaçSonucu");
if (m.includes("handicap") || m.includes("spread")) push("Handikap");
}
return Array.from(tags).slice(0, 12).join(" ");
}
/**
* Fallback caption when Gemini is not available.
* Designed to be informative + scannable + indexable; expanded vs the
* old one-liner so the post still ranks even without LLM enrichment.
*/
private generateFallbackCaption(card: PredictionCardDto): string {
const topPick = card.topPicks[0];
const leagueTag = card.leagueName
.replace(/\s+/g, "")
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
const sportLabel = card.sport === "basketball" ? "Basketbol" : "Futbol";
const halfLabel = card.sport === "basketball" ? D" : Y";
const halfLabel = card.sport === "basketball" ? lk Devre" : lk Yarı";
const top = card.topPicks || [];
return `${card.leagueName}${card.countryName ? ` (${card.countryName})` : ""}: ${card.homeTeam} vs ${card.awayTeam}
🎯 ${sportLabel} tahminimiz: ${card.ftScore} (${halfLabel}: ${card.htScore})
📊 Güven: %${card.scoreConfidence}
${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ""}
const lines: string[] = [];
const flag = card.countryName ? `🌍 ${card.countryName}` : "🌍";
lines.push(`${flag} | ${card.leagueName}`);
lines.push(` ${card.homeTeam} 🆚 ${card.awayTeam}`);
lines.push(`🗓️ ${card.matchDate}`);
lines.push("");
lines.push(`🎯 ${sportLabel} Skor Tahmini`);
lines.push(`${halfLabel}: ${card.htScore}`);
lines.push(` • Maç Sonu: ${card.ftScore}`);
lines.push(` • Skor Güveni: %${card.scoreConfidence}`);
#${leagueTag} #${sportLabel} #MaçTahmini #iddaai`.trim();
if (top.length) {
lines.push("");
lines.push("🔥 En İyi Tahminler:");
top.slice(0, 3).forEach((p, i) => {
const stars = "⭐".repeat(Math.min(3, Math.max(1, Math.round(p.confidence / 30))));
lines.push(` ${i + 1}. ${p.market} (%${p.confidence}) ${stars}`);
});
}
return lines.join("\n");
}
}
@@ -45,16 +45,85 @@ export class ImageRendererService implements OnModuleInit {
throw new Error("canvas native module is not available");
}
const fileName = `prediction_${card.sport}_${card.matchId}_${Date.now()}.jpg`;
// SEO-friendly filename: "<league>-<home>-vs-<away>-<yyyymmdd>.jpg"
// Search engines index image filenames; matchId is opaque, this isn't.
const fileName = this.buildSeoFilename(card);
const filePath = path.join(this.outputDir, fileName);
this.logger.log(
`Rendering ${card.sport} social card for ${card.homeTeam} vs ${card.awayTeam}`,
);
await this.drawCanvas(card, filePath);
// Sidecar metadata for OpenGraph / SEO consumers (same basename + .json).
try {
this.writeMetadataSidecar(card, filePath);
} catch (e) {
this.logger.warn(`metadata sidecar failed: ${(e as Error).message}`);
}
return filePath;
}
private buildSeoFilename(card: PredictionCardDto): string {
const slug = (s: string) =>
(s || "")
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[ıİ]/g, "i")
.replace(/[şŞ]/g, "s")
.replace(/[çÇ]/g, "c")
.replace(/[ğĞ]/g, "g")
.replace(/[öÖ]/g, "o")
.replace(/[üÜ]/g, "u")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
const league = slug(card.leagueName) || "match";
const home = slug(card.homeTeam) || "home";
const away = slug(card.awayTeam) || "away";
const d = new Date();
const stamp = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
return `${league}-${home}-vs-${away}-${stamp}.jpg`;
}
private writeMetadataSidecar(card: PredictionCardDto, imagePath: string): void {
const meta = {
title: `${card.homeTeam} - ${card.awayTeam} Maç Tahmini | ${card.leagueName}`,
description:
`${card.leagueName} maçında ${card.homeTeam} - ${card.awayTeam} ` +
`karşılaşması için AI destekli maç tahmini. Tahmini skor ${card.ftScore}, ` +
`ilk yarı ${card.htScore}. En iyi 3 bahis önerisi.`,
og: {
type: "article",
title: `${card.homeTeam} vs ${card.awayTeam}${card.leagueName}`,
description: `Skor tahmini: ${card.ftScore} (İlk yarı: ${card.htScore})`,
image_alt: `${card.homeTeam} - ${card.awayTeam} maç tahmin kartı`,
},
structured_data: {
"@context": "https://schema.org",
"@type": "SportsEvent",
name: `${card.homeTeam} vs ${card.awayTeam}`,
sport: card.sport === "basketball" ? "Basketball" : "Football",
homeTeam: { "@type": "SportsTeam", name: card.homeTeam },
awayTeam: { "@type": "SportsTeam", name: card.awayTeam },
location: card.countryName || undefined,
organizer: card.leagueName,
startDate: card.matchDate,
},
picks: card.topPicks.map((p) => ({
market: p.market,
pick: p.pick,
confidence: p.confidence,
odds: p.odds,
})),
generated_at: new Date().toISOString(),
};
const sidecar = imagePath.replace(/\.jpg$/i, ".json");
fs.writeFileSync(sidecar, JSON.stringify(meta, null, 2), "utf-8");
}
private async drawCanvas(
data: PredictionCardDto,
outPath: string,
@@ -1,25 +1,81 @@
import { Controller, Post, Param, Get, UseGuards } from "@nestjs/common";
import {
Controller,
Post,
Param,
Get,
UseGuards,
Res,
NotFoundException,
} from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import * as fs from "fs";
import { SocialPosterService } from "./social-poster.service";
import { Roles } from "../../common/decorators";
import { RolesGuard } from "../auth/guards/auth.guards";
@ApiTags("Social Poster")
@ApiBearerAuth()
@UseGuards(RolesGuard)
@Roles("superadmin")
@Controller("social-poster")
export class SocialPosterController {
constructor(private readonly socialPosterService: SocialPosterService) {}
/** Public health endpoint — config + last-run snapshot. Safe to expose. */
@Get("health")
health() {
return this.socialPosterService.getHealthStatus();
}
@ApiBearerAuth()
@UseGuards(RolesGuard)
@Roles("superadmin")
@Get("preview/:matchId")
async previewCard(@Param("matchId") matchId: string) {
return this.socialPosterService.renderPreview(matchId);
}
/**
* Render the prediction card for a match and stream the raw JPEG back
* the fastest way to QA the visual without opening the JSON or a viewer.
*/
@ApiBearerAuth()
@UseGuards(RolesGuard)
@Roles("superadmin")
@Get("preview-png/:matchId")
async previewPng(
@Param("matchId") matchId: string,
@Res() res: Response,
): Promise<void> {
const { imagePath } = await this.socialPosterService.renderPreview(matchId);
if (!fs.existsSync(imagePath)) {
throw new NotFoundException("Image not rendered");
}
res.setHeader("Content-Type", "image/jpeg");
res.setHeader(
"Cache-Control",
"public, max-age=300, stale-while-revalidate=60",
);
fs.createReadStream(imagePath).pipe(res);
}
@ApiBearerAuth()
@UseGuards(RolesGuard)
@Roles("superadmin")
@Post("post/:matchId")
async postMatch(@Param("matchId") matchId: string) {
return this.socialPosterService.manualPost(matchId);
}
/**
* Force a cron-style sweep right now useful right after pushing config
* changes, instead of waiting up to 10 minutes for the next scheduled run.
*/
@ApiBearerAuth()
@UseGuards(RolesGuard)
@Roles("superadmin")
@Post("run-now")
async runNow() {
await this.socialPosterService.checkAndPostUpcomingMatches();
return this.socialPosterService.getHealthStatus();
}
}
@@ -34,8 +34,15 @@ export class SocialPosterService {
private readonly sports: string[];
private readonly windowMinMinutes: number;
private readonly windowMaxMinutes: number;
private readonly maxPostsPerRun: number;
private readonly postedMatchIds = new Set<string>();
private topLeagueIds: Set<string> = new Set();
private lastRunAt: Date | null = null;
private lastRunResult: { posted: number; skipped: number; errors: number } = {
posted: 0,
skipped: 0,
errors: 0,
};
constructor(
private readonly prisma: PrismaService,
@@ -59,11 +66,18 @@ export class SocialPosterService {
.split(",")
.map((sport) => sport.trim())
.filter(Boolean);
// Default expanded to 10-60 min so each 10-minute cron run has a fresh
// batch of upcoming matches without overlap (each match falls in one
// ~10-min slice of the window). Override via env if you want tighter
// "just before kickoff" timing.
this.windowMinMinutes = Number(
this.configService.get<string>("SOCIAL_POSTER_WINDOW_MIN") || 25,
this.configService.get<string>("SOCIAL_POSTER_WINDOW_MIN") || 10,
);
this.windowMaxMinutes = Number(
this.configService.get<string>("SOCIAL_POSTER_WINDOW_MAX") || 45,
this.configService.get<string>("SOCIAL_POSTER_WINDOW_MAX") || 60,
);
this.maxPostsPerRun = Number(
this.configService.get<string>("SOCIAL_POSTER_MAX_PER_RUN") || 5,
);
this.loadTopLeagues();
@@ -108,12 +122,15 @@ export class SocialPosterService {
}
/**
* Cron: Every 15 minutes, check for upcoming matches.
* Posts predictions 30 minutes before kickoff.
* Cron: Every 10 minutes, check for upcoming matches and post prediction
* cards. Window is 10-60 min before kickoff; max posts/run is capped so
* one cron pass cannot flood Twitter / IG rate limits.
*/
@Cron("*/15 * * * *")
@Cron("*/10 * * * *")
async checkAndPostUpcomingMatches() {
if (!this.isEnabled) return;
this.lastRunAt = new Date();
this.lastRunResult = { posted: 0, skipped: 0, errors: 0 };
try {
const matches = await this.getUpcomingMatches(
@@ -121,11 +138,22 @@ export class SocialPosterService {
this.windowMaxMinutes,
);
this.logger.log(
`📅 Found ${matches.length} upcoming matches in the window`,
`📅 Found ${matches.length} upcoming matches in window ` +
`[${this.windowMinMinutes}-${this.windowMaxMinutes} min]`,
);
let postedThisRun = 0;
for (const match of matches) {
if (this.postedMatchIds.has(match.id)) continue;
if (postedThisRun >= this.maxPostsPerRun) {
this.logger.log(
`Hit max-per-run cap (${this.maxPostsPerRun}); deferring rest`,
);
break;
}
if (this.postedMatchIds.has(match.id)) {
this.lastRunResult.skipped++;
continue;
}
try {
const result = await this.predictAndPost(match);
@@ -136,12 +164,15 @@ export class SocialPosterService {
if (!posted) {
this.logger.warn(
`No platform accepted post for match ${match.id}; it will be retried later`,
`No platform accepted post for match ${match.id}; will retry later`,
);
this.lastRunResult.errors++;
continue;
}
this.postedMatchIds.add(match.id);
this.lastRunResult.posted++;
postedThisRun++;
// Cleanup: remove old IDs (keep last 500)
if (this.postedMatchIds.size > 500) {
@@ -155,6 +186,7 @@ export class SocialPosterService {
this.logger.error(
`Failed to process match ${match.id}: ${error.message}`,
);
this.lastRunResult.errors++;
}
// Small delay between posts to avoid rate limits
@@ -162,9 +194,32 @@ export class SocialPosterService {
}
} catch (error) {
this.logger.error(`Cron job failed: ${error.message}`);
this.lastRunResult.errors++;
}
}
/**
* Snapshot of the poster's runtime state for the /health endpoint.
*/
getHealthStatus() {
return {
enabled: this.isEnabled,
sports: this.sports,
window_min_minutes: this.windowMinMinutes,
window_max_minutes: this.windowMaxMinutes,
max_posts_per_run: this.maxPostsPerRun,
top_leagues_loaded: this.topLeagueIds.size,
posted_match_count: this.postedMatchIds.size,
last_run_at: this.lastRunAt?.toISOString() || null,
last_run_result: this.lastRunResult,
twitter_available: this.twitterService.available,
meta_facebook_available: this.metaService.facebookAvailable,
meta_instagram_available: this.metaService.instagramAvailable,
ai_engine_url: this.aiEngineUrl,
app_base_url: this.appBaseUrl,
};
}
/**
* Get matches starting in [minMinutes, maxMinutes] from now.
* Filtered by top leagues.
+119
View File
@@ -0,0 +1,119 @@
/**
* Standalone renderer for social-poster image preview.
* Bypasses NestJS directly instantiates ImageRendererService with a
* sample PredictionCardDto so we can SEE what the output looks like
* without standing up the full app.
*
* Usage:
* npx ts-node --transpile-only -r tsconfig-paths/register \
* src/scripts/render-social-card-sample.ts
*/
import { ImageRendererService } from "../modules/social-poster/image-renderer.service";
import { PredictionCardDto } from "../modules/social-poster/dto/prediction-card.dto";
async function main() {
const renderer = new ImageRendererService();
renderer.onModuleInit();
// Sample 1: Basketball — like the user's Unicaja vs AEK reference image
const basketCard: PredictionCardDto = {
matchId: "sample-basket-001",
sport: "basketball",
homeTeam: "Unicaja Malaga",
awayTeam: "AEK",
homeLogo: "",
awayLogo: "",
leagueName: "Şampiyonlar Ligi",
leagueLogo: "",
countryName: "Avrupa",
countryFlag: "",
matchDate: "25 May 2026 - 21:00",
htScore: "40-35",
ftScore: "88-75",
scoreConfidence: 90,
topPicks: [
{
market: "Toplam Sayı: Üst 160.5",
marketEn: "Total Points: Over 160.5",
pick: "Üst",
confidence: 85,
odds: 1.85,
},
{
market: "Ev Sahibi Toplam: Üst 82.5",
marketEn: "Home Total: Over 82.5",
pick: "Üst",
confidence: 83,
odds: 1.78,
},
{
market: "Handikap: Ev Sahibi -5.5",
marketEn: "Handicap: Home -5.5",
pick: "1 (-5.5)",
confidence: 78,
odds: 1.92,
},
],
riskLevel: "LOW",
};
// Sample 2: Football — Süper Lig
const footballCard: PredictionCardDto = {
matchId: "sample-foot-001",
sport: "football",
homeTeam: "Galatasaray",
awayTeam: "Fenerbahçe",
homeLogo: "",
awayLogo: "",
leagueName: "Süper Lig",
leagueLogo: "",
countryName: "Türkiye",
countryFlag: "",
matchDate: "26 May 2026 - 20:00",
htScore: "1-0",
ftScore: "2-1",
scoreConfidence: 72,
topPicks: [
{
market: "Maç Sonucu",
marketEn: "Match Result",
pick: "1",
confidence: 68,
odds: 2.15,
},
{
market: "Üst 2.5 Gol",
marketEn: "Over 2.5",
pick: "Üst",
confidence: 61,
odds: 1.92,
},
{
market: "Karşılıklı Gol",
marketEn: "Both Teams Score",
pick: "Var",
confidence: 58,
odds: 1.75,
},
],
riskLevel: "MEDIUM",
};
console.log("Rendering basketball card...");
const basketPath = await renderer.renderCard(basketCard);
console.log(`${basketPath}`);
console.log("Rendering football card...");
const footballPath = await renderer.renderCard(footballCard);
console.log(`${footballPath}`);
console.log("\nDone. Open the JPGs to see the output:");
console.log(` ${basketPath}`);
console.log(` ${footballPath}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+244
View File
@@ -0,0 +1,244 @@
/**
* V2 social card renderer satori (HTML/CSS SVG) resvg (SVG PNG).
* Modern editorial design. Uses React.createElement (h) form for satori's
* strict layout requirements (every multi-child node needs explicit display).
*/
import satori from "satori";
import { Resvg } from "@resvg/resvg-js";
import * as fs from "fs";
import * as path from "path";
const OUTPUT_DIR = path.join(process.cwd(), "public", "predictions");
const ARIAL = fs.readFileSync("C:/Windows/Fonts/arial.ttf");
const ARIAL_BOLD = fs.readFileSync("C:/Windows/Fonts/arialbd.ttf");
const ARIAL_BLACK = fs.existsSync("C:/Windows/Fonts/ariblk.ttf")
? fs.readFileSync("C:/Windows/Fonts/ariblk.ttf")
: ARIAL_BOLD;
interface Pick { market: string; pick: string; confidence: number; odds: number; }
interface Card {
sport: "football" | "basketball";
leagueName: string;
countryName: string;
matchDate: string;
homeTeam: string;
awayTeam: string;
htScore: string;
ftScore: string;
scoreConfidence: number;
picks: Pick[];
}
// satori expects React-element shape: { type, props: { children, style, ... } }
type N = string | { type: string; props: { style?: any; children?: any } };
const h = (type: string, style: any, ...children: N[]): N => ({
type,
props: { style, children: children.length === 1 ? children[0] : children },
});
function buildCard(c: Card): N {
const isBasket = c.sport === "basketball";
const accent = isBasket ? "#F59E0B" : "#22C55E";
const accentDark = isBasket ? "#92400E" : "#14532D";
const away = "#3B82F6";
const awayDark = "#1E3A8A";
const sportLabel = isBasket ? "BASKETBOL" : "FUTBOL";
const halfLabel = isBasket ? "İLK DEVRE" : "İLK YARI";
const avatar = (name: string, c1: string, c2: string) =>
h("div", {
display: "flex", alignItems: "center", justifyContent: "center",
width: 200, height: 200, borderRadius: 200,
background: `linear-gradient(135deg, ${c1} 0%, ${c2} 100%)`,
fontSize: 120, fontWeight: 900, color: "white",
boxShadow: "0 25px 60px rgba(0,0,0,0.55), inset 0 -8px 0 rgba(0,0,0,0.22)",
}, (name.match(/\p{L}/u)?.[0] || "?").toUpperCase());
const teamColumn = (name: string, c1: string, c2: string) =>
h("div", {
display: "flex", flexDirection: "column", alignItems: "center", width: 300,
},
avatar(name, c1, c2),
h("div", {
display: "flex", marginTop: 22, fontSize: 32, fontWeight: 900,
color: "white", textAlign: "center", lineHeight: 1.05,
maxWidth: 300, justifyContent: "center",
}, name),
);
const scoreBlock = h("div", {
display: "flex", flexDirection: "column", alignItems: "center",
},
h("div", {
display: "flex", fontSize: 14, color: "rgba(255,255,255,0.4)",
letterSpacing: 4, fontWeight: 700, marginBottom: 8,
}, "MAÇ SONU"),
h("div", {
display: "flex", fontSize: 110, fontWeight: 900, color: "white",
lineHeight: 1, letterSpacing: -4,
}, c.ftScore),
h("div", {
display: "flex", marginTop: 16, fontSize: 18, color: accent,
fontWeight: 800, background: `${accent}22`, padding: "8px 16px",
borderRadius: 10, letterSpacing: 1,
}, `${halfLabel} ${c.htScore}`),
);
const pickRow = (p: Pick, idx: number) =>
h("div", {
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "20px 26px", marginBottom: 12,
background: "rgba(255,255,255,0.045)",
borderLeft: `5px solid ${accent}`,
borderRadius: 12,
},
h("div", { display: "flex", alignItems: "center", flexGrow: 1 },
h("div", {
display: "flex", justifyContent: "center", alignItems: "center",
width: 44, height: 44, marginRight: 18, borderRadius: 8,
background: `${accent}22`, color: accent, fontSize: 24, fontWeight: 900,
}, String(idx + 1)),
h("div", { display: "flex", flexDirection: "column" },
h("div", { display: "flex", fontSize: 28, fontWeight: 800, color: "white", lineHeight: 1.1 }, p.market),
h("div", { display: "flex", marginTop: 4, fontSize: 18, color: "rgba(255,255,255,0.5)" }, `Oran ${p.odds.toFixed(2)}`),
),
),
h("div", {
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 38, fontWeight: 900, color: accent,
background: `${accent}15`,
padding: "6px 18px", borderRadius: 10,
border: `1px solid ${accent}`,
}, `%${p.confidence}`),
);
return h("div", {
width: 1080, height: 1080,
display: "flex", flexDirection: "column",
padding: "50px 60px",
background: "linear-gradient(180deg, #0F172A 0%, #020617 100%)",
color: "white",
fontFamily: "Arial",
},
// HEADER
h("div", {
display: "flex", alignItems: "center", justifyContent: "space-between",
marginBottom: 36,
},
h("div", { display: "flex", alignItems: "center" },
h("div", {
display: "flex", width: 8, height: 44, background: accent,
marginRight: 16, borderRadius: 4,
}),
h("div", { display: "flex", flexDirection: "column" },
h("div", {
display: "flex", fontSize: 14, color: "rgba(255,255,255,0.5)",
letterSpacing: 3, fontWeight: 700,
}, `${sportLabel} · ${c.countryName.toUpperCase()}`),
h("div", {
display: "flex", marginTop: 2, fontSize: 26, fontWeight: 900, color: "white",
}, c.leagueName),
),
),
h("div", {
display: "flex", fontSize: 16, color: "rgba(255,255,255,0.6)",
background: "rgba(255,255,255,0.05)",
padding: "10px 16px", borderRadius: 10,
border: "1px solid rgba(255,255,255,0.1)", fontWeight: 600,
}, c.matchDate),
),
// TEAMS + SCORE
h("div", {
display: "flex", alignItems: "center", justifyContent: "space-between",
marginBottom: 30, paddingLeft: 10, paddingRight: 10,
},
teamColumn(c.homeTeam, accent, accentDark),
scoreBlock,
teamColumn(c.awayTeam, away, awayDark),
),
// PICKS TITLE
h("div", {
display: "flex", alignItems: "center", justifyContent: "space-between",
marginBottom: 16, paddingLeft: 6, paddingRight: 6,
},
h("div", { display: "flex", fontSize: 22, fontWeight: 900, color: "white", letterSpacing: 2 }, "EN İYİ 3 TAHMİN"),
h("div", { display: "flex", alignItems: "center" },
h("div", { display: "flex", fontSize: 14, color: "rgba(255,255,255,0.55)", fontWeight: 700, marginRight: 8 }, "SKOR GÜVENİ"),
h("div", { display: "flex", fontSize: 22, fontWeight: 900, color: accent }, `%${c.scoreConfidence}`),
),
),
// PICKS LIST
h("div", { display: "flex", flexDirection: "column" },
...c.picks.slice(0, 3).map((p, i) => pickRow(p, i)),
),
// FOOTER
h("div", {
display: "flex", marginTop: "auto", alignItems: "center", justifyContent: "space-between",
paddingTop: 22, borderTop: "1px solid rgba(255,255,255,0.08)",
},
h("div", { display: "flex", fontSize: 16, color: "rgba(255,255,255,0.45)", fontWeight: 600 }, "AI Destekli Maç Tahmini · iddaai"),
h("div", { display: "flex", fontSize: 20, color: "white", fontWeight: 900, letterSpacing: 2 }, "iddaai.com"),
),
);
}
async function renderCard(c: Card, name: string) {
const svg = await satori(buildCard(c) as any, {
width: 1080,
height: 1080,
fonts: [
{ name: "Arial", data: ARIAL, weight: 400, style: "normal" },
{ name: "Arial", data: ARIAL_BOLD, weight: 700, style: "normal" },
{ name: "Arial", data: ARIAL_BLACK, weight: 900, style: "normal" },
],
});
const png = new Resvg(svg).render().asPng();
const out = path.join(OUTPUT_DIR, `v2-${name}.png`);
fs.writeFileSync(out, png);
console.log(`${out}`);
}
async function main() {
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
await renderCard({
sport: "basketball",
leagueName: "Şampiyonlar Ligi",
countryName: "Avrupa",
matchDate: "25 May 2026, 21:00",
homeTeam: "Unicaja Malaga",
awayTeam: "AEK",
htScore: "40-35",
ftScore: "88-75",
scoreConfidence: 90,
picks: [
{ market: "Toplam Sayı Üst 160.5", pick: "Üst", confidence: 85, odds: 1.85 },
{ market: "Ev Sahibi Toplam Üst 82.5", pick: "Üst", confidence: 83, odds: 1.78 },
{ market: "Handikap Ev Sahibi -5.5", pick: "1", confidence: 78, odds: 1.92 },
],
}, "basket-unicaja-aek");
await renderCard({
sport: "football",
leagueName: "Süper Lig",
countryName: "Türkiye",
matchDate: "26 May 2026, 20:00",
homeTeam: "Galatasaray",
awayTeam: "Fenerbahçe",
htScore: "1-0",
ftScore: "2-1",
scoreConfidence: 72,
picks: [
{ market: "Maç Sonucu - Ev Sahibi", pick: "1", confidence: 68, odds: 2.15 },
{ market: "Üst 2.5 Gol", pick: "Üst", confidence: 61, odds: 1.92 },
{ market: "Karşılıklı Gol", pick: "Var", confidence: 58, odds: 1.75 },
],
}, "football-gs-fb");
}
main().catch((e) => { console.error(e); process.exit(1); });
+331
View File
@@ -0,0 +1,331 @@
/**
* V3 social card sport icon + league badge + country flag.
* satori (HTML/CSS SVG) resvg (SVG PNG).
*/
import satori from "satori";
import { Resvg } from "@resvg/resvg-js";
import * as fs from "fs";
import * as path from "path";
const OUTPUT_DIR = path.join(process.cwd(), "public", "predictions");
const ASSETS_DIR = path.join(process.cwd(), "src", "modules", "social-poster", "assets");
const ARIAL = fs.readFileSync("C:/Windows/Fonts/arial.ttf");
const ARIAL_BOLD = fs.readFileSync("C:/Windows/Fonts/arialbd.ttf");
const ARIAL_BLACK = fs.existsSync("C:/Windows/Fonts/ariblk.ttf")
? fs.readFileSync("C:/Windows/Fonts/ariblk.ttf")
: ARIAL_BOLD;
function loadAsset(name: string): string {
const p = path.join(ASSETS_DIR, name);
if (!fs.existsSync(p)) return "";
const svg = fs.readFileSync(p, "utf-8");
return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`;
}
interface Pick { market: string; pick: string; confidence: number; odds: number; }
interface Card {
sport: "football" | "basketball";
leagueName: string;
leagueCode?: string; // e.g. "UCL", "SL"
countryName: string;
countryCode?: string; // "TR" | "ES" | "EU" | ...
matchDate: string;
homeTeam: string;
awayTeam: string;
htScore: string;
ftScore: string;
scoreConfidence: number;
picks: Pick[];
}
type N = string | { type: string; props: { style?: any; children?: any; src?: string; width?: number; height?: number } };
const h = (type: string, style: any, ...children: N[]): N => ({
type,
props: { style, children: children.length === 1 ? children[0] : children },
});
// ── INLINE SVG GENERATORS (no network, embedded as data URIs) ────────────
function dataUri(svg: string): string {
return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`;
}
function ballSvg(sport: "football" | "basketball"): string {
// Use Twemoji (Twitter's MIT-licensed emoji set) — recognized everywhere.
return loadAsset(sport === "basketball" ? "basketball.svg" : "football.svg");
}
const FLAG_MAP: Record<string, string> = {
TR: "flag-tr.svg",
EU: "flag-eu.svg",
GB: "flag-gb.svg", UK: "flag-gb.svg", EN: "flag-gb.svg",
ES: "flag-es.svg",
IT: "flag-it.svg",
DE: "flag-de.svg",
FR: "flag-fr.svg",
US: "flag-us.svg",
};
function flagSvg(code: string): string {
const c = (code || "").toUpperCase();
const file = FLAG_MAP[c];
if (!file) {
// Generic dark badge with country code
const fallback = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 40">
<rect width="60" height="40" rx="4" fill="#374151"/>
<text x="30" y="26" font-size="14" font-weight="bold" font-family="Arial" fill="white" text-anchor="middle">${c}</text>
</svg>`;
return dataUri(fallback);
}
return loadAsset(file);
}
function leagueBadgeSvg(code: string, color: string, size = 70): string {
// Shield/circle with league initials
return dataUri(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="${size}" height="${size}">
<defs>
<radialGradient id="g" cx="35%" cy="30%">
<stop offset="0%" stop-color="${color}"/>
<stop offset="100%" stop-color="#1F2937"/>
</radialGradient>
</defs>
<path d="M 40 4 L 72 14 L 72 42 Q 72 64 40 76 Q 8 64 8 42 L 8 14 Z"
fill="url(#g)" stroke="${color}" stroke-width="2.5"/>
<text x="40" y="52" font-size="${code.length > 3 ? 18 : 26}" font-weight="900"
font-family="Arial" fill="white" text-anchor="middle">${code.slice(0, 4)}</text>
</svg>`);
}
// ── CARD ─────────────────────────────────────────────────────────────────
function buildCard(c: Card): N {
const isBasket = c.sport === "basketball";
const accent = isBasket ? "#F59E0B" : "#22C55E";
const accentDark = isBasket ? "#92400E" : "#14532D";
const away = "#3B82F6";
const awayDark = "#1E3A8A";
const sportLabel = isBasket ? "BASKETBOL TAHMİNİ" : "FUTBOL TAHMİNİ";
const halfLabel = isBasket ? "İLK DEVRE" : "İLK YARI";
const leagueCode = (c.leagueCode || c.leagueName.split(" ").map(w => w[0]).join("")).toUpperCase();
const countryCode = (c.countryCode || "EU").toUpperCase();
const avatar = (name: string, c1: string, c2: string) =>
h("div", {
display: "flex", alignItems: "center", justifyContent: "center",
width: 180, height: 180, borderRadius: 180,
background: `linear-gradient(135deg, ${c1} 0%, ${c2} 100%)`,
fontSize: 110, fontWeight: 900, color: "white",
boxShadow: "0 20px 50px rgba(0,0,0,0.55), inset 0 -6px 0 rgba(0,0,0,0.22)",
}, (name.match(/\p{L}/u)?.[0] || "?").toUpperCase());
const teamColumn = (name: string, c1: string, c2: string) =>
h("div", {
display: "flex", flexDirection: "column", alignItems: "center", width: 280,
},
avatar(name, c1, c2),
h("div", {
display: "flex", marginTop: 18, fontSize: 28, fontWeight: 900,
color: "white", textAlign: "center", lineHeight: 1.05,
maxWidth: 280, justifyContent: "center",
}, name),
);
const scoreBlock = h("div", {
display: "flex", flexDirection: "column", alignItems: "center",
},
// Sport icon (BIG, this is the visual distinction)
h("div", { display: "flex", marginBottom: 8 },
h("img", { width: 110, height: 110 } as any, ...[]) as any,
),
h("div", {
display: "flex", fontSize: 12, color: "rgba(255,255,255,0.4)",
letterSpacing: 4, fontWeight: 700, marginBottom: 6,
}, "MAÇ SONU"),
h("div", {
display: "flex", fontSize: 92, fontWeight: 900, color: "white",
lineHeight: 1, letterSpacing: -3,
}, c.ftScore),
h("div", {
display: "flex", marginTop: 12, fontSize: 16, color: accent,
fontWeight: 800, background: `${accent}22`, padding: "6px 14px",
borderRadius: 10, letterSpacing: 1,
}, `${halfLabel} ${c.htScore}`),
);
// Replace the placeholder img with actual ball image
((scoreBlock as any).props.children[0] as any).props.children = {
type: "img",
props: { src: ballSvg(c.sport), width: 110, height: 110, style: { width: 110, height: 110 } },
};
const pickRow = (p: Pick, idx: number) =>
h("div", {
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "18px 24px", marginBottom: 11,
background: "rgba(255,255,255,0.045)",
borderLeft: `5px solid ${accent}`,
borderRadius: 12,
},
h("div", { display: "flex", alignItems: "center", flexGrow: 1 },
h("div", {
display: "flex", justifyContent: "center", alignItems: "center",
width: 40, height: 40, marginRight: 16, borderRadius: 8,
background: `${accent}22`, color: accent, fontSize: 22, fontWeight: 900,
}, String(idx + 1)),
h("div", { display: "flex", flexDirection: "column" },
h("div", { display: "flex", fontSize: 26, fontWeight: 800, color: "white", lineHeight: 1.1 }, p.market),
h("div", { display: "flex", marginTop: 4, fontSize: 16, color: "rgba(255,255,255,0.5)" }, `Oran ${p.odds.toFixed(2)}`),
),
),
h("div", {
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 34, fontWeight: 900, color: accent,
background: `${accent}15`,
padding: "6px 16px", borderRadius: 10,
border: `1px solid ${accent}`,
}, `%${p.confidence}`),
);
// Helper for inline <img>
const img = (src: string, w: number, hh: number) => ({
type: "img",
props: { src, width: w, height: hh, style: { width: w, height: hh } },
} as any);
return h("div", {
width: 1080, height: 1080,
display: "flex", flexDirection: "column",
padding: "44px 56px",
background: "linear-gradient(180deg, #0F172A 0%, #020617 100%)",
color: "white",
fontFamily: "Arial",
},
// ── HEADER: league badge + flag + name + date ──
h("div", {
display: "flex", alignItems: "center", justifyContent: "space-between",
marginBottom: 28,
},
h("div", { display: "flex", alignItems: "center" },
img(leagueBadgeSvg(leagueCode, accent), 64, 64),
h("div", { display: "flex", flexDirection: "column", marginLeft: 16 },
h("div", { display: "flex", alignItems: "center" },
img(flagSvg(countryCode), 30, 20),
h("div", {
display: "flex", marginLeft: 10, fontSize: 14, color: "rgba(255,255,255,0.55)",
letterSpacing: 2, fontWeight: 700,
}, `${sportLabel} · ${c.countryName.toUpperCase()}`),
),
h("div", {
display: "flex", marginTop: 4, fontSize: 28, fontWeight: 900, color: "white",
}, c.leagueName),
),
),
h("div", {
display: "flex", fontSize: 16, color: "rgba(255,255,255,0.6)",
background: "rgba(255,255,255,0.05)",
padding: "10px 16px", borderRadius: 10,
border: "1px solid rgba(255,255,255,0.1)", fontWeight: 600,
}, c.matchDate),
),
// ── TEAMS + SCORE ──
h("div", {
display: "flex", alignItems: "center", justifyContent: "space-between",
marginBottom: 26, paddingLeft: 6, paddingRight: 6,
},
teamColumn(c.homeTeam, accent, accentDark),
scoreBlock,
teamColumn(c.awayTeam, away, awayDark),
),
// ── PICKS TITLE ──
h("div", {
display: "flex", alignItems: "center", justifyContent: "space-between",
marginBottom: 14, paddingLeft: 6, paddingRight: 6,
},
h("div", { display: "flex", fontSize: 22, fontWeight: 900, color: "white", letterSpacing: 2 }, "EN İYİ 3 TAHMİN"),
h("div", { display: "flex", alignItems: "center" },
h("div", { display: "flex", fontSize: 13, color: "rgba(255,255,255,0.55)", fontWeight: 700, marginRight: 8 }, "SKOR GÜVENİ"),
h("div", { display: "flex", fontSize: 22, fontWeight: 900, color: accent }, `%${c.scoreConfidence}`),
),
),
// ── PICKS LIST ──
h("div", { display: "flex", flexDirection: "column" },
...c.picks.slice(0, 3).map((p, i) => pickRow(p, i)),
),
// ── FOOTER ──
h("div", {
display: "flex", marginTop: "auto", alignItems: "center", justifyContent: "space-between",
paddingTop: 20, borderTop: "1px solid rgba(255,255,255,0.08)",
},
h("div", { display: "flex", alignItems: "center" },
img(ballSvg(c.sport), 28, 28),
h("div", { display: "flex", marginLeft: 10, fontSize: 15, color: "rgba(255,255,255,0.5)", fontWeight: 600 }, "AI Destekli Maç Tahmini"),
),
h("div", { display: "flex", fontSize: 20, color: "white", fontWeight: 900, letterSpacing: 2 }, "iddaai.com"),
),
);
}
async function renderCard(c: Card, name: string) {
const svg = await satori(buildCard(c) as any, {
width: 1080,
height: 1080,
fonts: [
{ name: "Arial", data: ARIAL, weight: 400, style: "normal" },
{ name: "Arial", data: ARIAL_BOLD, weight: 700, style: "normal" },
{ name: "Arial", data: ARIAL_BLACK, weight: 900, style: "normal" },
],
});
const png = new Resvg(svg).render().asPng();
const out = path.join(OUTPUT_DIR, `v3-${name}.png`);
fs.writeFileSync(out, png);
console.log(`${out}`);
}
async function main() {
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
await renderCard({
sport: "basketball",
leagueName: "Şampiyonlar Ligi",
leagueCode: "UCL",
countryName: "Avrupa",
countryCode: "EU",
matchDate: "25 May 2026, 21:00",
homeTeam: "Unicaja Malaga",
awayTeam: "AEK",
htScore: "40-35",
ftScore: "88-75",
scoreConfidence: 90,
picks: [
{ market: "Toplam Sayı Üst 160.5", pick: "Üst", confidence: 85, odds: 1.85 },
{ market: "Ev Sahibi Toplam Üst 82.5", pick: "Üst", confidence: 83, odds: 1.78 },
{ market: "Handikap Ev Sahibi -5.5", pick: "1", confidence: 78, odds: 1.92 },
],
}, "basket-unicaja-aek");
await renderCard({
sport: "football",
leagueName: "Süper Lig",
leagueCode: "SL",
countryName: "Türkiye",
countryCode: "TR",
matchDate: "26 May 2026, 20:00",
homeTeam: "Galatasaray",
awayTeam: "Fenerbahçe",
htScore: "1-0",
ftScore: "2-1",
scoreConfidence: 72,
picks: [
{ market: "Maç Sonucu - Ev Sahibi", pick: "1", confidence: 68, odds: 2.15 },
{ market: "Üst 2.5 Gol", pick: "Üst", confidence: 61, odds: 1.92 },
{ market: "Karşılıklı Gol", pick: "Var", confidence: 58, odds: 1.75 },
],
}, "football-gs-fb");
}
main().catch((e) => { console.error(e); process.exit(1); });