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>
@@ -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
|
||||||
|
@@ -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)
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -39,6 +39,31 @@ class BettingBrain:
|
|||||||
|
|
||||||
SNIPER_BLOCKED_MARKETS = {"HT", "HTFT", "OE", "CARDS", "HT_OU05", "HT_OU15"}
|
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 = {
|
MARKET_PRIORS = {
|
||||||
"DC": 4.0,
|
"DC": 4.0,
|
||||||
"OU15": 3.0,
|
"OU15": 3.0,
|
||||||
@@ -86,6 +111,36 @@ class BettingBrain:
|
|||||||
watchlist.sort(key=self._candidate_sort_key, reverse=True)
|
watchlist.sort(key=self._candidate_sort_key, reverse=True)
|
||||||
no_value.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 {}
|
original_main = guarded.get("main_pick") or {}
|
||||||
main_pick = None
|
main_pick = None
|
||||||
decision = "NO_BET"
|
decision = "NO_BET"
|
||||||
@@ -142,10 +197,11 @@ class BettingBrain:
|
|||||||
|
|
||||||
rejected = [d for d in decisions if d.get("action") == "REJECT"]
|
rejected = [d for d in decisions if d.get("action") == "REJECT"]
|
||||||
guarded["betting_brain"] = {
|
guarded["betting_brain"] = {
|
||||||
"version": "judge-v1",
|
"version": "judge-v2-score-coherent",
|
||||||
"decision": decision,
|
"decision": decision,
|
||||||
"reason": decision_reason,
|
"reason": decision_reason,
|
||||||
"main_pick_key": main_key or None,
|
"main_pick_key": main_key or None,
|
||||||
|
"score_coherent_filter_applied": coherent_flag,
|
||||||
"approved_count": len(approved),
|
"approved_count": len(approved),
|
||||||
"watchlist_count": len(watchlist),
|
"watchlist_count": len(watchlist),
|
||||||
"rejected_count": len(rejected),
|
"rejected_count": len(rejected),
|
||||||
@@ -243,6 +299,44 @@ class BettingBrain:
|
|||||||
if play_score < 50.0 and not is_value_sniper:
|
if play_score < 50.0 and not is_value_sniper:
|
||||||
vetoes.append("play_score_too_low")
|
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 is not None:
|
||||||
if divergence >= self.HARD_DIVERGENCE and not is_value_sniper:
|
if divergence >= self.HARD_DIVERGENCE and not is_value_sniper:
|
||||||
score -= 42.0
|
score -= 42.0
|
||||||
@@ -635,6 +729,112 @@ class BettingBrain:
|
|||||||
return self._safe_float(ou25.get(key)) if key else None
|
return self._safe_float(ou25.get(key)) if key else None
|
||||||
return 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]]:
|
def _triple_value(self, package: Dict[str, Any], key: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
if not key:
|
if not key:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -449,6 +449,12 @@ class DataLoaderMixin:
|
|||||||
return 1.5, 1.2
|
return 1.5, 1.2
|
||||||
return weighted_for / total_weight, weighted_against / total_weight
|
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(
|
def _estimate_league_position(
|
||||||
self,
|
self,
|
||||||
cur: RealDictCursor,
|
cur: RealDictCursor,
|
||||||
@@ -458,6 +464,7 @@ class DataLoaderMixin:
|
|||||||
) -> int:
|
) -> int:
|
||||||
if not team_id or not league_id:
|
if not team_id or not league_id:
|
||||||
return 10
|
return 10
|
||||||
|
season_start_ms = before_date_ms - self._SEASON_LOOKBACK_MS
|
||||||
try:
|
try:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
@@ -478,6 +485,7 @@ class DataLoaderMixin:
|
|||||||
AND m.score_home IS NOT NULL
|
AND m.score_home IS NOT NULL
|
||||||
AND m.score_away IS NOT NULL
|
AND m.score_away IS NOT NULL
|
||||||
AND m.mst_utc < %s
|
AND m.mst_utc < %s
|
||||||
|
AND m.mst_utc >= %s
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT
|
SELECT
|
||||||
m.away_team_id AS team_id,
|
m.away_team_id AS team_id,
|
||||||
@@ -492,11 +500,15 @@ class DataLoaderMixin:
|
|||||||
AND m.score_home IS NOT NULL
|
AND m.score_home IS NOT NULL
|
||||||
AND m.score_away IS NOT NULL
|
AND m.score_away IS NOT NULL
|
||||||
AND m.mst_utc < %s
|
AND m.mst_utc < %s
|
||||||
|
AND m.mst_utc >= %s
|
||||||
) tm
|
) tm
|
||||||
GROUP BY tm.team_id
|
GROUP BY tm.team_id
|
||||||
ORDER BY points DESC
|
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()
|
rows = cur.fetchall()
|
||||||
if not rows:
|
if not rows:
|
||||||
|
|||||||
@@ -225,20 +225,43 @@ class FeatureBuilderMixin:
|
|||||||
if enrichment_failures:
|
if enrichment_failures:
|
||||||
print(f"⚠️ Enrichment partial failures for {data.match_id}: {', '.join(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 engine features
|
||||||
upset_atmosphere, upset_motivation, upset_fatigue = 0.0, 0.0, 0.0
|
upset_atmosphere, upset_motivation, upset_fatigue = 0.0, 0.0, 0.0
|
||||||
try:
|
try:
|
||||||
upset_engine = get_upset_engine()
|
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(
|
upset_feats = upset_engine.get_features(
|
||||||
home_team_name=getattr(data, 'home_team_name', '') or '',
|
home_team_name=getattr(data, 'home_team_name', '') or '',
|
||||||
home_team_id=data.home_team_id,
|
home_team_id=data.home_team_id,
|
||||||
away_team_name=getattr(data, 'away_team_name', '') or '',
|
away_team_name=getattr(data, 'away_team_name', '') or '',
|
||||||
league_name=getattr(data, 'league_name', '') or '',
|
league_name=getattr(data, 'league_name', '') or '',
|
||||||
home_position=10,
|
home_position=_home_pos,
|
||||||
away_position=10,
|
away_position=_away_pos,
|
||||||
match_date_ms=data.match_date_ms,
|
match_date_ms=data.match_date_ms,
|
||||||
|
is_cup_match=_is_cup_match,
|
||||||
home_days_rest=int(home_rest),
|
home_days_rest=int(home_rest),
|
||||||
away_days_rest=int(away_rest),
|
away_days_rest=int(away_rest),
|
||||||
|
total_teams=_league_total_teams,
|
||||||
)
|
)
|
||||||
upset_atmosphere = upset_feats.get('upset_atmosphere', 0.0)
|
upset_atmosphere = upset_feats.get('upset_atmosphere', 0.0)
|
||||||
upset_motivation = upset_feats.get('upset_motivation', 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_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
|
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) ──
|
# ── Derived / Interaction features (V27) ──
|
||||||
# Cup games: home ELO advantage is ~30% weaker (rotation, lower motivation)
|
# 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
|
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_home = data.home_goals_avg - data.away_conceded_avg
|
||||||
attack_vs_defense_away = data.away_goals_avg - data.home_conceded_avg
|
attack_vs_defense_away = data.away_goals_avg - data.home_conceded_avg
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"@nestjs/terminus": "^11.0.0",
|
"@nestjs/terminus": "^11.0.0",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/client": "^6.19.3",
|
"@prisma/client": "^6.19.3",
|
||||||
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bullmq": "^5.66.4",
|
"bullmq": "^5.66.4",
|
||||||
@@ -49,6 +50,8 @@
|
|||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"satori": "^0.26.0",
|
||||||
|
"satori-html": "^0.3.2",
|
||||||
"twitter-api-v2": "^1.29.0",
|
"twitter-api-v2": "^1.29.0",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
@@ -3912,12 +3915,243 @@
|
|||||||
"@redis/client": "^1.0.0"
|
"@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": {
|
"node_modules/@scarf/scarf": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
||||||
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
|
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
|
||||||
"hasInstallScript": true
|
"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": {
|
"node_modules/@sinclair/typebox": {
|
||||||
"version": "0.34.47",
|
"version": "0.34.47",
|
||||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz",
|
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz",
|
||||||
@@ -6535,6 +6769,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001762",
|
"version": "1.0.30001762",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
|
||||||
@@ -7086,6 +7329,36 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/css-select": {
|
||||||
"version": "5.2.2",
|
"version": "5.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||||
@@ -7102,6 +7375,17 @@
|
|||||||
"url": "https://github.com/sponsors/fb55"
|
"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": {
|
"node_modules/css-what": {
|
||||||
"version": "6.2.2",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
|
"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": {
|
"node_modules/empathic": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
||||||
@@ -8152,6 +8445,12 @@
|
|||||||
"node": "^12.20 || >= 14.13"
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"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==",
|
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/hookified": {
|
||||||
"version": "1.15.0",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.33.tgz",
|
||||||
"integrity": "sha512-r9kw4OA6oDO4dPXkOrXTkArQAafIKAU71hChInV4FxZ69dxCfbwQGDPzqR5/vea94wU705/3AZroEbSoeVWrQw=="
|
"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": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -11042,6 +11378,16 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/parse-json": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||||
@@ -11438,6 +11784,12 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/prebuild-install": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
"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": {
|
"node_modules/schema-utils": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
|
||||||
@@ -12488,6 +12871,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/strip-ansi": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
@@ -12858,6 +13247,12 @@
|
|||||||
"real-require": "^0.2.0"
|
"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": {
|
"node_modules/tinyexec": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
|
||||||
@@ -13260,6 +13655,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/undici": {
|
||||||
"version": "7.18.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
|
"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": {
|
"node_modules/universalify": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||||
@@ -13858,6 +14269,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/zod": {
|
||||||
"version": "4.3.5",
|
"version": "4.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
"@nestjs/terminus": "^11.0.0",
|
"@nestjs/terminus": "^11.0.0",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/client": "^6.19.3",
|
"@prisma/client": "^6.19.3",
|
||||||
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bullmq": "^5.66.4",
|
"bullmq": "^5.66.4",
|
||||||
@@ -82,6 +83,8 @@
|
|||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"satori": "^0.26.0",
|
||||||
|
"satori-html": "^0.3.2",
|
||||||
"twitter-api-v2": "^1.29.0",
|
"twitter-api-v2": "^1.29.0",
|
||||||
"zod": "^4.3.5"
|
"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 {
|
private ensureHashtags(text: string, card: PredictionCardDto): string {
|
||||||
// If no hashtags in text, add them
|
// Always strip any LLM-emitted hashtags then append our curated SEO set,
|
||||||
if (!text.includes("#")) {
|
// so we control the canonical tags the post is indexed under.
|
||||||
const leagueTag = card.leagueName
|
const stripped = text.replace(/(^|\s)#[^\s#]+/g, "").trim();
|
||||||
.replace(/\s+/g, "")
|
const tags = this.buildSeoHashtags(card);
|
||||||
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
|
return `${stripped}\n\n${tags}`.trim();
|
||||||
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}`;
|
|
||||||
}
|
}
|
||||||
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.
|
* 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 {
|
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 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}
|
const lines: string[] = [];
|
||||||
🎯 ${sportLabel} tahminimiz: ${card.ftScore} (${halfLabel}: ${card.htScore})
|
const flag = card.countryName ? `🌍 ${card.countryName}` : "🌍";
|
||||||
📊 Güven: %${card.scoreConfidence}
|
lines.push(`${flag} | ${card.leagueName}`);
|
||||||
${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ""}
|
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");
|
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);
|
const filePath = path.join(this.outputDir, fileName);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Rendering ${card.sport} social card for ${card.homeTeam} vs ${card.awayTeam}`,
|
`Rendering ${card.sport} social card for ${card.homeTeam} vs ${card.awayTeam}`,
|
||||||
);
|
);
|
||||||
await this.drawCanvas(card, filePath);
|
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;
|
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(
|
private async drawCanvas(
|
||||||
data: PredictionCardDto,
|
data: PredictionCardDto,
|
||||||
outPath: string,
|
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 { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
|
import type { Response } from "express";
|
||||||
|
import * as fs from "fs";
|
||||||
|
|
||||||
import { SocialPosterService } from "./social-poster.service";
|
import { SocialPosterService } from "./social-poster.service";
|
||||||
import { Roles } from "../../common/decorators";
|
import { Roles } from "../../common/decorators";
|
||||||
import { RolesGuard } from "../auth/guards/auth.guards";
|
import { RolesGuard } from "../auth/guards/auth.guards";
|
||||||
|
|
||||||
@ApiTags("Social Poster")
|
@ApiTags("Social Poster")
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(RolesGuard)
|
|
||||||
@Roles("superadmin")
|
|
||||||
@Controller("social-poster")
|
@Controller("social-poster")
|
||||||
export class SocialPosterController {
|
export class SocialPosterController {
|
||||||
constructor(private readonly socialPosterService: SocialPosterService) {}
|
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")
|
@Get("preview/:matchId")
|
||||||
async previewCard(@Param("matchId") matchId: string) {
|
async previewCard(@Param("matchId") matchId: string) {
|
||||||
return this.socialPosterService.renderPreview(matchId);
|
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")
|
@Post("post/:matchId")
|
||||||
async postMatch(@Param("matchId") matchId: string) {
|
async postMatch(@Param("matchId") matchId: string) {
|
||||||
return this.socialPosterService.manualPost(matchId);
|
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 sports: string[];
|
||||||
private readonly windowMinMinutes: number;
|
private readonly windowMinMinutes: number;
|
||||||
private readonly windowMaxMinutes: number;
|
private readonly windowMaxMinutes: number;
|
||||||
|
private readonly maxPostsPerRun: number;
|
||||||
private readonly postedMatchIds = new Set<string>();
|
private readonly postedMatchIds = new Set<string>();
|
||||||
private topLeagueIds: Set<string> = new Set();
|
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(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
@@ -59,11 +66,18 @@ export class SocialPosterService {
|
|||||||
.split(",")
|
.split(",")
|
||||||
.map((sport) => sport.trim())
|
.map((sport) => sport.trim())
|
||||||
.filter(Boolean);
|
.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.windowMinMinutes = Number(
|
||||||
this.configService.get<string>("SOCIAL_POSTER_WINDOW_MIN") || 25,
|
this.configService.get<string>("SOCIAL_POSTER_WINDOW_MIN") || 10,
|
||||||
);
|
);
|
||||||
this.windowMaxMinutes = Number(
|
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();
|
this.loadTopLeagues();
|
||||||
@@ -108,12 +122,15 @@ export class SocialPosterService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cron: Every 15 minutes, check for upcoming matches.
|
* Cron: Every 10 minutes, check for upcoming matches and post prediction
|
||||||
* Posts predictions 30 minutes before kickoff.
|
* 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() {
|
async checkAndPostUpcomingMatches() {
|
||||||
if (!this.isEnabled) return;
|
if (!this.isEnabled) return;
|
||||||
|
this.lastRunAt = new Date();
|
||||||
|
this.lastRunResult = { posted: 0, skipped: 0, errors: 0 };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const matches = await this.getUpcomingMatches(
|
const matches = await this.getUpcomingMatches(
|
||||||
@@ -121,11 +138,22 @@ export class SocialPosterService {
|
|||||||
this.windowMaxMinutes,
|
this.windowMaxMinutes,
|
||||||
);
|
);
|
||||||
this.logger.log(
|
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) {
|
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 {
|
try {
|
||||||
const result = await this.predictAndPost(match);
|
const result = await this.predictAndPost(match);
|
||||||
@@ -136,12 +164,15 @@ export class SocialPosterService {
|
|||||||
|
|
||||||
if (!posted) {
|
if (!posted) {
|
||||||
this.logger.warn(
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.postedMatchIds.add(match.id);
|
this.postedMatchIds.add(match.id);
|
||||||
|
this.lastRunResult.posted++;
|
||||||
|
postedThisRun++;
|
||||||
|
|
||||||
// Cleanup: remove old IDs (keep last 500)
|
// Cleanup: remove old IDs (keep last 500)
|
||||||
if (this.postedMatchIds.size > 500) {
|
if (this.postedMatchIds.size > 500) {
|
||||||
@@ -155,6 +186,7 @@ export class SocialPosterService {
|
|||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to process match ${match.id}: ${error.message}`,
|
`Failed to process match ${match.id}: ${error.message}`,
|
||||||
);
|
);
|
||||||
|
this.lastRunResult.errors++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small delay between posts to avoid rate limits
|
// Small delay between posts to avoid rate limits
|
||||||
@@ -162,9 +194,32 @@ export class SocialPosterService {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Cron job failed: ${error.message}`);
|
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.
|
* Get matches starting in [minMinutes, maxMinutes] from now.
|
||||||
* Filtered by top leagues.
|
* Filtered by top leagues.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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); });
|
||||||
@@ -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); });
|
||||||