diff --git a/config/application.yml b/config/application.yml index 6868705..852e2fc 100644 --- a/config/application.yml +++ b/config/application.yml @@ -5,6 +5,60 @@ screenshotter: resolver: api_key: 733f6882605be2de8980bbd074091ee4 + league_map: + # European cups + liga mistrů: 2 + champions league: 2 + evropská liga: 3 + europa league: 3 + konferenční liga: 848 + conference league: 848 + # Top flights + 1. anglie: 39 + 1. belgie: 144 + 1. česko: 345 + 1. dánsko: 119 + 1. francie: 61 + 1. itálie: 135 + 1. itálie - ženy: 794 + 1. německo: 78 + 1. nizozemsko: 88 + 1. polsko: 106 + 1. portugalsko: 94 + 1. rakousko: 218 + 1. rumunsko: 283 + 1. skotsko: 179 + 1. slovensko: 332 + 1. španělsko: 140 + 1. wales: 771 + # Second divisions + 2. anglie: 40 + 2. česko: 346 + 2. francie: 62 + 2. itálie: 136 + 2. německo: 79 + 2. nizozemsko: 89 + 2. rakousko: 219 + 2. slovensko: 333 + 2. španělsko: 141 + # Third divisions + 3. francie: 63 + 3. česko msfl: 349 + 3. česko čfl: 348 + # Fourth divisions + 4. česko - sk. a: 350 + 4. česko - sk. b: 351 + 4. česko - sk. c: 352 + 4. česko - sk. d: 353 + 4. česko - sk. e: 354 + 4. česko - sk. f: 686 + # Women + 1. česko - ženy: 669 + fortuna=liga ženy: 669 + # Domestic cups + anglie - fa cup: 45 + anglie - efl cup: 48 + česko - pohár: 347 img_classifier: target_path: data/screenshots/ diff --git a/src/beaky/cli.py b/src/beaky/cli.py index 279b37e..ab6777e 100644 --- a/src/beaky/cli.py +++ b/src/beaky/cli.py @@ -3,18 +3,24 @@ import argparse import yaml from pydantic import ValidationError +from beaky import _ansi from beaky.config import Config from beaky.link_classifier.classifier import LinkClassifier -from beaky.resolvers.resolver import _B, _GRAY, _GREEN, _R, _RED, _YELLOW, TicketResolver, TicketVerdict +from beaky.resolvers.resolver import TicketResolver, TicketVerdict from beaky.scanner.scanner import Links from beaky.screenshotter.screenshotter import Screenshotter -_VERDICT_COLOR = { - TicketVerdict.TRUTHFUL: _GREEN, - TicketVerdict.NOT_TRUTHFUL: _RED, - TicketVerdict.POSSIBLY_TRUTHFUL: _YELLOW, - TicketVerdict.UNKNOWN: _GRAY, -} + +def _verdict_str(verdict: TicketVerdict) -> str: + text = f"VERDICT: {verdict.value.upper()}" + if verdict == TicketVerdict.TRUTHFUL: + return _ansi.green(text) + if verdict == TicketVerdict.NOT_TRUTHFUL: + return _ansi.red(text) + if verdict == TicketVerdict.POSSIBLY_TRUTHFUL: + return _ansi.yellow(text) + return _ansi.gray(text) + def load_config(path: str) -> Config | None: with open(path) as f: @@ -81,8 +87,7 @@ def main() -> None: print(f"\n--- Resolving ticket {link.id} ---") resolved = resolver.resolve(ticket) - color = _VERDICT_COLOR.get(resolved.verdict, "") - print(f"\n {color}{_B}VERDICT: {resolved.verdict.value.upper()}{_R}") + print(f"\n {_ansi.bold(_verdict_str(resolved.verdict))}") if __name__ == "__main__": main() diff --git a/src/beaky/datamodels/ticket.py b/src/beaky/datamodels/ticket.py index 5649477..82745ed 100644 --- a/src/beaky/datamodels/ticket.py +++ b/src/beaky/datamodels/ticket.py @@ -15,7 +15,23 @@ class BetType(str, Enum): GOAL_AMOUNT = "goal_amount" GOAL_HANDICAP = "goal_handicap" UNKNOWN = "unknown" - ... + + +class BetOutcome(str, Enum): + WIN = "win" + LOSE = "lose" + VOID = "void" # stake returned (e.g. WinLose on draw, integer goal line hit) + UNKNOWN = "unknown" # fixture not found or unclassified bet + + +@dataclass +class MatchInfo: + goals_home: int + goals_away: int + half_time_home: int | None = None + half_time_away: int | None = None + corners_home: int | None = None + corners_away: int | None = None @dataclass @@ -25,67 +41,102 @@ class Bet(ABC): team2Name: str date: datetime league: str + @abstractmethod - def resolve(self) -> None: pass + def resolve(self, match: MatchInfo) -> BetOutcome: ... + @dataclass class WinDrawLose(Bet): """Výsledek zápasu 1X2""" betType: Literal["X", "0", "1", "2"] = "0" - def resolve(self) -> None: - ... + + def resolve(self, match: MatchInfo) -> BetOutcome: + home, away = match.goals_home, match.goals_away + bet_draw = self.betType in ("X", "0") + if bet_draw: + return BetOutcome.WIN if home == away else BetOutcome.LOSE + actual = "1" if home > away else ("0" if home == away else "2") + return BetOutcome.WIN if actual == self.betType else BetOutcome.LOSE + @dataclass class Advance(Bet): """What team advances to next round""" - def resolve(self) -> None: - raise NotImplementedError("Vyser si voko vine") + + def resolve(self, match: MatchInfo) -> BetOutcome: + raise NotImplementedError("Advance bet resolution is not implemented") + @dataclass class WinDrawLoseDouble(Bet): """Výsledek zápasu - double""" betType: Literal["01", "12", "02"] = "01" - def resolve(self) -> None: - ... + + def resolve(self, match: MatchInfo) -> BetOutcome: + home, away = match.goals_home, match.goals_away + actual = "1" if home > away else ("0" if home == away else "2") + return BetOutcome.WIN if actual in self.betType else BetOutcome.LOSE + @dataclass class WinLose(Bet): """Výsledek zápasu bez remízy""" betType: Literal["1", "2"] = "1" - def resolve(self) -> None: - ... + + def resolve(self, match: MatchInfo) -> BetOutcome: + home, away = match.goals_home, match.goals_away + if home == away: + return BetOutcome.VOID + actual = "1" if home > away else "2" + return BetOutcome.WIN if actual == self.betType else BetOutcome.LOSE + @dataclass class BothTeamScored(Bet): - def resolve(self) -> None: - ... + def resolve(self, match: MatchInfo) -> BetOutcome: + return BetOutcome.WIN if match.goals_home > 0 and match.goals_away > 0 else BetOutcome.LOSE + @dataclass class GoalAmount(Bet): """Počet gólů v zápasu — over/under total goals""" line: float = 0.0 # goal line, e.g. 2.5 over: bool = True # True = more than line, False = less than line - def resolve(self) -> None: - ... + + def resolve(self, match: MatchInfo) -> BetOutcome: + total = match.goals_home + match.goals_away + if total == self.line: + return BetOutcome.VOID + won = total > self.line if self.over else total < self.line + return BetOutcome.WIN if won else BetOutcome.LOSE + @dataclass class GoalHandicap(Bet): """Goal handicap for a specific team — add handicap_amount to team's score, team wins = you win""" team_bet: Literal["1", "2"] = "1" # which team the handicap is applied to handicap_amount: float = 0.0 # e.g. +1.5 or -0.5 - def resolve(self) -> None: - ... + + def resolve(self, match: MatchInfo) -> BetOutcome: + home = match.goals_home + (self.handicap_amount if self.team_bet == "1" else 0.0) + away = match.goals_away + (self.handicap_amount if self.team_bet == "2" else 0.0) + if home == away: + return BetOutcome.VOID + actual_winner = "1" if home > away else "2" + return BetOutcome.WIN if actual_winner == self.team_bet else BetOutcome.LOSE + @dataclass class UnknownTicket(Bet): """Bet type that could not be classified""" raw_text: str = "" - def resolve(self) -> None: - ... + def resolve(self, match: MatchInfo) -> BetOutcome: + return BetOutcome.UNKNOWN @dataclass class Ticket: id: int - bets: list[Bet] \ No newline at end of file + bets: list[Bet] diff --git a/src/beaky/resolvers/config.py b/src/beaky/resolvers/config.py index 3825a73..1949148 100644 --- a/src/beaky/resolvers/config.py +++ b/src/beaky/resolvers/config.py @@ -4,3 +4,4 @@ from pydantic.dataclasses import dataclass @dataclass class ResolverConfig: api_key: str + league_map: dict[str, int] diff --git a/src/beaky/resolvers/resolver.py b/src/beaky/resolvers/resolver.py index bd63fa5..7f0eed3 100644 --- a/src/beaky/resolvers/resolver.py +++ b/src/beaky/resolvers/resolver.py @@ -7,97 +7,20 @@ from typing import Any import requests +from beaky import _ansi from beaky.datamodels.ticket import ( Bet, - BothTeamScored, - GoalAmount, - GoalHandicap, + BetOutcome, + MatchInfo, Ticket, UnknownTicket, - WinDrawLose, - WinDrawLoseDouble, - WinLose, ) from beaky.resolvers.config import ResolverConfig _API_BASE = "https://v3.football.api-sports.io" -# Fortuna league strings (lowercased substring match) -> api-football league ID -_LEAGUE_MAP: dict[str, int] = { - # European cups - "liga mistrů": 2, - "champions league": 2, - "evropská liga": 3, - "europa league": 3, - "konferenční liga": 848, - "conference league": 848, - # Top flights - "1. anglie": 39, - "1. belgie": 144, - "1. česko": 345, - "1. dánsko": 119, - "1. francie": 61, - "1. itálie": 135, - "1. itálie - ženy": 794, - "1. německo": 78, - "1. nizozemsko": 88, - "1. polsko": 106, - "1. portugalsko": 94, - "1. rakousko": 218, - "1. rumunsko": 283, - "1. skotsko": 179, - "1. slovensko": 332, - "1. španělsko": 140, - "1. wales": 771, - # Second divisions - "2. anglie": 40, - "2. česko": 346, - "2. francie": 62, - "2. itálie": 136, - "2. německo": 79, - "2. nizozemsko": 89, - "2. rakousko": 219, - "2. slovensko": 333, - "2. španělsko": 141, - # Third divisions - "3. francie": 63, - "3. česko msfl": 349, - "3. česko čfl": 348, - # Fourth divisions - "4. česko - sk. a": 350, - "4. česko - sk. b": 351, - "4. česko - sk. c": 352, - "4. česko - sk. d": 353, - "4. česko - sk. e": 354, - "4. česko - sk. f": 686, - # Women - "1. česko - ženy": 669, - "fortuna=liga ženy": 669, - # Domestic cups - "anglie - fa cup": 45, - "anglie - efl cup": 48, - "česko - pohár": 347, -} - _DATE_WINDOW = 3 # days either side of the bet date to search -# ANSI color helpers -_R = "\033[0m" -_B = "\033[1m" -_DIM= "\033[2m" -_GREEN = "\033[32m" -_RED = "\033[31m" -_YELLOW = "\033[33m" -_CYAN = "\033[36m" -_GRAY = "\033[90m" - -_OUTCOME_COLOR = { - "win": _GREEN, - "lose": _RED, - "void": _YELLOW, - "unknown": _GRAY, -} - class TicketVerdict(str, Enum): TRUTHFUL = "truthful" @@ -106,13 +29,6 @@ class TicketVerdict(str, Enum): UNKNOWN = "unknown — could not resolve enough bets to decide" -class BetOutcome(str, Enum): - WIN = "win" - LOSE = "lose" - VOID = "void" # stake returned (e.g. WinLose on draw, integer goal line hit) - UNKNOWN = "unknown" # fixture not found or unclassified bet - - @dataclass class ResolvedBet: bet: Bet @@ -166,6 +82,7 @@ def _get(url: str, headers: dict[str, str], params: dict[str, str | int], retrie class TicketResolver: def __init__(self, config: ResolverConfig): self._headers = {"x-apisports-key": config.api_key} + self._league_map = config.league_map # Cache maps (center_date_str, league_id | None) -> list of fixture dicts self._fixture_cache: dict[tuple[str, int | None], list[dict[str, Any]]] = {} # Cache maps league name -> (league_id, confidence) @@ -179,31 +96,40 @@ class TicketResolver: def _resolve_bet(self, bet: Bet) -> ResolvedBet: bet_type = type(bet).__name__ - print(f"\n {_B}{_CYAN}┌─ [{bet_type}]{_R} {_B}{bet.team1Name} vs {bet.team2Name}{_R}" - f" {_DIM}{bet.date.strftime('%Y-%m-%d')} | {bet.league}{_R}") + print(f"\n {_ansi.bold(_ansi.cyan(f'┌─ [{bet_type}]'))} {_ansi.bold(f'{bet.team1Name} vs {bet.team2Name}')}" + f" {_ansi.dim(f'{bet.date.strftime('%Y-%m-%d')} | {bet.league}')}") if isinstance(bet, UnknownTicket): - print(f" {_GRAY}│ skipping — not implemented: {bet.raw_text!r}{_R}") - print(f" {_GRAY}└─ UNKNOWN{_R}") + print(_ansi.gray(f" │ skipping — not implemented: {bet.raw_text!r}")) + print(_ansi.gray(" └─ UNKNOWN")) return ResolvedBet(bet=bet, outcome=BetOutcome.UNKNOWN) fixture, name_match, date_prox, league_conf = self._find_fixture(bet) if fixture is None: - print(f" {_GRAY}└─ UNKNOWN — no fixture found{_R}") + print(_ansi.gray(" └─ UNKNOWN — no fixture found")) return ResolvedBet(bet=bet, outcome=BetOutcome.UNKNOWN, league_found=league_conf) home_name = fixture["teams"]["home"]["name"] away_name = fixture["teams"]["away"]["name"] finished = _is_finished(fixture) confidence = round((name_match + date_prox + league_conf + finished) / 4, 3) - outcome = _evaluate_bet(bet, fixture) if finished == 1.0 else BetOutcome.UNKNOWN + + if finished == 1.0: + match_info = _fixture_to_match_info(fixture) + outcome = bet.resolve(match_info) + else: + outcome = BetOutcome.UNKNOWN goals = fixture["goals"] - color = _OUTCOME_COLOR.get(outcome.value, _GRAY) - print(f" {_DIM}│ matched #{fixture['fixture']['id']}: {home_name} vs {away_name}" - f" | {goals['home']}:{goals['away']} | {fixture['fixture']['status']['short']}" - f" | confidence {confidence} (name={name_match:.2f} date={date_prox:.2f} league={league_conf} finished={finished}){_R}") - print(f" {color}{_B}└─ {outcome.value.upper()}{_R}") + print(_ansi.dim( + f" │ matched #{fixture['fixture']['id']}: {home_name} vs {away_name}" + f" | {goals['home']}:{goals['away']} | {fixture['fixture']['status']['short']}" + f" | confidence {confidence} (name={name_match:.2f} date={date_prox:.2f} league={league_conf} finished={finished})" + )) + print(_ansi.bold(_ansi.green(f" └─ {outcome.value.upper()}") if outcome == BetOutcome.WIN + else _ansi.red(f" └─ {outcome.value.upper()}") if outcome == BetOutcome.LOSE + else _ansi.yellow(f" └─ {outcome.value.upper()}") if outcome == BetOutcome.VOID + else _ansi.gray(f" └─ {outcome.value.upper()}"))) return ResolvedBet( bet=bet, @@ -230,13 +156,13 @@ class TicketResolver: if league_id is not None: params["league"] = league_id params["season"] = center.year if center.month >= 7 else center.year - 1 - print(f" {_GRAY}│ GET /fixtures {params}{_R}") + print(_ansi.gray(f" │ GET /fixtures {params}")) resp = _get(f"{_API_BASE}/fixtures", headers=self._headers, params=params) resp.raise_for_status() self._fixture_cache[cache_key] = resp.json().get("response", []) - print(f" {_GRAY}│ {len(self._fixture_cache[cache_key])} fixtures returned (cached){_R}") + print(_ansi.gray(f" │ {len(self._fixture_cache[cache_key])} fixtures returned (cached)")) else: - print(f" {_GRAY}│ /fixtures (±{_DATE_WINDOW}d of {date_str}, league={league_id}) served from cache{_R}") + print(_ansi.gray(f" │ /fixtures (±{_DATE_WINDOW}d of {date_str}, league={league_id}) served from cache")) fixture, name_match, date_prox = _best_fixture_match( self._fixture_cache[cache_key], bet.team1Name, bet.team2Name, center @@ -248,28 +174,57 @@ class TicketResolver: if key in self._league_cache: return self._league_cache[key] - for pattern, league_id in _LEAGUE_MAP.items(): + for pattern, league_id in self._league_map.items(): if pattern in key: - print(f" {_GRAY}│ league {league_name!r} -> id={league_id} (static map){_R}") + print(_ansi.gray(f" │ league {league_name!r} -> id={league_id} (static map)")) self._league_cache[key] = (league_id, 1.0) return league_id, 1.0 # Fall back to API search — lower confidence since first result is taken unverified - print(f" {_GRAY}│ GET /leagues search={league_name!r}{_R}") + print(_ansi.gray(f" │ GET /leagues search={league_name!r}")) resp = _get(f"{_API_BASE}/leagues", headers=self._headers, params={"search": league_name[:20]}) results = resp.json().get("response", []) if results: league_id = results[0]["league"]["id"] league_found_name = results[0]["league"]["name"] - print(f" {_GRAY}│ matched {league_found_name!r} id={league_id} (API fallback, confidence=0.7){_R}") + print(_ansi.gray(f" │ matched {league_found_name!r} id={league_id} (API fallback, confidence=0.7)")) self._league_cache[key] = (league_id, 0.7) return league_id, 0.7 - print(f" {_GRAY}│ no league found, searching fixtures by date only (confidence=0.3){_R}") + print(_ansi.gray(" │ no league found, searching fixtures by date only (confidence=0.3)")) self._league_cache[key] = (None, 0.3) return None, 0.3 +def _fixture_to_match_info(fixture: dict[str, Any]) -> MatchInfo: + goals = fixture.get("goals", {}) + score = fixture.get("score", {}) + halftime = score.get("halftime", {}) + + corners_home: int | None = None + corners_away: int | None = None + for stat_entry in fixture.get("statistics", []): + for stat in stat_entry.get("statistics", []): + if stat.get("type") == "Corner Kicks": + team_id = stat_entry.get("team", {}).get("id") + home_team_id = fixture.get("teams", {}).get("home", {}).get("id") + value = stat.get("value") + if isinstance(value, int): + if team_id == home_team_id: + corners_home = value + else: + corners_away = value + + return MatchInfo( + goals_home=goals.get("home", 0), + goals_away=goals.get("away", 0), + half_time_home=halftime.get("home"), + half_time_away=halftime.get("away"), + corners_home=corners_home, + corners_away=corners_away, + ) + + def _similarity(a: str, b: str) -> float: return SequenceMatcher(None, a.lower(), b.lower()).ratio() @@ -306,49 +261,3 @@ def _best_fixture_match(fixtures: list[dict[str, Any]], team1: str, team2: str, def _is_finished(fixture: dict[str, Any]) -> float: status = fixture.get("fixture", {}).get("status", {}).get("short", "") return 1.0 if status in ("FT", "AET", "PEN", "AWD", "WO") else 0.0 - - -def _evaluate_bet(bet: Bet, fixture: dict[str, Any]) -> BetOutcome: - goals = fixture.get("goals", {}) - home = goals.get("home") - away = goals.get("away") - - if home is None or away is None: - return BetOutcome.UNKNOWN - - if isinstance(bet, WinDrawLose): - bet_draw = bet.betType in ("X", "0") - if bet_draw: - return BetOutcome.WIN if home == away else BetOutcome.LOSE - actual = "1" if home > away else ("0" if home == away else "2") - return BetOutcome.WIN if actual == bet.betType else BetOutcome.LOSE - - if isinstance(bet, WinDrawLoseDouble): - actual = "1" if home > away else ("0" if home == away else "2") - return BetOutcome.WIN if actual in bet.betType else BetOutcome.LOSE - - if isinstance(bet, WinLose): - if home == away: - return BetOutcome.VOID - actual = "1" if home > away else "2" - return BetOutcome.WIN if actual == bet.betType else BetOutcome.LOSE - - if isinstance(bet, BothTeamScored): - return BetOutcome.WIN if home > 0 and away > 0 else BetOutcome.LOSE - - if isinstance(bet, GoalAmount): - total = home + away - if total == bet.line: - return BetOutcome.VOID - won = total > bet.line if bet.over else total < bet.line - return BetOutcome.WIN if won else BetOutcome.LOSE - - if isinstance(bet, GoalHandicap): - h_home = home + (bet.handicap_amount if bet.team_bet == "1" else 0.0) - h_away = away + (bet.handicap_amount if bet.team_bet == "2" else 0.0) - if h_home == h_away: - return BetOutcome.VOID - actual_winner = "1" if h_home > h_away else "2" - return BetOutcome.WIN if actual_winner == bet.team_bet else BetOutcome.LOSE - - return BetOutcome.UNKNOWN