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
+766
View File
@@ -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()