first (part 2: other directories)
Deploy Iddaai Backend / build-and-deploy (push) Failing after 18s

This commit is contained in:
2026-04-16 15:11:25 +03:00
parent 7814e0bc6b
commit 2f0b85a0c7
203 changed files with 59989 additions and 0 deletions
+302
View File
@@ -0,0 +1,302 @@
"""
Quantitative Finance Module — V2 Betting Engine
Edge calculation, Fractional Kelly Criterion staking, bet grading, and risk assessment.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Any
# ═══════════════════════════════════════════════════════════════════════════
# Constants
# ═══════════════════════════════════════════════════════════════════════════
BANKROLL_UNITS: float = 10.0 # Total bankroll in abstract units
KELLY_FRACTION: float = 0.25 # Quarter-Kelly (conservative, anti-ruin)
MIN_EDGE_PLAYABLE: float = 0.05 # 5% edge minimum to mark as playable
MIN_ODDS_PLAYABLE: float = 1.30 # Skip extreme chalk below 1.30
# ═══════════════════════════════════════════════════════════════════════════
# Edge Calculation
# ═══════════════════════════════════════════════════════════════════════════
def calculate_edge(true_prob: float, decimal_odds: float) -> float:
"""
Edge = (True_Probability × Decimal_Odds) - 1.0
Positive edge → the model says we have an advantage over the bookmaker.
"""
if decimal_odds <= 1.0 or true_prob <= 0.0:
return -1.0
return round((true_prob * decimal_odds) - 1.0, 4)
# ═══════════════════════════════════════════════════════════════════════════
# Kelly Criterion Staking
# ═══════════════════════════════════════════════════════════════════════════
def kelly_stake(true_prob: float, decimal_odds: float) -> float:
"""
Fractional Kelly Criterion for a bankroll of BANKROLL_UNITS.
Full Kelly: f* = ((b × p) - q) / b
where b = decimal_odds - 1, p = true_prob, q = 1 - true_prob
We use KELLY_FRACTION (25%) to reduce variance and avoid ruin.
Returns stake in units, rounded to 0.1.
"""
if decimal_odds <= 1.0 or true_prob <= 0.0 or true_prob >= 1.0:
return 0.0
b = decimal_odds - 1.0
p = true_prob
q = 1.0 - p
f_star = ((b * p) - q) / b
if f_star <= 0.0:
return 0.0
# Scale by fraction and bankroll
stake = f_star * KELLY_FRACTION * BANKROLL_UNITS
# Cap at a sensible maximum (3 units on a 10-unit bankroll)
stake = min(stake, 3.0)
return round(max(0.0, stake), 1)
# ═══════════════════════════════════════════════════════════════════════════
# Bet Grading
# ═══════════════════════════════════════════════════════════════════════════
def grade_bet(edge: float, playable: bool) -> str:
"""
Assign a letter grade based on edge magnitude.
A: Edge > 10% — Elite value, rare
B: Edge > 5% — Strong value, core bets
C: Edge > 2% — Marginal value, supporting picks only
PASS: Below threshold — Do not bet
"""
if not playable or edge < 0.02:
return "PASS"
if edge > 0.10:
return "A"
if edge > 0.05:
return "B"
return "C"
def is_playable(edge: float, decimal_odds: float) -> bool:
"""A pick is playable if it has sufficient edge AND reasonable odds."""
return edge >= MIN_EDGE_PLAYABLE and decimal_odds >= MIN_ODDS_PLAYABLE
# ═══════════════════════════════════════════════════════════════════════════
# Play Score (0-100 composite)
# ═══════════════════════════════════════════════════════════════════════════
def calculate_play_score(
edge: float,
true_prob: float,
data_quality: float,
) -> float:
"""
Composite score combining edge strength, probability confidence,
and data quality. Used for ranking picks and filtering.
Components:
- Edge contribution (0-50): edge * 250, capped at 50
- Prob contribution (0-30): probability * 30
- DQ contribution (0-20): data_quality * 20
"""
edge_score = min(50.0, max(0.0, edge * 250.0))
prob_score = min(30.0, max(0.0, true_prob * 30.0))
dq_score = min(20.0, max(0.0, data_quality * 20.0))
return round(edge_score + prob_score + dq_score, 1)
# ═══════════════════════════════════════════════════════════════════════════
# Risk Assessment
# ═══════════════════════════════════════════════════════════════════════════
@dataclass
class RiskResult:
level: str # LOW, MEDIUM, HIGH, EXTREME
score: float # 0.0 - 1.0
is_surprise_risk: bool
surprise_type: str | None
warnings: list[str]
def assess_risk(
missing_players_impact: float,
data_quality_score: float,
elo_diff: float,
implied_prob_fav: float,
) -> RiskResult:
"""
Multi-factor risk assessment.
Factors:
1. Missing key players (injuries/suspensions)
2. Data quality (missing stats, odds)
3. ELO closeness (tight matches are riskier)
4. Surprise potential (heavy favorite vulnerable)
"""
warnings: list[str] = []
risk_score = 0.0
# ─── Factor 1: Missing players ────────────────────────────────────
if missing_players_impact > 0.3:
risk_score += 0.35
warnings.append(
f"High missing-player impact: {missing_players_impact:.2f}"
)
elif missing_players_impact > 0.15:
risk_score += 0.15
warnings.append(
f"Moderate missing-player impact: {missing_players_impact:.2f}"
)
# ─── Factor 2: Data quality ───────────────────────────────────────
if data_quality_score < 0.5:
risk_score += 0.25
warnings.append(
f"Low data quality: {data_quality_score:.2f}"
)
elif data_quality_score < 0.75:
risk_score += 0.10
# ─── Factor 3: ELO closeness ──────────────────────────────────────
abs_elo_diff = abs(elo_diff)
if abs_elo_diff < 50:
risk_score += 0.15
warnings.append("Very tight ELO difference — coin-flip territory")
elif abs_elo_diff < 100:
risk_score += 0.05
# ─── Factor 4: Surprise detection ─────────────────────────────────
is_surprise = False
surprise_type: str | None = None
if implied_prob_fav > 0.65 and abs_elo_diff < 80:
# Heavy favorite by odds but ELO says match is closer
is_surprise = True
surprise_type = "odds_elo_divergence"
risk_score += 0.15
warnings.append(
"Upset potential: bookmaker odds suggest heavy favorite "
"but ELO says the match is closer than the market thinks"
)
# ─── Classify ─────────────────────────────────────────────────────
risk_score = min(1.0, risk_score)
if risk_score >= 0.7:
level = "EXTREME"
elif risk_score >= 0.45:
level = "HIGH"
elif risk_score >= 0.2:
level = "MEDIUM"
else:
level = "LOW"
return RiskResult(
level=level,
score=round(risk_score, 3),
is_surprise_risk=is_surprise,
surprise_type=surprise_type,
warnings=warnings,
)
# ═══════════════════════════════════════════════════════════════════════════
# Market Analysis (orchestrates edge/kelly/grade per market)
# ═══════════════════════════════════════════════════════════════════════════
@dataclass
class MarketPick:
market: str
pick: str
probability: float
odds: float
edge: float
playable: bool
bet_grade: str
stake_units: float
play_score: float
decision_reasons: list[str]
def analyze_market(
market: str,
probs: dict[str, float],
odds_map: dict[str, float],
data_quality_score: float,
) -> MarketPick:
"""
For a given market (MS, OU25, BTTS), find the best pick,
calculate edge, kelly stake, and grade it.
Parameters:
market: "MS", "OU25", "BTTS"
probs: {"1": 0.55, "X": 0.25, "2": 0.20} — calibrated model probs
odds_map: {"1": 2.10, "X": 3.40, "2": 3.50} — decimal odds
data_quality_score: 0.0-1.0
"""
best_pick: str = ""
best_edge: float = -99.0
best_prob: float = 0.0
best_odds: float = 0.0
reasons: list[str] = []
for pick_name, prob in probs.items():
odd = odds_map.get(pick_name, 0.0)
if odd <= 1.0:
continue
edge = calculate_edge(prob, odd)
if edge > best_edge:
best_edge = edge
best_pick = pick_name
best_prob = prob
best_odds = odd
if not best_pick:
return MarketPick(
market=market, pick="", probability=0.0, odds=0.0,
edge=0.0, playable=False, bet_grade="PASS",
stake_units=0.0, play_score=0.0,
decision_reasons=["no_valid_odds_found"],
)
playable = is_playable(best_edge, best_odds)
grade = grade_bet(best_edge, playable)
stake = kelly_stake(best_prob, best_odds) if playable else 0.0
play_score = calculate_play_score(best_edge, best_prob, data_quality_score)
# Build decision reasons
if playable:
reasons.append(f"edge_{best_edge:.1%}_above_threshold")
reasons.append(f"kelly_stake_{stake:.1f}_units")
else:
if best_edge < MIN_EDGE_PLAYABLE:
reasons.append(f"edge_{best_edge:.1%}_below_{MIN_EDGE_PLAYABLE:.0%}_threshold")
if best_odds < MIN_ODDS_PLAYABLE:
reasons.append(f"odds_{best_odds:.2f}_below_{MIN_ODDS_PLAYABLE:.2f}_minimum")
return MarketPick(
market=market,
pick=best_pick,
probability=round(best_prob, 4),
odds=round(best_odds, 2),
edge=round(best_edge, 4),
playable=playable,
bet_grade=grade,
stake_units=stake,
play_score=play_score,
decision_reasons=reasons,
)