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 schemas.prediction import FullMatchPrediction from schemas.match_data import MatchData from models.basketball_v25 import BasketballMatchPrediction from services.single_match_orchestrator import 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()