This commit is contained in:
Executable
+75
@@ -0,0 +1,75 @@
|
||||
import sys
|
||||
import unittest
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
AI_ENGINE_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(AI_ENGINE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(AI_ENGINE_ROOT))
|
||||
|
||||
from core.engines.odds_predictor import OddsPredictorEngine
|
||||
from features.sidelined_analyzer import SidelinedAnalyzer
|
||||
|
||||
|
||||
class EngineNullSafetyTests(unittest.TestCase):
|
||||
def test_odds_predictor_accepts_decimal_inputs_without_crashing(self):
|
||||
engine = OddsPredictorEngine()
|
||||
|
||||
prediction = engine.predict(
|
||||
odds_data={
|
||||
"ms_h": Decimal("2.10"),
|
||||
"ms_d": Decimal("3.25"),
|
||||
"ms_a": Decimal("3.60"),
|
||||
"ou25_o": Decimal("1.90"),
|
||||
},
|
||||
)
|
||||
|
||||
self.assertGreater(prediction.market_home_prob, 0.0)
|
||||
self.assertGreater(prediction.market_draw_prob, 0.0)
|
||||
self.assertGreater(prediction.market_away_prob, 0.0)
|
||||
|
||||
def test_sidelined_analyzer_handles_non_numeric_fields(self):
|
||||
analyzer = SidelinedAnalyzer.__new__(SidelinedAnalyzer)
|
||||
analyzer.position_weights = {"K": 0.35, "D": 0.20, "O": 0.25, "F": 0.30}
|
||||
analyzer.max_rating = 10
|
||||
analyzer.adaptation_threshold = 10
|
||||
analyzer.adaptation_discount = 0.5
|
||||
analyzer.goalkeeper_penalty = 0.15
|
||||
analyzer.confidence_boost = 10
|
||||
analyzer.max_impact = 0.85
|
||||
analyzer.key_player_threshold = 3
|
||||
analyzer.recent_matches_lookback = 15
|
||||
analyzer._fetch_player_stats = MagicMock(return_value={})
|
||||
|
||||
result = analyzer.analyze(
|
||||
{
|
||||
"totalSidelined": 2,
|
||||
"players": [
|
||||
{
|
||||
"playerId": "p1",
|
||||
"playerName": "Player One",
|
||||
"positionShort": "O",
|
||||
"matchesMissed": "N/A",
|
||||
"average": "?",
|
||||
"type": "injury",
|
||||
},
|
||||
{
|
||||
"playerId": "p2",
|
||||
"playerName": "Player Two",
|
||||
"positionShort": "K",
|
||||
"matchesMissed": "12",
|
||||
"average": "6.7",
|
||||
"type": "suspension",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(result.total_sidelined, 2)
|
||||
self.assertGreaterEqual(result.impact_score, 0.0)
|
||||
self.assertTrue(len(result.player_details) >= 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
Unit tests for FeatureEnrichmentService
|
||||
========================================
|
||||
Tests all 6 enrichment methods with mocked DB cursor:
|
||||
1. compute_team_stats
|
||||
2. compute_h2h
|
||||
3. compute_form_streaks
|
||||
4. compute_referee_stats
|
||||
5. compute_league_averages
|
||||
6. compute_momentum
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
AI_ENGINE_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(AI_ENGINE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(AI_ENGINE_ROOT))
|
||||
|
||||
from services.feature_enrichment import FeatureEnrichmentService, _safe_avg
|
||||
|
||||
|
||||
def _make_cursor(rows=None, side_effect=None):
|
||||
"""Create a mock RealDictCursor."""
|
||||
cur = MagicMock()
|
||||
if side_effect:
|
||||
cur.execute.side_effect = side_effect
|
||||
else:
|
||||
cur.fetchall.return_value = rows or []
|
||||
cur.fetchone.return_value = rows[0] if rows else None
|
||||
return cur
|
||||
|
||||
|
||||
class TestSafeAvg(unittest.TestCase):
|
||||
def test_returns_average(self):
|
||||
self.assertAlmostEqual(_safe_avg([2.0, 4.0, 6.0], 0.0), 4.0)
|
||||
|
||||
def test_returns_default_on_empty(self):
|
||||
self.assertEqual(_safe_avg([], 99.0), 99.0)
|
||||
|
||||
def test_single_value(self):
|
||||
self.assertAlmostEqual(_safe_avg([7.5], 0.0), 7.5)
|
||||
|
||||
|
||||
class TestComputeTeamStats(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.svc = FeatureEnrichmentService()
|
||||
self.ts = 1700000000000
|
||||
|
||||
def test_returns_defaults_when_no_team_id(self):
|
||||
result = self.svc.compute_team_stats(MagicMock(), '', self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_TEAM_STATS)
|
||||
|
||||
def test_returns_defaults_when_no_rows(self):
|
||||
cur = _make_cursor(rows=[])
|
||||
result = self.svc.compute_team_stats(cur, 'team1', self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_TEAM_STATS)
|
||||
|
||||
def test_returns_defaults_on_db_error(self):
|
||||
cur = _make_cursor(side_effect=Exception('DB down'))
|
||||
result = self.svc.compute_team_stats(cur, 'team1', self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_TEAM_STATS)
|
||||
|
||||
def test_calculates_averages_correctly(self):
|
||||
rows = [
|
||||
{'possession_percentage': 60.0, 'shots_on_target': 5, 'total_shots': 10, 'corners': 7},
|
||||
{'possession_percentage': 40.0, 'shots_on_target': 3, 'total_shots': 12, 'corners': 3},
|
||||
]
|
||||
cur = _make_cursor(rows)
|
||||
result = self.svc.compute_team_stats(cur, 'team1', self.ts)
|
||||
|
||||
self.assertAlmostEqual(result['avg_possession'], 50.0)
|
||||
self.assertAlmostEqual(result['avg_shots_on_target'], 4.0)
|
||||
self.assertAlmostEqual(result['shot_conversion'], (5 / 10 + 3 / 12) / 2, places=4)
|
||||
self.assertAlmostEqual(result['avg_corners'], 5.0)
|
||||
|
||||
def test_handles_none_subfields_gracefully(self):
|
||||
"""Rows with None values should be skipped, not crash."""
|
||||
rows = [
|
||||
{'possession_percentage': 55.0, 'shots_on_target': None, 'total_shots': None, 'corners': 4},
|
||||
{'possession_percentage': None, 'shots_on_target': 2, 'total_shots': 8, 'corners': None},
|
||||
]
|
||||
cur = _make_cursor(rows)
|
||||
result = self.svc.compute_team_stats(cur, 'team1', self.ts)
|
||||
|
||||
self.assertAlmostEqual(result['avg_possession'], 55.0)
|
||||
self.assertAlmostEqual(result['avg_shots_on_target'], 2.0)
|
||||
self.assertAlmostEqual(result['avg_corners'], 4.0)
|
||||
|
||||
|
||||
class TestComputeH2H(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.svc = FeatureEnrichmentService()
|
||||
self.ts = 1700000000000
|
||||
|
||||
def test_returns_defaults_when_no_ids(self):
|
||||
result = self.svc.compute_h2h(MagicMock(), '', 'away1', self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_H2H)
|
||||
|
||||
def test_returns_defaults_when_no_rows(self):
|
||||
cur = _make_cursor(rows=[])
|
||||
result = self.svc.compute_h2h(cur, 'home1', 'away1', self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_H2H)
|
||||
|
||||
def test_calculates_h2h_stats(self):
|
||||
rows = [
|
||||
{'home_team_id': 'home1', 'away_team_id': 'away1', 'score_home': 2, 'score_away': 1}, # home win, btts, over25
|
||||
{'home_team_id': 'home1', 'away_team_id': 'away1', 'score_home': 0, 'score_away': 0}, # draw, no btts, no over25
|
||||
{'home_team_id': 'away1', 'away_team_id': 'home1', 'score_home': 1, 'score_away': 3}, # reversed: home wins again, btts, over25
|
||||
{'home_team_id': 'away1', 'away_team_id': 'home1', 'score_home': 2, 'score_away': 0}, # reversed: away(=home1) lost
|
||||
]
|
||||
cur = _make_cursor(rows)
|
||||
result = self.svc.compute_h2h(cur, 'home1', 'away1', self.ts)
|
||||
|
||||
self.assertEqual(result['total_matches'], 4)
|
||||
self.assertAlmostEqual(result['home_win_rate'], 2 / 4)
|
||||
self.assertAlmostEqual(result['draw_rate'], 1 / 4)
|
||||
self.assertAlmostEqual(result['btts_rate'], 2 / 4)
|
||||
self.assertAlmostEqual(result['over25_rate'], 2 / 4)
|
||||
|
||||
def test_returns_defaults_on_db_error(self):
|
||||
cur = _make_cursor(side_effect=Exception('connection lost'))
|
||||
result = self.svc.compute_h2h(cur, 'home1', 'away1', self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_H2H)
|
||||
|
||||
|
||||
class TestComputeFormStreaks(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.svc = FeatureEnrichmentService()
|
||||
self.ts = 1700000000000
|
||||
|
||||
def test_returns_defaults_when_no_team_id(self):
|
||||
result = self.svc.compute_form_streaks(MagicMock(), '', self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_FORM)
|
||||
|
||||
def test_calculates_streaks_correctly(self):
|
||||
"""Most recent first: W, W, D, L → winning_streak=2, unbeaten_streak=3."""
|
||||
rows = [
|
||||
{'home_team_id': 'team1', 'away_team_id': 'x', 'score_home': 2, 'score_away': 0}, # W (clean sheet, scored)
|
||||
{'home_team_id': 'team1', 'away_team_id': 'x', 'score_home': 1, 'score_away': 0}, # W (clean sheet, scored)
|
||||
{'home_team_id': 'x', 'away_team_id': 'team1', 'score_home': 1, 'score_away': 1}, # D (scored, conceded)
|
||||
{'home_team_id': 'team1', 'away_team_id': 'x', 'score_home': 0, 'score_away': 2}, # L (not scored, conceded)
|
||||
]
|
||||
cur = _make_cursor(rows)
|
||||
result = self.svc.compute_form_streaks(cur, 'team1', self.ts)
|
||||
|
||||
self.assertEqual(result['winning_streak'], 2)
|
||||
self.assertEqual(result['unbeaten_streak'], 3)
|
||||
self.assertAlmostEqual(result['clean_sheet_rate'], 2 / 4)
|
||||
self.assertAlmostEqual(result['scoring_rate'], 3 / 4)
|
||||
|
||||
def test_all_losses(self):
|
||||
rows = [
|
||||
{'home_team_id': 'team1', 'away_team_id': 'x', 'score_home': 0, 'score_away': 1},
|
||||
{'home_team_id': 'team1', 'away_team_id': 'x', 'score_home': 0, 'score_away': 3},
|
||||
]
|
||||
cur = _make_cursor(rows)
|
||||
result = self.svc.compute_form_streaks(cur, 'team1', self.ts)
|
||||
|
||||
self.assertEqual(result['winning_streak'], 0)
|
||||
self.assertEqual(result['unbeaten_streak'], 0)
|
||||
self.assertAlmostEqual(result['scoring_rate'], 0.0)
|
||||
|
||||
|
||||
class TestComputeRefereeStats(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.svc = FeatureEnrichmentService()
|
||||
self.ts = 1700000000000
|
||||
|
||||
def test_returns_defaults_when_no_name(self):
|
||||
result = self.svc.compute_referee_stats(MagicMock(), None, self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_REFEREE)
|
||||
|
||||
def test_calculates_referee_tendencies(self):
|
||||
match_rows = [
|
||||
{'home_team_id': 'h1', 'score_home': 2, 'score_away': 0, 'match_id': 'm1'}, # home win
|
||||
{'home_team_id': 'h2', 'score_home': 1, 'score_away': 1, 'match_id': 'm2'}, # draw
|
||||
]
|
||||
card_row = {'yellows': 6, 'total_cards': 8}
|
||||
|
||||
cur = MagicMock()
|
||||
# First execute (match query) → match_rows
|
||||
# Second execute (card query) → card_row
|
||||
cur.fetchall.return_value = match_rows
|
||||
cur.fetchone.return_value = card_row
|
||||
|
||||
result = self.svc.compute_referee_stats(cur, 'Ref Name', self.ts)
|
||||
|
||||
self.assertEqual(result['experience'], 2)
|
||||
self.assertAlmostEqual(result['avg_goals'], (2 + 0 + 1 + 1) / 2)
|
||||
# home_bias = (1/2) - 0.46 = 0.04
|
||||
self.assertAlmostEqual(result['home_bias'], 0.04, places=4)
|
||||
self.assertAlmostEqual(result['avg_yellow'], 6 / 2)
|
||||
self.assertAlmostEqual(result['cards_total'], 8 / 2)
|
||||
|
||||
def test_returns_defaults_on_db_error(self):
|
||||
cur = _make_cursor(side_effect=Exception('timeout'))
|
||||
result = self.svc.compute_referee_stats(cur, 'Some Ref', self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_REFEREE)
|
||||
|
||||
|
||||
class TestComputeLeagueAverages(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.svc = FeatureEnrichmentService()
|
||||
self.ts = 1700000000000
|
||||
|
||||
def test_returns_defaults_when_no_league_id(self):
|
||||
result = self.svc.compute_league_averages(MagicMock(), None, self.ts)
|
||||
self.assertEqual(result, FeatureEnrichmentService._DEFAULT_LEAGUE)
|
||||
|
||||
def test_calculates_league_averages(self):
|
||||
rows = [
|
||||
{'score_home': 1, 'score_away': 1}, # 2 goals
|
||||
{'score_home': 0, 'score_away': 0}, # 0 goals (zero-goal match)
|
||||
{'score_home': 3, 'score_away': 2}, # 5 goals
|
||||
]
|
||||
cur = _make_cursor(rows)
|
||||
result = self.svc.compute_league_averages(cur, 'league1', self.ts)
|
||||
|
||||
self.assertAlmostEqual(result['avg_goals'], 7 / 3, places=4)
|
||||
self.assertAlmostEqual(result['zero_goal_rate'], 1 / 3, places=4)
|
||||
|
||||
|
||||
class TestComputeMomentum(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.svc = FeatureEnrichmentService()
|
||||
self.ts = 1700000000000
|
||||
|
||||
def test_returns_zero_when_no_team_id(self):
|
||||
result = self.svc.compute_momentum(MagicMock(), '', self.ts)
|
||||
self.assertEqual(result, 0.0)
|
||||
|
||||
def test_returns_zero_when_no_rows(self):
|
||||
cur = _make_cursor(rows=[])
|
||||
result = self.svc.compute_momentum(cur, 'team1', self.ts)
|
||||
self.assertEqual(result, 0.0)
|
||||
|
||||
def test_all_wins_returns_one(self):
|
||||
"""All wins → momentum = 1.0 (max possible)."""
|
||||
rows = [
|
||||
{'home_team_id': 'team1', 'score_home': 3, 'score_away': 0},
|
||||
{'home_team_id': 'team1', 'score_home': 2, 'score_away': 1},
|
||||
]
|
||||
cur = _make_cursor(rows)
|
||||
result = self.svc.compute_momentum(cur, 'team1', self.ts)
|
||||
self.assertAlmostEqual(result, 1.0, places=4)
|
||||
|
||||
def test_all_losses_returns_negative(self):
|
||||
"""All losses → negative momentum."""
|
||||
rows = [
|
||||
{'home_team_id': 'team1', 'score_home': 0, 'score_away': 2},
|
||||
{'home_team_id': 'team1', 'score_home': 1, 'score_away': 3},
|
||||
]
|
||||
cur = _make_cursor(rows)
|
||||
result = self.svc.compute_momentum(cur, 'team1', self.ts)
|
||||
self.assertLess(result, 0.0)
|
||||
|
||||
def test_mixed_results(self):
|
||||
"""W, D, L → weighted score between -1 and 1."""
|
||||
rows = [
|
||||
{'home_team_id': 'team1', 'score_home': 1, 'score_away': 0}, # W (weight=3)
|
||||
{'home_team_id': 'x', 'away_team_id': 'team1', 'score_home': 0, 'score_away': 0}, # D (weight=2)
|
||||
{'home_team_id': 'team1', 'score_home': 0, 'score_away': 1}, # L (weight=1)
|
||||
]
|
||||
cur = _make_cursor(rows)
|
||||
result = self.svc.compute_momentum(cur, 'team1', self.ts)
|
||||
|
||||
# weighted = 3*3 + 1*2 + (-1)*1 = 9+2-1 = 10
|
||||
# max_possible = 3*3 + 3*2 + 3*1 = 18
|
||||
# normalised = 10/18 ≈ 0.5556
|
||||
self.assertAlmostEqual(result, round(10 / 18, 4), places=4)
|
||||
|
||||
def test_returns_zero_on_db_error(self):
|
||||
cur = _make_cursor(side_effect=Exception('broken pipe'))
|
||||
result = self.svc.compute_momentum(cur, 'team1', self.ts)
|
||||
self.assertEqual(result, 0.0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
AI_ENGINE_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(AI_ENGINE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(AI_ENGINE_ROOT))
|
||||
|
||||
import main as ai_main
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
class MainApiFunctionTests(unittest.TestCase):
|
||||
def test_analyze_match_v20plus_returns_payload(self):
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.analyze_match.return_value = {"match_info": {"match_id": "m1"}}
|
||||
|
||||
with patch("main.get_single_match_orchestrator", return_value=orchestrator):
|
||||
result = _run(ai_main.analyze_match_v20plus("m1"))
|
||||
|
||||
self.assertEqual(result["match_info"]["match_id"], "m1")
|
||||
|
||||
def test_analyze_match_v20plus_raises_404(self):
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.analyze_match.return_value = None
|
||||
|
||||
with patch("main.get_single_match_orchestrator", return_value=orchestrator):
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
_run(ai_main.analyze_match_v20plus("missing"))
|
||||
|
||||
self.assertEqual(ctx.exception.status_code, 404)
|
||||
|
||||
def test_analyze_match_htms_v20plus_returns_payload(self):
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.analyze_match_htms.return_value = {
|
||||
"status": "ok",
|
||||
"engine_used": "v20plus_top_htms",
|
||||
}
|
||||
|
||||
with patch("main.get_single_match_orchestrator", return_value=orchestrator):
|
||||
result = _run(ai_main.analyze_match_htms_v20plus("m1"))
|
||||
|
||||
self.assertEqual(result["status"], "ok")
|
||||
self.assertEqual(result["engine_used"], "v20plus_top_htms")
|
||||
|
||||
def test_analyze_match_htft_timeout_validation(self):
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
_run(ai_main.analyze_match_htft_v20plus("m1", timeout_sec=2))
|
||||
|
||||
self.assertEqual(ctx.exception.status_code, 400)
|
||||
|
||||
def test_generate_coupon_v20plus_forwards_payload(self):
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.build_coupon.return_value = {"bets": []}
|
||||
|
||||
request = ai_main.CouponRequest(
|
||||
match_ids=["m1", "m2"],
|
||||
strategy="SAFE",
|
||||
max_matches=3,
|
||||
min_confidence=70,
|
||||
)
|
||||
|
||||
with patch("main.get_single_match_orchestrator", return_value=orchestrator):
|
||||
result = _run(ai_main.generate_coupon_v20plus(request))
|
||||
|
||||
self.assertEqual(result, {"bets": []})
|
||||
orchestrator.build_coupon.assert_called_once_with(
|
||||
match_ids=["m1", "m2"],
|
||||
strategy="SAFE",
|
||||
max_matches=3,
|
||||
min_confidence=70.0,
|
||||
)
|
||||
|
||||
def test_reversal_watchlist_validation(self):
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
_run(ai_main.get_reversal_watchlist_v20plus(count=0))
|
||||
self.assertEqual(ctx.exception.status_code, 400)
|
||||
|
||||
def test_reversal_watchlist_forwards_payload(self):
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.get_reversal_watchlist.return_value = {"watchlist": []}
|
||||
|
||||
with patch("main.get_single_match_orchestrator", return_value=orchestrator):
|
||||
result = _run(
|
||||
ai_main.get_reversal_watchlist_v20plus(
|
||||
count=12,
|
||||
horizon_hours=48,
|
||||
min_score=50.5,
|
||||
top_leagues_only=True,
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"watchlist": []})
|
||||
orchestrator.get_reversal_watchlist.assert_called_once_with(
|
||||
count=12,
|
||||
horizon_hours=48,
|
||||
min_score=50.5,
|
||||
top_leagues_only=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+766
@@ -0,0 +1,766 @@
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
AI_ENGINE_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(AI_ENGINE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(AI_ENGINE_ROOT))
|
||||
|
||||
from models.v20_ensemble import FullMatchPrediction
|
||||
from models.basketball_v25 import BasketballMatchPrediction
|
||||
from services.single_match_orchestrator import MatchData, SingleMatchOrchestrator
|
||||
|
||||
|
||||
class _CursorContext:
|
||||
def __init__(self, cursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self):
|
||||
return self._cursor
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class _ConnContext:
|
||||
def __init__(self, cursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def cursor(self, cursor_factory=None):
|
||||
return _CursorContext(self._cursor)
|
||||
|
||||
|
||||
class _StaticFetchAllCursor:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
self.executed = []
|
||||
|
||||
def execute(self, query, params=None):
|
||||
self.executed.append((query, params))
|
||||
|
||||
def fetchall(self):
|
||||
return list(self.rows)
|
||||
|
||||
|
||||
class _RouterCursor:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
live_row=None,
|
||||
hist_row=None,
|
||||
relational_rows=None,
|
||||
participation_rows=None,
|
||||
probable_rows=None,
|
||||
):
|
||||
self.live_row = live_row
|
||||
self.hist_row = hist_row
|
||||
self.relational_rows = relational_rows or []
|
||||
self.participation_rows = participation_rows or []
|
||||
self.probable_rows = probable_rows or []
|
||||
self.last_query = ""
|
||||
|
||||
def execute(self, query, params=None):
|
||||
self.last_query = query
|
||||
|
||||
def fetchone(self):
|
||||
if "FROM live_matches" in self.last_query:
|
||||
return self.live_row
|
||||
if "FROM matches m" in self.last_query:
|
||||
return self.hist_row
|
||||
return None
|
||||
|
||||
def fetchall(self):
|
||||
if "FROM odd_categories" in self.last_query:
|
||||
return list(self.relational_rows)
|
||||
if "FROM match_player_participation" in self.last_query and "GROUP BY" not in self.last_query:
|
||||
return list(self.participation_rows)
|
||||
if "GROUP BY mpp.player_id" in self.last_query:
|
||||
return list(self.probable_rows)
|
||||
return []
|
||||
|
||||
|
||||
def _build_orchestrator() -> SingleMatchOrchestrator:
|
||||
orchestrator = SingleMatchOrchestrator.__new__(SingleMatchOrchestrator)
|
||||
orchestrator.v25_predictor = MagicMock()
|
||||
orchestrator.basketball_predictor = MagicMock()
|
||||
orchestrator.dsn = "postgresql://unit-test"
|
||||
orchestrator.league_reliability = {}
|
||||
orchestrator.market_calibration = {
|
||||
"MS": 0.82,
|
||||
"DC": 0.93,
|
||||
"OU15": 0.90,
|
||||
"OU25": 0.85,
|
||||
"OU35": 0.88,
|
||||
"BTTS": 0.83,
|
||||
"HT": 0.80,
|
||||
"HT_OU05": 0.88,
|
||||
}
|
||||
orchestrator.market_min_conf = {
|
||||
"MS": 52.0,
|
||||
"DC": 56.0,
|
||||
"OU15": 60.0,
|
||||
"OU25": 58.0,
|
||||
"OU35": 54.0,
|
||||
"BTTS": 57.0,
|
||||
"HT": 53.0,
|
||||
"HT_OU05": 55.0,
|
||||
}
|
||||
orchestrator.market_min_play_score = {
|
||||
"MS": 72.0,
|
||||
"DC": 62.0,
|
||||
"OU15": 64.0,
|
||||
"OU25": 70.0,
|
||||
"OU35": 76.0,
|
||||
"BTTS": 70.0,
|
||||
"HT": 74.0,
|
||||
"HT_OU05": 64.0,
|
||||
}
|
||||
orchestrator.market_min_edge = {
|
||||
"MS": 0.03,
|
||||
"DC": 0.01,
|
||||
"OU15": 0.01,
|
||||
"OU25": 0.02,
|
||||
"OU35": 0.04,
|
||||
"BTTS": 0.03,
|
||||
"HT": 0.04,
|
||||
"HT_OU05": 0.01,
|
||||
}
|
||||
return orchestrator
|
||||
|
||||
|
||||
class SingleMatchOrchestratorTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.orchestrator = _build_orchestrator()
|
||||
|
||||
def test_parse_odds_json_uses_exact_market_match_and_ignores_collisions(self):
|
||||
odds_json = {
|
||||
"Maç Sonucu": {"1": "2.15", "X": "3.20", "2": "3.30"},
|
||||
"İlk Yarı/Maç Sonucu": {"1/1": "4.30"},
|
||||
"2,5 Alt/Üst": {"Üst": "1.85", "Alt": "1.95"},
|
||||
"İY 0,5 Alt/Üst": {"Üst": "1.49", "Alt": "2.20"},
|
||||
"1. Yarı Ev Sahibi 0,5 Alt/Üst": {"Üst": "1.99", "Alt": "1.45"},
|
||||
"2,5 Kart Puanı Alt/Üst": {"Üst": "1.33", "Alt": "2.95"},
|
||||
"Karşılıklı Gol": {"Var": "1.75", "Yok": "2.05"},
|
||||
"1. Yarı Karşılıklı Gol": {"Var": "2.10", "Yok": "1.60"},
|
||||
"Çifte Şans": {"1-X": "1.33", "X-2": "1.62", "1-2": "1.30"},
|
||||
"1. Yarı Sonucu": {"1": "2.45", "X": "2.00", "2": "3.80"},
|
||||
}
|
||||
|
||||
parsed = self.orchestrator._parse_odds_json(odds_json)
|
||||
|
||||
self.assertEqual(parsed["ms_h"], 2.15)
|
||||
self.assertEqual(parsed["ms_d"], 3.20)
|
||||
self.assertEqual(parsed["ms_a"], 3.30)
|
||||
self.assertEqual(parsed["ou25_o"], 1.85)
|
||||
self.assertEqual(parsed["ou25_u"], 1.95)
|
||||
self.assertEqual(parsed["btts_y"], 1.75)
|
||||
self.assertEqual(parsed["btts_n"], 2.05)
|
||||
self.assertEqual(parsed["dc_1x"], 1.33)
|
||||
self.assertEqual(parsed["dc_x2"], 1.62)
|
||||
self.assertEqual(parsed["dc_12"], 1.30)
|
||||
self.assertEqual(parsed["ht_h"], 2.45)
|
||||
self.assertEqual(parsed["ht_d"], 2.00)
|
||||
self.assertEqual(parsed["ht_a"], 3.80)
|
||||
self.assertEqual(parsed["ht_ou05_o"], 1.49)
|
||||
self.assertEqual(parsed["ht_ou05_u"], 2.20)
|
||||
self.assertEqual(parsed["htft_11"], 4.30)
|
||||
|
||||
def test_parse_odds_json_accepts_selection_variants(self):
|
||||
odds_json = {
|
||||
"2,5 Alt/Üst": {"2,5 Üst": "1.91", "2,5 Alt": "1.86"},
|
||||
"Karşılıklı Gol": {"YES": "1.82", "NO": "1.96"},
|
||||
"Çifte Şans": {"1X": "1.28", "X2": "1.44", "12": "1.32"},
|
||||
}
|
||||
|
||||
parsed = self.orchestrator._parse_odds_json(odds_json)
|
||||
|
||||
self.assertEqual(parsed["ou25_o"], 1.91)
|
||||
self.assertEqual(parsed["ou25_u"], 1.86)
|
||||
self.assertEqual(parsed["btts_y"], 1.82)
|
||||
self.assertEqual(parsed["btts_n"], 1.96)
|
||||
self.assertEqual(parsed["dc_1x"], 1.28)
|
||||
self.assertEqual(parsed["dc_x2"], 1.44)
|
||||
self.assertEqual(parsed["dc_12"], 1.32)
|
||||
|
||||
def test_parse_odds_json_maps_all_football_markets_with_noise(self):
|
||||
odds_json = {
|
||||
"Maç Sonucu": {"1": "2.31", "X": "3.22", "2": "3.05"},
|
||||
"Çifte Şans": {"1-X": "1.34", "X-2": "1.52", "1-2": "1.28"},
|
||||
"1,5 Alt/Üst": {"Üst": "1.29", "Alt": "3.45"},
|
||||
"2,5 Alt/Üst": {"Üst": "1.71", "Alt": "2.05"},
|
||||
"3,5 Alt/Üst": {"Üst": "2.62", "Alt": "1.41"},
|
||||
"Karşılıklı Gol": {"Var": "1.66", "Yok": "2.11"},
|
||||
"1. Yarı Sonucu": {"1": "3.10", "X": "1.95", "2": "4.60"},
|
||||
"1. Yarı 0,5 Alt/Üst": {"Üst": "1.21", "Alt": "2.72"},
|
||||
# noise categories that must not overwrite football main markets
|
||||
"1. Yarı Ev Sahibi 0,5 Alt/Üst": {"Üst": "1.99", "Alt": "1.45"},
|
||||
"1. Yarı Deplasman 0,5 Alt/Üst": {"Üst": "1.73", "Alt": "1.63"},
|
||||
"1.Yarı 3,5 Korner Alt/Üst": {"Üst": "1.26", "Alt": "2.30"},
|
||||
"2,5 Kart Puanı Alt/Üst": {"Üst": "1.40", "Alt": "2.60"},
|
||||
}
|
||||
|
||||
parsed = self.orchestrator._parse_odds_json(odds_json)
|
||||
|
||||
self.assertEqual(parsed["ms_h"], 2.31)
|
||||
self.assertEqual(parsed["ms_d"], 3.22)
|
||||
self.assertEqual(parsed["ms_a"], 3.05)
|
||||
self.assertEqual(parsed["dc_1x"], 1.34)
|
||||
self.assertEqual(parsed["dc_x2"], 1.52)
|
||||
self.assertEqual(parsed["dc_12"], 1.28)
|
||||
self.assertEqual(parsed["ou15_o"], 1.29)
|
||||
self.assertEqual(parsed["ou15_u"], 3.45)
|
||||
self.assertEqual(parsed["ou25_o"], 1.71)
|
||||
self.assertEqual(parsed["ou25_u"], 2.05)
|
||||
self.assertEqual(parsed["ou35_o"], 2.62)
|
||||
self.assertEqual(parsed["ou35_u"], 1.41)
|
||||
self.assertEqual(parsed["btts_y"], 1.66)
|
||||
self.assertEqual(parsed["btts_n"], 2.11)
|
||||
self.assertEqual(parsed["ht_h"], 3.10)
|
||||
self.assertEqual(parsed["ht_d"], 1.95)
|
||||
self.assertEqual(parsed["ht_a"], 4.60)
|
||||
self.assertEqual(parsed["ht_ou05_o"], 1.21)
|
||||
self.assertEqual(parsed["ht_ou05_u"], 2.72)
|
||||
|
||||
def test_v25_market_odds_ignores_synthetic_default_when_selection_missing(self):
|
||||
odds_json = {
|
||||
"1,5 Alt/Üst": {"Alt": 5.70},
|
||||
"Çifte Şans": {"1-X": 1.30, "X-2": 1.38, "1-2": 1.09},
|
||||
}
|
||||
|
||||
parsed = self.orchestrator._parse_odds_json(odds_json)
|
||||
|
||||
self.assertEqual(parsed["ou15_o"], 0.0)
|
||||
self.assertEqual(
|
||||
self.orchestrator._v25_market_odds(parsed, "OU15", "Over"),
|
||||
1.0,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.orchestrator._v25_market_odds(parsed, "OU15", "Under"),
|
||||
5.7,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.orchestrator._v25_market_odds(parsed, "DC", "X2"),
|
||||
1.38,
|
||||
)
|
||||
|
||||
def test_parse_odds_json_extracts_basketball_ml_total_spread(self):
|
||||
odds_json = {
|
||||
"Maç Sonucu (Uzt. Dahil)": {"1": "1.74", "2": "2.08"},
|
||||
"Alt/Üst (163,5)": {"Üst": "1.86", "Alt": "1.94"},
|
||||
"1. Yarı Alt/Üst (81,5)": {"Üst": "1.89", "Alt": "1.91"},
|
||||
"1. Yarı Alt/Üst (100,5)": {"Üst": "1.83", "Alt": "1.97"},
|
||||
"Hnd. MS (0:5,5)": {"1": "1.91", "+5.5h": "1.87"},
|
||||
}
|
||||
|
||||
parsed = self.orchestrator._parse_odds_json(odds_json)
|
||||
|
||||
self.assertEqual(parsed["ml_h"], 1.74)
|
||||
self.assertEqual(parsed["ml_a"], 2.08)
|
||||
self.assertEqual(parsed["tot_line"], 163.5)
|
||||
self.assertEqual(parsed["tot_o"], 1.86)
|
||||
self.assertEqual(parsed["tot_u"], 1.94)
|
||||
self.assertEqual(parsed["spread_home_line"], -5.5)
|
||||
self.assertEqual(parsed["spread_h"], 1.91)
|
||||
self.assertEqual(parsed["spread_a"], 1.87)
|
||||
self.assertNotIn("ht_ou05_o", parsed)
|
||||
self.assertNotIn("ht_ou05_u", parsed)
|
||||
|
||||
def test_extract_odds_merges_relational_when_live_json_is_incomplete(self):
|
||||
row = {
|
||||
"match_id": "m-1",
|
||||
"odds": {"Maç Sonucu": {"1": 2.10, "X": 3.20, "2": 3.35}},
|
||||
}
|
||||
relational_rows = [
|
||||
{"category_name": "Çifte Şans", "selection_name": "1-X", "odd_value": 1.28},
|
||||
{"category_name": "Çifte Şans", "selection_name": "X-2", "odd_value": 1.44},
|
||||
{"category_name": "Çifte Şans", "selection_name": "1-2", "odd_value": 1.31},
|
||||
{"category_name": "2,5 Alt/Üst", "selection_name": "Üst", "odd_value": 1.89},
|
||||
{"category_name": "2,5 Alt/Üst", "selection_name": "Alt", "odd_value": 1.94},
|
||||
{"category_name": "Karşılıklı Gol", "selection_name": "Var", "odd_value": 1.77},
|
||||
{"category_name": "Karşılıklı Gol", "selection_name": "Yok", "odd_value": 2.02},
|
||||
{"category_name": "1. Yarı Sonucu", "selection_name": "1", "odd_value": 2.55},
|
||||
{"category_name": "1. Yarı Sonucu", "selection_name": "X", "odd_value": 1.98},
|
||||
{"category_name": "1. Yarı Sonucu", "selection_name": "2", "odd_value": 3.40},
|
||||
]
|
||||
cur = _StaticFetchAllCursor(relational_rows)
|
||||
|
||||
odds = self.orchestrator._extract_odds(cur, row)
|
||||
|
||||
self.assertEqual(odds["ms_h"], 2.10)
|
||||
self.assertEqual(odds["ms_d"], 3.20)
|
||||
self.assertEqual(odds["ms_a"], 3.35)
|
||||
self.assertEqual(odds["dc_x2"], 1.44)
|
||||
self.assertEqual(odds["ou25_o"], 1.89)
|
||||
self.assertEqual(odds["btts_y"], 1.77)
|
||||
self.assertEqual(odds["ht_d"], 1.98)
|
||||
self.assertEqual(len(cur.executed), 1)
|
||||
|
||||
def test_extract_odds_fills_default_ms_when_no_source_available(self):
|
||||
row = {"match_id": "m-2", "odds": None}
|
||||
cur = _StaticFetchAllCursor([])
|
||||
|
||||
odds = self.orchestrator._extract_odds(cur, row)
|
||||
|
||||
self.assertEqual(odds["ms_h"], SingleMatchOrchestrator.DEFAULT_MS_H)
|
||||
self.assertEqual(odds["ms_d"], SingleMatchOrchestrator.DEFAULT_MS_D)
|
||||
self.assertEqual(odds["ms_a"], SingleMatchOrchestrator.DEFAULT_MS_A)
|
||||
|
||||
def test_parse_lineups_json_supports_id_playerid_personid(self):
|
||||
lineups = {
|
||||
"home": {
|
||||
"xi": [
|
||||
{"id": "11"},
|
||||
{"playerId": "12"},
|
||||
],
|
||||
},
|
||||
"away": {
|
||||
"starting": [
|
||||
{"personId": "21"},
|
||||
"22",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
home, away = self.orchestrator._parse_lineups_json(lineups)
|
||||
|
||||
self.assertEqual(home, ["11", "12"])
|
||||
self.assertEqual(away, ["21", "22"])
|
||||
|
||||
def test_extract_lineups_uses_participation_and_probable_xi_fallbacks(self):
|
||||
row = {
|
||||
"match_id": "m-3",
|
||||
"home_team_id": "h1",
|
||||
"away_team_id": "a1",
|
||||
"match_date_ms": 1700000000000,
|
||||
"lineups": {
|
||||
"home": {"xi": [{"personId": "h-live-1"}]},
|
||||
"away": {},
|
||||
},
|
||||
}
|
||||
participation = [
|
||||
{"team_id": "a1", "player_id": "a-db-1"},
|
||||
{"team_id": "a1", "player_id": "a-db-2"},
|
||||
]
|
||||
cur = _StaticFetchAllCursor(participation)
|
||||
|
||||
with patch.object(
|
||||
self.orchestrator,
|
||||
"_build_probable_xi",
|
||||
side_effect=[["h-prob-1"], ["a-prob-1"]],
|
||||
) as probable_xi:
|
||||
home, away, source = self.orchestrator._extract_lineups(cur, row)
|
||||
|
||||
self.assertEqual(home, ["h-live-1"])
|
||||
self.assertEqual(away, ["a-db-1", "a-db-2"])
|
||||
self.assertEqual(source, "none")
|
||||
probable_xi.assert_not_called()
|
||||
|
||||
def test_extract_lineups_falls_back_to_probable_xi_when_live_and_participation_missing(self):
|
||||
row = {
|
||||
"match_id": "m-4",
|
||||
"home_team_id": "h2",
|
||||
"away_team_id": "a2",
|
||||
"match_date_ms": 1700000000000,
|
||||
"lineups": None,
|
||||
}
|
||||
cur = _StaticFetchAllCursor([])
|
||||
|
||||
with patch.object(
|
||||
self.orchestrator,
|
||||
"_build_probable_xi",
|
||||
side_effect=[["h-prob-1", "h-prob-2"], ["a-prob-1"]],
|
||||
) as probable_xi:
|
||||
home, away, source = self.orchestrator._extract_lineups(cur, row)
|
||||
|
||||
self.assertEqual(home, ["h-prob-1", "h-prob-2"])
|
||||
self.assertEqual(away, ["a-prob-1"])
|
||||
self.assertEqual(source, "probable_xi")
|
||||
self.assertEqual(probable_xi.call_count, 2)
|
||||
|
||||
def test_load_match_data_parses_live_row_json_and_sidelined(self):
|
||||
odds_payload = {
|
||||
"Maç Sonucu": {"1": 2.10, "X": 3.30, "2": 3.50},
|
||||
"Çifte Şans": {"1-X": 1.30, "X-2": 1.52, "1-2": 1.34},
|
||||
"1,5 Alt/Üst": {"Üst": 1.33, "Alt": 2.90},
|
||||
"2,5 Alt/Üst": {"Üst": 1.91, "Alt": 1.85},
|
||||
"3,5 Alt/Üst": {"Üst": 2.95, "Alt": 1.38},
|
||||
"Karşılıklı Gol": {"Var": 1.84, "Yok": 1.92},
|
||||
"1. Yarı Sonucu": {"1": 2.55, "X": 1.97, "2": 3.45},
|
||||
}
|
||||
lineups_payload = {
|
||||
"home": {"xi": [{"personId": "101"}, {"personId": "102"}]},
|
||||
"away": {"xi": [{"personId": "201"}, {"personId": "202"}]},
|
||||
}
|
||||
live_row = {
|
||||
"match_id": "live-101",
|
||||
"home_team_id": "h-101",
|
||||
"away_team_id": "a-101",
|
||||
"league_id": "l-101",
|
||||
"sport": "FOOTBALL",
|
||||
"match_date_ms": 1760000000000,
|
||||
"odds": json.dumps(odds_payload),
|
||||
"lineups": json.dumps(lineups_payload),
|
||||
"sidelined": json.dumps(
|
||||
{
|
||||
"homeTeam": {"totalSidelined": 1, "players": []},
|
||||
"awayTeam": {"totalSidelined": 0, "players": []},
|
||||
}
|
||||
),
|
||||
"referee_name": "John Ref",
|
||||
"home_team_name": "Home FC",
|
||||
"away_team_name": "Away FC",
|
||||
"league_name": "League Name",
|
||||
}
|
||||
cursor = _RouterCursor(live_row=live_row)
|
||||
|
||||
with patch("services.single_match_orchestrator.psycopg2.connect", return_value=_ConnContext(cursor)):
|
||||
data = self.orchestrator._load_match_data("live-101")
|
||||
|
||||
self.assertIsNotNone(data)
|
||||
self.assertEqual(data.match_id, "live-101")
|
||||
self.assertEqual(data.home_team_id, "h-101")
|
||||
self.assertEqual(data.away_team_id, "a-101")
|
||||
self.assertEqual(data.sport, "football")
|
||||
self.assertEqual(data.referee_name, "John Ref")
|
||||
self.assertEqual(data.home_lineup, ["101", "102"])
|
||||
self.assertEqual(data.away_lineup, ["201", "202"])
|
||||
self.assertEqual(data.lineup_source, "none")
|
||||
self.assertEqual(data.sidelined_data["homeTeam"]["totalSidelined"], 1)
|
||||
self.assertEqual(data.odds_data["dc_x2"], 1.52)
|
||||
self.assertEqual(data.odds_data["ht_h"], 2.55)
|
||||
|
||||
def test_analyze_match_forwards_all_core_fields_to_predictor(self):
|
||||
match_data = MatchData(
|
||||
match_id="live-55",
|
||||
home_team_id="home-55",
|
||||
away_team_id="away-55",
|
||||
home_team_name="Home 55",
|
||||
away_team_name="Away 55",
|
||||
match_date_ms=1760000000000,
|
||||
sport="football",
|
||||
league_id="league-55",
|
||||
league_name="League 55",
|
||||
referee_name="Ref 55",
|
||||
odds_data={"ms_h": 2.4, "ms_d": 3.1, "ms_a": 2.9},
|
||||
home_lineup=["h1", "h2"],
|
||||
away_lineup=["a1", "a2"],
|
||||
sidelined_data={
|
||||
"homeTeam": {"totalSidelined": 2, "players": []},
|
||||
"awayTeam": {"totalSidelined": 1, "players": []},
|
||||
},
|
||||
home_goals_avg=1.6,
|
||||
home_conceded_avg=1.1,
|
||||
away_goals_avg=1.2,
|
||||
away_conceded_avg=1.4,
|
||||
home_position=5,
|
||||
away_position=8,
|
||||
lineup_source="confirmed_live",
|
||||
)
|
||||
prediction = FullMatchPrediction(match_id="live-55", home_team="Home 55", away_team="Away 55")
|
||||
|
||||
self.orchestrator._load_match_data = MagicMock(return_value=match_data)
|
||||
self.orchestrator.v25_predictor.predict_market_bundle = MagicMock(return_value={"MS": {"pick": "1"}})
|
||||
self.orchestrator._build_v25_features = MagicMock(return_value={})
|
||||
self.orchestrator._get_v25_signal = MagicMock(return_value={"MS": {"pick": "1"}})
|
||||
self.orchestrator._build_v25_prediction = MagicMock(return_value=prediction)
|
||||
self.orchestrator._build_prediction_package = MagicMock(return_value={"ok": True})
|
||||
|
||||
result = self.orchestrator.analyze_match("live-55")
|
||||
|
||||
self.assertEqual(result, {"ok": True})
|
||||
self.orchestrator._build_v25_features.assert_called_once_with(match_data)
|
||||
self.orchestrator._get_v25_signal.assert_called_once_with(match_data, {})
|
||||
self.orchestrator._build_v25_prediction.assert_called_once_with(
|
||||
match_data,
|
||||
{},
|
||||
{"MS": {"pick": "1"}},
|
||||
)
|
||||
|
||||
def test_analyze_match_routes_basketball_to_basketball_predictor(self):
|
||||
match_data = MatchData(
|
||||
match_id="b-live-1",
|
||||
home_team_id="bh",
|
||||
away_team_id="ba",
|
||||
home_team_name="Home B",
|
||||
away_team_name="Away B",
|
||||
match_date_ms=1760000000000,
|
||||
sport="basketball",
|
||||
league_id="bleague",
|
||||
league_name="B League",
|
||||
referee_name=None,
|
||||
odds_data={"ml_h": 1.75, "ml_a": 2.05, "tot_line": 161.5, "tot_o": 1.88, "tot_u": 1.92},
|
||||
home_lineup=None,
|
||||
away_lineup=None,
|
||||
sidelined_data={"homeTeam": {"totalSidelined": 1}, "awayTeam": {"totalSidelined": 0}},
|
||||
home_goals_avg=85.0,
|
||||
home_conceded_avg=79.0,
|
||||
away_goals_avg=82.0,
|
||||
away_conceded_avg=81.0,
|
||||
home_position=4,
|
||||
away_position=7,
|
||||
lineup_source="none",
|
||||
)
|
||||
prediction = BasketballMatchPrediction(
|
||||
match_id="b-live-1",
|
||||
home_team_name="Home B",
|
||||
away_team_name="Away B",
|
||||
league_name="B League",
|
||||
)
|
||||
|
||||
self.orchestrator._load_match_data = MagicMock(return_value=match_data)
|
||||
self.orchestrator.basketball_predictor.predict = MagicMock(return_value=prediction)
|
||||
self.orchestrator._build_basketball_prediction_package = MagicMock(
|
||||
return_value={"sport": "basketball", "ok": True}
|
||||
)
|
||||
|
||||
result = self.orchestrator.analyze_match("b-live-1")
|
||||
|
||||
self.assertEqual(result, {"sport": "basketball", "ok": True})
|
||||
self.orchestrator.basketball_predictor.predict.assert_called_once()
|
||||
kwargs = self.orchestrator.basketball_predictor.predict.call_args.kwargs
|
||||
self.assertEqual(kwargs["match_id"], "b-live-1")
|
||||
self.assertEqual(kwargs["home_team_id"], "bh")
|
||||
self.assertEqual(kwargs["away_team_id"], "ba")
|
||||
self.assertEqual(kwargs["league_id"], "bleague")
|
||||
self.assertEqual(kwargs["odds_data"]["ml_h"], 1.75)
|
||||
self.orchestrator.v25_predictor.predict_market_bundle.assert_not_called()
|
||||
|
||||
def test_build_market_rows_maps_odds_keys_correctly(self):
|
||||
data = MatchData(
|
||||
match_id="m-rows",
|
||||
home_team_id="h",
|
||||
away_team_id="a",
|
||||
home_team_name="Home",
|
||||
away_team_name="Away",
|
||||
match_date_ms=1760000000000,
|
||||
sport="football",
|
||||
league_id=None,
|
||||
league_name="",
|
||||
referee_name=None,
|
||||
odds_data={
|
||||
"ms_h": 2.3,
|
||||
"ms_d": 3.2,
|
||||
"ms_a": 3.1,
|
||||
"dc_x2": 1.45,
|
||||
"ou15_o": 1.36,
|
||||
"ou25_u": 1.92,
|
||||
"ou35_o": 2.85,
|
||||
"btts_y": 1.88,
|
||||
"ht_h": 2.55,
|
||||
"ht_ou05_o": 1.47,
|
||||
},
|
||||
home_lineup=None,
|
||||
away_lineup=None,
|
||||
sidelined_data=None,
|
||||
home_goals_avg=1.5,
|
||||
home_conceded_avg=1.2,
|
||||
away_goals_avg=1.2,
|
||||
away_conceded_avg=1.4,
|
||||
home_position=10,
|
||||
away_position=10,
|
||||
lineup_source="none",
|
||||
)
|
||||
pred = FullMatchPrediction(
|
||||
match_id="m-rows",
|
||||
home_team="Home",
|
||||
away_team="Away",
|
||||
ms_home_prob=0.25,
|
||||
ms_draw_prob=0.30,
|
||||
ms_away_prob=0.45,
|
||||
ms_pick="2",
|
||||
ms_confidence=69.0,
|
||||
dc_1x_prob=0.60,
|
||||
dc_x2_prob=0.72,
|
||||
dc_12_prob=0.68,
|
||||
dc_pick="X2",
|
||||
dc_confidence=67.0,
|
||||
over_15_prob=0.74,
|
||||
under_15_prob=0.26,
|
||||
ou15_pick="1.5 Üst",
|
||||
ou15_confidence=72.0,
|
||||
over_25_prob=0.44,
|
||||
under_25_prob=0.56,
|
||||
ou25_pick="2.5 Alt",
|
||||
ou25_confidence=61.0,
|
||||
over_35_prob=0.39,
|
||||
under_35_prob=0.61,
|
||||
ou35_pick="3.5 Over",
|
||||
ou35_confidence=58.0,
|
||||
btts_yes_prob=0.57,
|
||||
btts_no_prob=0.43,
|
||||
btts_pick="Yes",
|
||||
btts_confidence=63.0,
|
||||
ht_home_prob=0.41,
|
||||
ht_draw_prob=0.39,
|
||||
ht_away_prob=0.20,
|
||||
ht_pick="1",
|
||||
ht_confidence=60.0,
|
||||
ht_over_05_prob=0.64,
|
||||
ht_under_05_prob=0.36,
|
||||
ht_ou_pick="Over 0.5",
|
||||
)
|
||||
|
||||
rows = self.orchestrator._build_market_rows(data, pred)
|
||||
by_market = {row["market"]: row for row in rows}
|
||||
|
||||
self.assertEqual(by_market["MS"]["odds"], 3.1)
|
||||
self.assertEqual(by_market["DC"]["odds"], 1.45)
|
||||
self.assertEqual(by_market["OU15"]["odds"], 1.36)
|
||||
self.assertEqual(by_market["OU25"]["odds"], 1.92)
|
||||
self.assertEqual(by_market["OU35"]["odds"], 2.85)
|
||||
self.assertEqual(by_market["BTTS"]["odds"], 1.88)
|
||||
self.assertEqual(by_market["HT"]["odds"], 2.55)
|
||||
self.assertEqual(by_market["HT_OU05"]["odds"], 1.47)
|
||||
|
||||
def test_build_basketball_market_rows_maps_odds_keys_correctly(self):
|
||||
data = MatchData(
|
||||
match_id="b-rows",
|
||||
home_team_id="bh",
|
||||
away_team_id="ba",
|
||||
home_team_name="Home B",
|
||||
away_team_name="Away B",
|
||||
match_date_ms=1760000000000,
|
||||
sport="basketball",
|
||||
league_id="bl",
|
||||
league_name="Basketball League",
|
||||
referee_name=None,
|
||||
odds_data={
|
||||
"ml_h": 1.73,
|
||||
"ml_a": 2.10,
|
||||
"tot_line": 162.5,
|
||||
"tot_o": 1.89,
|
||||
"tot_u": 1.93,
|
||||
"spread_home_line": -4.5,
|
||||
"spread_h": 1.91,
|
||||
"spread_a": 1.88,
|
||||
},
|
||||
home_lineup=None,
|
||||
away_lineup=None,
|
||||
sidelined_data=None,
|
||||
home_goals_avg=84.0,
|
||||
home_conceded_avg=80.0,
|
||||
away_goals_avg=82.0,
|
||||
away_conceded_avg=81.0,
|
||||
home_position=5,
|
||||
away_position=8,
|
||||
lineup_source="none",
|
||||
)
|
||||
pred = {
|
||||
"match_id": "b-rows",
|
||||
"market_board": {
|
||||
"ML": {"1": "62%", "2": "38%"},
|
||||
"Totals": {"Under 162.5": "43%", "Over 162.5": "57%"},
|
||||
"Spread": {"Away +4.5": "46%", "Home -4.5": "54%"}
|
||||
}
|
||||
}
|
||||
|
||||
rows = self.orchestrator._build_basketball_market_rows(data, pred)
|
||||
by_market = {row["market"]: row for row in rows}
|
||||
|
||||
self.assertEqual(by_market["ML"]["odds"], 1.73)
|
||||
self.assertEqual(by_market["TOTAL"]["odds"], 1.89)
|
||||
self.assertEqual(by_market["SPREAD"]["odds"], 1.91)
|
||||
|
||||
def test_compute_data_quality_flags_missing_referee_and_lineup(self):
|
||||
data = MatchData(
|
||||
match_id="dq-1",
|
||||
home_team_id="h",
|
||||
away_team_id="a",
|
||||
home_team_name="Home",
|
||||
away_team_name="Away",
|
||||
match_date_ms=1760000000000,
|
||||
sport="football",
|
||||
league_id=None,
|
||||
league_name="",
|
||||
referee_name=None,
|
||||
odds_data={"ms_h": 2.5, "ms_d": 3.2, "ms_a": 2.9},
|
||||
home_lineup=["h1", "h2"],
|
||||
away_lineup=["a1"],
|
||||
sidelined_data=None,
|
||||
home_goals_avg=1.5,
|
||||
home_conceded_avg=1.2,
|
||||
away_goals_avg=1.2,
|
||||
away_conceded_avg=1.4,
|
||||
home_position=10,
|
||||
away_position=10,
|
||||
lineup_source="none",
|
||||
)
|
||||
|
||||
quality = self.orchestrator._compute_data_quality(data)
|
||||
|
||||
self.assertIn("lineup_incomplete", quality["flags"])
|
||||
self.assertIn("missing_referee", quality["flags"])
|
||||
self.assertEqual(quality["label"], "MEDIUM")
|
||||
|
||||
def test_load_match_data_returns_none_when_team_ids_missing(self):
|
||||
live_row = {
|
||||
"match_id": "live-missing-ids",
|
||||
"home_team_id": None,
|
||||
"away_team_id": None,
|
||||
"league_id": "l-1",
|
||||
"sport": "football",
|
||||
"match_date_ms": 1760000000000,
|
||||
"odds": None,
|
||||
"lineups": None,
|
||||
"sidelined": None,
|
||||
"referee_name": None,
|
||||
"home_team_name": "Home",
|
||||
"away_team_name": "Away",
|
||||
"league_name": "League",
|
||||
}
|
||||
cursor = _RouterCursor(live_row=live_row)
|
||||
|
||||
with patch("services.single_match_orchestrator.psycopg2.connect", return_value=_ConnContext(cursor)):
|
||||
data = self.orchestrator._load_match_data("live-missing-ids")
|
||||
|
||||
self.assertIsNone(data)
|
||||
|
||||
def test_decorate_market_row_blocks_required_market_when_odds_missing(self):
|
||||
data = MatchData(
|
||||
match_id="dq-odds",
|
||||
home_team_id="h",
|
||||
away_team_id="a",
|
||||
home_team_name="Home",
|
||||
away_team_name="Away",
|
||||
match_date_ms=1760000000000,
|
||||
sport="football",
|
||||
league_id="l1",
|
||||
league_name="League",
|
||||
referee_name="Ref",
|
||||
odds_data={"ms_h": 2.2, "ms_d": 3.2, "ms_a": 3.0},
|
||||
home_lineup=["h"] * 11,
|
||||
away_lineup=["a"] * 11,
|
||||
sidelined_data=None,
|
||||
home_goals_avg=1.5,
|
||||
home_conceded_avg=1.2,
|
||||
away_goals_avg=1.2,
|
||||
away_conceded_avg=1.4,
|
||||
home_position=7,
|
||||
away_position=9,
|
||||
lineup_source="confirmed_live",
|
||||
)
|
||||
prediction = FullMatchPrediction(match_id="dq-odds", home_team="Home", away_team="Away")
|
||||
quality = self.orchestrator._compute_data_quality(data)
|
||||
row = {
|
||||
"market": "HT_OU05",
|
||||
"pick": "İY 0.5 Üst",
|
||||
"probability": 0.65,
|
||||
"confidence": 66.0,
|
||||
"odds": 0.0,
|
||||
}
|
||||
|
||||
out = self.orchestrator._decorate_market_row(data, prediction, quality, row)
|
||||
self.assertFalse(out["playable"])
|
||||
self.assertIn("market_odds_missing", out["decision_reasons"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Unit Test for NEW Skip Logic in BetRecommender
|
||||
==============================================
|
||||
Run with: python ai-engine/tests/test_skip_logic.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
# Add paths
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
|
||||
|
||||
from core.calculators.bet_recommender import BetRecommender, RecommendationResult, MarketPredictionDTO
|
||||
from core.calculators.risk_assessor import RiskAnalysis
|
||||
from core.calculators.match_result_calculator import MatchResultPrediction
|
||||
from core.calculators.over_under_calculator import OverUnderPrediction
|
||||
from config.config_loader import get_config
|
||||
|
||||
@dataclass
|
||||
class DummyContext:
|
||||
"""Minimal mock for CalculationContext"""
|
||||
odds_data: dict
|
||||
|
||||
class TestSkipLogic(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Mock config to pass into BetRecommender
|
||||
self.mock_config = {
|
||||
"recommendations.market_weights": {"MS": 1.0, "ÇŞ": 0.9, "BTTS": 0.9, "2.5 Üst/Alt": 0.9},
|
||||
"recommendations.safe_markets": ["ÇŞ", "1.5 Üst/Alt"],
|
||||
"recommendations.market_accuracy": {"MS": 65, "ÇŞ": 75, "BTTS": 60, "2.5 Üst/Alt": 65},
|
||||
"recommendations.baseline_accuracy": 65.0,
|
||||
"recommendations.confidence_threshold": 60,
|
||||
"recommendations.value_confidence_min": 45,
|
||||
"recommendations.value_confidence_max": 60,
|
||||
"recommendations.value_edge_margin": 0.03,
|
||||
"recommendations.value_upgrade_edge": 5.0,
|
||||
"recommendations.risk_safe_boost": 1.2,
|
||||
"recommendations.risk_ms_penalty_high": 0.5,
|
||||
"recommendations.risk_other_penalty": 0.7,
|
||||
"recommendations.risk_ms_penalty_medium": 0.8,
|
||||
}
|
||||
self.recommender = BetRecommender(self.mock_config)
|
||||
|
||||
def _make_risk(self, level="MEDIUM", is_surprise=False):
|
||||
return RiskAnalysis(risk_level=level, is_surprise_risk=is_surprise, risk_score=0.5)
|
||||
|
||||
def _make_ms_pred(self, pick, conf):
|
||||
# pick: "1", "X", "2"
|
||||
probs = {"1": {"ms_home_prob": 0.5, "ms_draw_prob": 0.3, "ms_away_prob": 0.2},
|
||||
"X": {"ms_home_prob": 0.2, "ms_draw_prob": 0.5, "ms_away_prob": 0.3},
|
||||
"2": {"ms_home_prob": 0.2, "ms_draw_prob": 0.3, "ms_away_prob": 0.5}}
|
||||
p = probs.get(pick, probs["1"])
|
||||
return MatchResultPrediction(
|
||||
ms_pick=pick, ms_confidence=conf,
|
||||
dc_pick="1X", dc_confidence=0,
|
||||
dc_1x_prob=0.7, dc_x2_prob=0.7, dc_12_prob=0.7,
|
||||
**p
|
||||
)
|
||||
|
||||
def _make_ou_pred(self):
|
||||
return OverUnderPrediction(
|
||||
ou25_pick="2.5 Üst", ou25_confidence=50.0,
|
||||
over_25_prob=0.55, under_25_prob=0.45,
|
||||
|
||||
btts_pick="Var", btts_confidence=50.0,
|
||||
btts_yes_prob=0.55, btts_no_prob=0.45,
|
||||
|
||||
ou15_pick="1.5 Üst", ou15_confidence=60.0, over_15_prob=0.7, under_15_prob=0.3,
|
||||
ou35_pick="3.5 Alt", ou35_confidence=50.0, over_35_prob=0.3, under_35_prob=0.7
|
||||
)
|
||||
|
||||
def test_low_confidence_should_skip(self):
|
||||
"""Confidence < 45% should be SKIPPED"""
|
||||
ms_pred = self._make_ms_pred(pick="2", conf=40.0)
|
||||
ou_pred = self._make_ou_pred()
|
||||
risk = self._make_risk("MEDIUM")
|
||||
ctx = DummyContext(odds_data={"ms_2": 2.5})
|
||||
|
||||
res = self.recommender.calculate(ctx, ms_pred, ou_pred, risk)
|
||||
|
||||
# Check if MS bet is skipped
|
||||
ms_bet = next((b for b in res.skipped_bets if b.market_type == "MS"), None)
|
||||
self.assertIsNotNone(ms_bet, "MS bet with 40% conf should be skipped!")
|
||||
self.assertTrue(ms_bet.is_skip)
|
||||
|
||||
def test_good_confidence_should_recommend(self):
|
||||
"""Confidence > 60% and Good Odds should be RECOMMENDED"""
|
||||
ms_pred = self._make_ms_pred(pick="1", conf=70.0)
|
||||
ou_pred = self._make_ou_pred()
|
||||
risk = self._make_risk("MEDIUM")
|
||||
# Odds 1.80 for 70% prob = Good Value (Need real odds for MS to pass)
|
||||
ctx = DummyContext(odds_data={"ms_1": 1.80, "ou15_o": 1.50}) # Added ou15 odds
|
||||
|
||||
res = self.recommender.calculate(ctx, ms_pred, ou_pred, risk)
|
||||
|
||||
# Check if ANY bet is recommended (doesn't have to be MS, but usually is)
|
||||
self.assertGreater(len(res.recommended_bets), 0, "At least one bet should be recommended!")
|
||||
# Check that MS bet is NOT skipped
|
||||
ms_bet = next((b for b in res.recommended_bets if b.market_type == "MS"), None)
|
||||
if ms_bet:
|
||||
self.assertFalse(ms_bet.is_skip)
|
||||
|
||||
def test_negative_edge_should_skip(self):
|
||||
"""Even with high confidence, if Odds are too low (Bad Value), SKIP"""
|
||||
ms_pred = self._make_ms_pred(pick="1", conf=70.0) # 70% prob
|
||||
ou_pred = self._make_ou_pred()
|
||||
risk = self._make_risk("MEDIUM")
|
||||
# Odds 1.10 -> Implied 90%. Our prob is 70%. Edge is -20% -> SKIP
|
||||
ctx = DummyContext(odds_data={"ms_1": 1.10})
|
||||
|
||||
res = self.recommender.calculate(ctx, ms_pred, ou_pred, risk)
|
||||
|
||||
ms_bet = next((b for b in res.skipped_bets if b.market_type == "MS"), None)
|
||||
self.assertIsNotNone(ms_bet, "MS bet with terrible odds (Negative Edge) should be skipped!")
|
||||
self.assertTrue(ms_bet.is_skip)
|
||||
|
||||
def test_no_bets_recommendation(self):
|
||||
"""If all bets are low confidence, best_bet should be None"""
|
||||
ms_pred = self._make_ms_pred(pick="1", conf=30.0) # Very low conf
|
||||
ou_pred = self._make_ou_pred()
|
||||
# Reset ALL OU confs to low
|
||||
ou_pred.ou25_confidence = 30.0
|
||||
ou_pred.btts_confidence = 30.0
|
||||
ou_pred.ou15_confidence = 30.0 # This was 60 in setUp, causing the fail!
|
||||
ou_pred.ou35_confidence = 30.0
|
||||
|
||||
risk = self._make_risk("MEDIUM")
|
||||
ctx = DummyContext(odds_data={"ms_1": 2.0})
|
||||
|
||||
res = self.recommender.calculate(ctx, ms_pred, ou_pred, risk)
|
||||
|
||||
self.assertIsNone(res.best_bet, "If everything is skipped, there should be no best_bet.")
|
||||
self.assertEqual(len(res.recommended_bets), 0, "No bets should be recommended!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("🧪 Running Skip Logic Unit Tests...")
|
||||
print("="*50)
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user