353 lines
13 KiB
Python
353 lines
13 KiB
Python
"""
|
||
Tutarsızlık Bazlı Backtest
|
||
============================
|
||
Modeller arası tutarsızlığı ölçer, tutarlı maçlarda bahis açılsaydı
|
||
ROI ne olurdu hesaplar.
|
||
|
||
Mantık:
|
||
- Her maç için market'ler arası çelişkileri tespit et
|
||
- Tutarsız maçları filtrele
|
||
- Tutarlı maçlarda hit rate ve ROI hesapla
|
||
|
||
Usage:
|
||
python scripts/backtest_consistency.py
|
||
"""
|
||
|
||
import os, sys, json
|
||
import numpy as np
|
||
import pandas as pd
|
||
import xgboost as xgb
|
||
from sklearn.metrics import accuracy_score
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
DATA_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
'data', 'training_data.csv')
|
||
MODELS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
'models', 'v25')
|
||
|
||
SKIP_COLS = {
|
||
'match_id','home_team_id','away_team_id','league_id','mst_utc',
|
||
'score_home','score_away','total_goals','ht_score_home','ht_score_away','ht_total_goals',
|
||
'label_ms','label_ou05','label_ou15','label_ou25','label_ou35','label_btts',
|
||
'label_ht_result','label_ht_ou05','label_ht_ou15','label_ht_ft',
|
||
'label_odd_even','label_yellow_cards','label_cards_ou45','label_handicap_ms',
|
||
}
|
||
|
||
|
||
def load_model(market: str):
|
||
path = os.path.join(MODELS_DIR, f'xgb_v25_{market}.json')
|
||
if not os.path.exists(path):
|
||
return None
|
||
b = xgb.Booster()
|
||
b.load_model(path)
|
||
return b
|
||
|
||
|
||
def predict_proba(model, X: np.ndarray, feature_cols: list, n_class: int):
|
||
dmat = xgb.DMatrix(pd.DataFrame(X, columns=feature_cols))
|
||
raw = model.predict(dmat)
|
||
if n_class > 2:
|
||
return raw.reshape(-1, n_class)
|
||
return np.column_stack([1 - raw, raw])
|
||
|
||
|
||
def consistency_score(probs: dict) -> tuple[float, list]:
|
||
"""
|
||
Market'ler arası tutarsızlığı hesapla.
|
||
0 = tamamen tutarlı, 1 = tamamen çelişkili.
|
||
|
||
Kontrol edilen çelişkiler:
|
||
1. OU15 üst yüksek ama OU25 üst de yüksek → ok
|
||
OU15 üst yüksek ama OU25 alt yüksek → ÇELISKI (1 gol bekleniyor ama 2.5+ da bekleniyor?)
|
||
|
||
2. HT_OU05 üst yüksek ama HT sonucu draw yüksek → ÇELISKI
|
||
|
||
3. OU35 üst yüksek ama BTTS düşük → şüpheli
|
||
|
||
4. MS home yüksek ama HT away yüksek → çelişkili
|
||
"""
|
||
conflicts = []
|
||
total_weight = 0
|
||
total_conflict = 0
|
||
|
||
# OU tutarlılığı: P(OU25>0.5) <= P(OU15>0.5) matematiksel zorunluluk
|
||
ou15_over = probs.get('ou15_over', 0.5)
|
||
ou25_over = probs.get('ou25_over', 0.5)
|
||
ou35_over = probs.get('ou35_over', 0.5)
|
||
|
||
# OU hiyerarşisi: ou35 <= ou25 <= ou15 olmalı
|
||
if ou25_over > ou15_over + 0.05:
|
||
gap = ou25_over - ou15_over
|
||
conflicts.append(f'OU25>{ou25_over:.0%} > OU15>{ou15_over:.0%} (imkansız)')
|
||
total_conflict += gap * 2
|
||
total_weight += 1
|
||
|
||
if ou35_over > ou25_over + 0.05:
|
||
gap = ou35_over - ou25_over
|
||
conflicts.append(f'OU35>{ou35_over:.0%} > OU25>{ou25_over:.0%} (imkansız)')
|
||
total_conflict += gap * 2
|
||
total_weight += 1
|
||
|
||
# HT_OU05 ve HT sonuç tutarlılığı
|
||
ht_ou05_over = probs.get('ht_ou05_over', 0.5)
|
||
ht_draw_prob = probs.get('ht_draw', 0.34)
|
||
|
||
# İlk yarıda gol bekleniyor ama beraberlik de bekleniyor (0-0 draw?)
|
||
# HT_OU05 >%70 ama HT draw >%50 → çelişkili (0-0 berabere çok?)
|
||
if ht_ou05_over > 0.70 and ht_draw_prob > 0.50:
|
||
conflict = min(ht_ou05_over - 0.5, ht_draw_prob - 0.4)
|
||
conflicts.append(f'HT_OU05>{ht_ou05_over:.0%} ama HT_Draw>{ht_draw_prob:.0%}')
|
||
total_conflict += conflict
|
||
total_weight += 1
|
||
|
||
# HT_OU05 ve HT_OU15 tutarlılığı
|
||
ht_ou15_over = probs.get('ht_ou15_over', 0.3)
|
||
if ht_ou15_over > ht_ou05_over + 0.05:
|
||
gap = ht_ou15_over - ht_ou05_over
|
||
conflicts.append(f'HT_OU15>{ht_ou15_over:.0%} > HT_OU05>{ht_ou05_over:.0%} (imkansız)')
|
||
total_conflict += gap * 2
|
||
total_weight += 1
|
||
|
||
# MS ve OU tutarlılığı
|
||
ms_home = probs.get('ms_home', 0.33)
|
||
ms_away = probs.get('ms_away', 0.33)
|
||
btts_yes = probs.get('btts_yes', 0.5)
|
||
|
||
# Tek takım galibiyeti kuvvetli ama BTTS yüksek → şüpheli
|
||
dominant = max(ms_home, ms_away)
|
||
if dominant > 0.65 and btts_yes > 0.65:
|
||
conflict = (dominant - 0.5) * (btts_yes - 0.5)
|
||
conflicts.append(f'MS dominant>{dominant:.0%} ama BTTS_Yes>{btts_yes:.0%}')
|
||
total_conflict += conflict * 0.5
|
||
total_weight += 1
|
||
|
||
# OU25 ve BTTS tutarlılığı
|
||
# BTTS yüksekse en az 2 gol → OU25 üst de yüksek olmalı
|
||
if btts_yes > 0.65 and ou25_over < 0.45:
|
||
conflict = btts_yes - ou25_over
|
||
conflicts.append(f'BTTS_Yes>{btts_yes:.0%} ama OU25>{ou25_over:.0%} düşük')
|
||
total_conflict += conflict
|
||
total_weight += 1
|
||
|
||
# OU35 üst yüksek ama BTTS düşük → şüpheli (3+ gol ama tek takım mı?)
|
||
if ou35_over > 0.45 and btts_yes < 0.40:
|
||
conflict = (ou35_over - 0.35) * (0.5 - btts_yes)
|
||
conflicts.append(f'OU35>{ou35_over:.0%} ama BTTS_Yes<{btts_yes:.0%}')
|
||
total_conflict += conflict
|
||
total_weight += 1
|
||
|
||
score = min(1.0, total_conflict / max(total_weight * 0.3, 0.1))
|
||
return score, conflicts
|
||
|
||
|
||
def main():
|
||
print('Loading data...')
|
||
df = pd.read_csv(DATA_PATH, low_memory=False)
|
||
|
||
# Son %20 = test seti (kronolojik)
|
||
df = df.sort_values('mst_utc')
|
||
n_test = int(len(df) * 0.20)
|
||
df_test = df.tail(n_test).copy()
|
||
print(f'Test seti: {len(df_test):,} maç')
|
||
|
||
feature_cols = [c for c in df.columns if c not in SKIP_COLS]
|
||
|
||
# Modelleri yükle
|
||
print('Modeller yükleniyor...')
|
||
models = {
|
||
'ms': (load_model('ms'), 3),
|
||
'ou15': (load_model('ou15'), 2),
|
||
'ou25': (load_model('ou25'), 2),
|
||
'ou35': (load_model('ou35'), 2),
|
||
'btts': (load_model('btts'), 2),
|
||
'ht_result':(load_model('ht_result'), 3),
|
||
'ht_ou05': (load_model('ht_ou05'), 2),
|
||
'ht_ou15': (load_model('ht_ou15'), 2),
|
||
}
|
||
models = {k: v for k, v in models.items() if v[0] is not None}
|
||
print(f'Yüklenen model: {list(models.keys())}')
|
||
|
||
X = df_test[feature_cols].fillna(0).values
|
||
|
||
# Tüm tahminleri al
|
||
print('Tahminler yapılıyor...')
|
||
preds = {}
|
||
for mkey, (model, n_class) in models.items():
|
||
p = predict_proba(model, X, feature_cols, n_class)
|
||
preds[mkey] = p
|
||
|
||
# Her maç için tutarsızlık skoru ve tahmin kararı
|
||
results = []
|
||
for i in range(len(df_test)):
|
||
row = df_test.iloc[i]
|
||
|
||
# Olasılıkları topla
|
||
probs = {}
|
||
if 'ms' in preds:
|
||
probs['ms_home'] = preds['ms'][i][0]
|
||
probs['ms_draw'] = preds['ms'][i][1]
|
||
probs['ms_away'] = preds['ms'][i][2]
|
||
if 'ou15' in preds:
|
||
probs['ou15_over'] = preds['ou15'][i][1]
|
||
if 'ou25' in preds:
|
||
probs['ou25_over'] = preds['ou25'][i][1]
|
||
if 'ou35' in preds:
|
||
probs['ou35_over'] = preds['ou35'][i][1]
|
||
if 'btts' in preds:
|
||
probs['btts_yes'] = preds['btts'][i][1]
|
||
if 'ht_result' in preds:
|
||
probs['ht_home'] = preds['ht_result'][i][0]
|
||
probs['ht_draw'] = preds['ht_result'][i][1]
|
||
probs['ht_away'] = preds['ht_result'][i][2]
|
||
if 'ht_ou05' in preds:
|
||
probs['ht_ou05_over'] = preds['ht_ou05'][i][1]
|
||
if 'ht_ou15' in preds:
|
||
probs['ht_ou15_over'] = preds['ht_ou15'][i][1]
|
||
|
||
c_score, conflicts = consistency_score(probs)
|
||
|
||
# Gerçek sonuçlar
|
||
actual = {
|
||
'ms': int(row.get('label_ms', -1)),
|
||
'ou15': int(row.get('label_ou15', -1)),
|
||
'ou25': int(row.get('label_ou25', -1)),
|
||
'ou35': int(row.get('label_ou35', -1)),
|
||
'btts': int(row.get('label_btts', -1)),
|
||
}
|
||
|
||
# Her market için tahmin ve doğruluk
|
||
market_results = {}
|
||
for mkt, label_key in [('ms','ms'),('ou15','ou15'),('ou25','ou25'),
|
||
('ou35','ou35'),('btts','btts')]:
|
||
if mkt not in preds or actual[label_key] < 0:
|
||
continue
|
||
pred_class = int(np.argmax(preds[mkt][i]))
|
||
correct = int(pred_class == actual[label_key])
|
||
|
||
# Odds (implied prob → odds = 1/prob)
|
||
pred_prob = float(preds[mkt][i][pred_class])
|
||
implied_odds = 1 / pred_prob if pred_prob > 0.01 else 10.0
|
||
# ROI hesabı: 1 birim bahis, kazanırsa (odds-1) kazanç, kaybederse -1
|
||
roi = (implied_odds - 1) * correct - (1 - correct)
|
||
|
||
market_results[mkt] = {
|
||
'pred': pred_class,
|
||
'actual': actual[label_key],
|
||
'correct': correct,
|
||
'prob': pred_prob,
|
||
'roi': roi,
|
||
}
|
||
|
||
results.append({
|
||
'idx': i,
|
||
'consistency_score': c_score,
|
||
'conflicts': conflicts,
|
||
'probs': probs,
|
||
'market_results': market_results,
|
||
})
|
||
|
||
df_results = pd.DataFrame([{
|
||
'consistency_score': r['consistency_score'],
|
||
'n_conflicts': len(r['conflicts']),
|
||
**{f'{m}_correct': r['market_results'].get(m, {}).get('correct', None)
|
||
for m in ['ms','ou15','ou25','ou35','btts']},
|
||
**{f'{m}_roi': r['market_results'].get(m, {}).get('roi', None)
|
||
for m in ['ms','ou15','ou25','ou35','btts']},
|
||
} for r in results])
|
||
|
||
# ── Analiz ──────────────────────────────────────────────────────────
|
||
print(f'\n{"="*70}')
|
||
print('TUTARSIZLIK ANALİZİ')
|
||
print(f'{"="*70}')
|
||
|
||
thresholds = [0.0, 0.1, 0.2, 0.3, 0.5]
|
||
markets = ['ms', 'ou15', 'ou25', 'ou35', 'btts']
|
||
|
||
for t in thresholds:
|
||
mask = df_results['consistency_score'] <= t
|
||
n = mask.sum()
|
||
if n < 50:
|
||
continue
|
||
|
||
print(f'\n[Tutarsızlık <= {t:.1f}] → {n:,} maç ({n/len(df_results)*100:.0f}%)')
|
||
print(f' {"Market":<8} {"HitRate":>8} {"ROI/bahis":>10} {"Toplam ROI":>12}')
|
||
print(f' {"-"*42}')
|
||
for m in markets:
|
||
col_c = f'{m}_correct'
|
||
col_r = f'{m}_roi'
|
||
if col_c not in df_results.columns:
|
||
continue
|
||
sub = df_results[mask][col_c].dropna()
|
||
roi_sub = df_results[mask][col_r].dropna()
|
||
if len(sub) < 20:
|
||
continue
|
||
hit = sub.mean()
|
||
avg_roi = roi_sub.mean()
|
||
total_roi = roi_sub.sum()
|
||
print(f' {m:<8} {hit:>7.1%} {avg_roi:>+9.3f} {total_roi:>+11.1f}')
|
||
|
||
# Çelişki türlerine göre breakdown
|
||
print(f'\n{"="*70}')
|
||
print('EN SIK ÇELIŞKILER')
|
||
print(f'{"="*70}')
|
||
all_conflicts = [c for r in results for c in r['conflicts']]
|
||
from collections import Counter
|
||
for conflict, cnt in Counter(all_conflicts).most_common(10):
|
||
print(f' {cnt:>5}x {conflict}')
|
||
|
||
# Tutarsızlık dağılımı
|
||
print(f'\n{"="*70}')
|
||
print('TUTARSIZLIK DAĞILIMI')
|
||
print(f'{"="*70}')
|
||
for label, lo, hi in [
|
||
('Tamamen tutarlı', 0.0, 0.05),
|
||
('Çok tutarlı', 0.05, 0.15),
|
||
('Orta', 0.15, 0.30),
|
||
('Tutarsız', 0.30, 0.50),
|
||
('Çok tutarsız', 0.50, 1.01),
|
||
]:
|
||
mask = (df_results['consistency_score'] >= lo) & (df_results['consistency_score'] < hi)
|
||
n = mask.sum()
|
||
ou25_hit = df_results[mask]['ou25_correct'].mean()
|
||
ms_hit = df_results[mask]['ms_correct'].mean()
|
||
print(f' {label:<20} {n:>6,} maç ({n/len(df_results)*100:>4.0f}%) | '
|
||
f'MS={ms_hit:.0%} OU25={ou25_hit:.0%}')
|
||
|
||
# Raporu kaydet
|
||
report = {
|
||
'total_test': len(df_results),
|
||
'thresholds': {},
|
||
}
|
||
for t in thresholds:
|
||
mask = df_results['consistency_score'] <= t
|
||
n = mask.sum()
|
||
report['thresholds'][str(t)] = {
|
||
'n_matches': int(n),
|
||
'pct': round(n/len(df_results)*100, 1),
|
||
'markets': {},
|
||
}
|
||
for m in markets:
|
||
col_c = f'{m}_correct'
|
||
col_r = f'{m}_roi'
|
||
if col_c not in df_results.columns:
|
||
continue
|
||
sub_c = df_results[mask][col_c].dropna()
|
||
sub_r = df_results[mask][col_r].dropna()
|
||
if len(sub_c) > 0:
|
||
report['thresholds'][str(t)]['markets'][m] = {
|
||
'hit_rate': round(float(sub_c.mean()), 4),
|
||
'avg_roi': round(float(sub_r.mean()), 4),
|
||
'total_roi': round(float(sub_r.sum()), 2),
|
||
}
|
||
|
||
out_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
'reports', 'backtest_consistency.json')
|
||
with open(out_path, 'w') as f:
|
||
json.dump(report, f, indent=2)
|
||
print(f'\nRapor: {out_path}')
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|