diff --git a/knowledge_base/tickety.md b/knowledge_base/tickety.md index e66ce9d..1f9a99e 100644 --- a/knowledge_base/tickety.md +++ b/knowledge_base/tickety.md @@ -39,7 +39,8 @@ význam? - Sázka na více než 2 góly: výhra - Sázka na více než 4 góly: prohra - Sázka na více/méně než 3 góly: storno -- [Tým] počet gólů (ano ta sázka se tak jmenuje) + +- počet gólů (ano ta sázka se tak jmenuje) - +/- v tomto kontextu znamená větší/menší než. Tedy sázíme, zda daný tým dal méně/více než nějaký počet gólů - příklad, tým dal 3 góly - sázka -3.5: výhra diff --git a/src/beaky/cli.py b/src/beaky/cli.py index ca1439c..22d1170 100644 --- a/src/beaky/cli.py +++ b/src/beaky/cli.py @@ -11,7 +11,7 @@ from beaky.config import Config from beaky.datamodels.ticket import Bet, Ticket from beaky.image_classifier.classifier import img_classify from beaky.link_classifier.classifier import LinkClassifier -from beaky.resolvers.resolver import TicketResolver, TicketVerdict +from beaky.resolvers.resolver import ResolvedTicket, TicketResolver, TicketVerdict from beaky.scanner.scanner import Links from beaky.screenshotter.screenshotter import Screenshotter @@ -158,6 +158,38 @@ def _print_single(ticket: Ticket, col_label: str) -> None: _print_bet_grid(header, all_lines, _BLANK_ROWS, _BET_WS) +def _print_resolve_dump(resolved: ResolvedTicket) -> None: + print(f"\n{'═' * 60}") + print(_ansi.bold(f" Ticket {resolved.ticket_id} — resolve dump")) + print(f"{'═' * 60}") + for i, rb in enumerate(resolved.bets, 1): + bet = rb.bet + print(f"\n {_ansi.bold(_ansi.cyan(f'Bet {i}'))} [{type(bet).__name__}] outcome={_ansi.bold(rb.outcome.value.upper())}") + print(f" fixture_id: {rb.fixture_id}") + print(f" confidence: {rb.confidence} (name={rb.name_match} date={rb.date_proximity} league={rb.league_found} finished={rb.match_finished})") + print(f" --- bet fields ---") + for k, v in vars(bet).items(): + val = v.strftime("%Y-%m-%d %H:%M") if k == "date" and isinstance(v, datetime) else str(v) + print(f" {k}: {val}") + print(f" --- match info ---") + if rb.match_info is None: + print(f" (not available — fixture not finished or not found)") + else: + for k, v in vars(rb.match_info).items(): + print(f" {k}: {v}") + + +def _print_dump(ticket: Ticket, label: str) -> None: + print(f"\n{'═' * 60}") + print(_ansi.bold(f" Ticket {ticket.id} — {label} │ {len(ticket.bets)} bet(s)")) + print(f"{'═' * 60}") + for i, bet in enumerate(ticket.bets, 1): + print(f"\n {_ansi.bold(_ansi.cyan(f'Bet {i}'))} [{type(bet).__name__}]") + for k, v in vars(bet).items(): + val = v.strftime("%Y-%m-%d %H:%M") if k == "date" and isinstance(v, datetime) else str(v) + print(f" {k}: {val}") + + def load_config(path: str) -> Config | None: with open(path) as f: config_dict = yaml.safe_load(f) @@ -175,6 +207,8 @@ def main() -> None: parser.add_argument("mode", choices=["screen", "parse", "compare", "resolve"], help="Mode of operation.") parser.add_argument("--classifier", choices=["link", "img", "both"], default="both", help="Which classifier to use in compare mode (default: both).") + parser.add_argument("--dump", action="store_true", + help="Dump all bet fields untruncated (compare mode only).") args = parser.parse_args() config = load_config(args.config) @@ -215,7 +249,12 @@ def main() -> None: for link in selected_links: link_ticket = linkclassifier.classify(link) if use_link else None img_ticket = img_classify([f"./data/screenshots/{link.id}.png"], ticket_id=link.id) if use_img else None - if args.classifier == "both" and link_ticket and img_ticket: + if args.dump: + if link_ticket: + _print_dump(link_ticket, "link classifier") + if img_ticket: + _print_dump(img_ticket, "image classifier") + elif args.classifier == "both" and link_ticket and img_ticket: _print_compare(link_ticket, img_ticket) elif link_ticket: _print_single(link_ticket, "link classifier") @@ -234,6 +273,8 @@ def main() -> None: print(f"\n--- Resolving ticket {link.id} ---") resolved = resolver.resolve(ticket) + if args.dump: + _print_resolve_dump(resolved) print(f"\n {_ansi.bold(_verdict_str(resolved.verdict))}") if __name__ == "__main__": diff --git a/src/beaky/datamodels/ticket.py b/src/beaky/datamodels/ticket.py index 2a27e0c..eddc948 100644 --- a/src/beaky/datamodels/ticket.py +++ b/src/beaky/datamodels/ticket.py @@ -14,6 +14,12 @@ class BetType(str, Enum): BOTH_TEAM_SCORED = "both_team_scored" GOAL_AMOUNT = "goal_amount" GOAL_HANDICAP = "goal_handicap" + HALF_TIME_RESULT = "half_time_result" + HALF_TIME_DOUBLE = "half_time_double" + HALF_TIME_FULL_TIME = "half_time_full_time" + CORNER_AMOUNT = "corner_amount" + TEAM_CORNER_AMOUNT = "team_corner_amount" + MORE_OFFSIDES = "more_offsides" UNKNOWN = "unknown" @@ -32,6 +38,8 @@ class MatchInfo: half_time_away: int | None = None corners_home: int | None = None corners_away: int | None = None + offsides_home: int | None = None + offsides_away: int | None = None @dataclass @@ -132,6 +140,95 @@ class GoalHandicap(Bet): return BetOutcome.WIN if actual_winner == self.team_bet else BetOutcome.LOSE +@dataclass +class HalfTimeResult(Bet): + """Výsledek 1. poločasu: 0/1/2""" + + betType: Literal["0", "1", "2"] + + def resolve(self, match: MatchInfo) -> BetOutcome: + if match.half_time_home is None or match.half_time_away is None: + return BetOutcome.UNKNOWN + actual = "1" if match.half_time_home > match.half_time_away else ("0" if match.half_time_home == match.half_time_away else "2") + return BetOutcome.WIN if actual == self.betType else BetOutcome.LOSE + + +@dataclass +class HalfTimeDouble(Bet): + """Výsledek 1. poločasu - dvojtip: 10/02/01""" + + betType: Literal["01", "02", "12"] + + def resolve(self, match: MatchInfo) -> BetOutcome: + if match.half_time_home is None or match.half_time_away is None: + return BetOutcome.UNKNOWN + actual = "1" if match.half_time_home > match.half_time_away else ("0" if match.half_time_home == match.half_time_away else "2") + return BetOutcome.WIN if actual in self.betType else BetOutcome.LOSE + + +@dataclass +class HalfTimeFullTime(Bet): + """Výsledek 1. poločasu/výsledek zápasu: X/Y""" + + ht_bet: Literal["0", "1", "2"] + ft_bet: Literal["0", "1", "2"] + + def resolve(self, match: MatchInfo) -> BetOutcome: + if match.half_time_home is None or match.half_time_away is None: + return BetOutcome.UNKNOWN + actual_ht = "1" if match.half_time_home > match.half_time_away else ("0" if match.half_time_home == match.half_time_away else "2") + actual_ft = "1" if match.goals_home > match.goals_away else ("0" if match.goals_home == match.goals_away else "2") + return BetOutcome.WIN if actual_ht == self.ht_bet and actual_ft == self.ft_bet else BetOutcome.LOSE + + +@dataclass +class CornerAmount(Bet): + """Počet rohových kopů v zápasu X.5: +/- — total corners over/under""" + + line: float + over: bool + + def resolve(self, match: MatchInfo) -> BetOutcome: + if match.corners_home is None or match.corners_away is None: + return BetOutcome.UNKNOWN + total = match.corners_home + match.corners_away + if total == self.line: + return BetOutcome.VOID + return BetOutcome.WIN if (total > self.line) == self.over else BetOutcome.LOSE + + +@dataclass +class TeamCornerAmount(Bet): + """Team-specific corners over/under""" + + team_bet: Literal["1", "2"] + line: float + over: bool + + def resolve(self, match: MatchInfo) -> BetOutcome: + if match.corners_home is None or match.corners_away is None: + return BetOutcome.UNKNOWN + corners = match.corners_home if self.team_bet == "1" else match.corners_away + if corners == self.line: + return BetOutcome.VOID + return BetOutcome.WIN if (corners > self.line) == self.over else BetOutcome.LOSE + + +@dataclass +class MoreOffsides(Bet): + """Více ofsajdů v zápasu: 1/2""" + + team_bet: Literal["1", "2"] + + def resolve(self, match: MatchInfo) -> BetOutcome: + if match.offsides_home is None or match.offsides_away is None: + return BetOutcome.UNKNOWN + if match.offsides_home == match.offsides_away: + return BetOutcome.VOID + actual = "1" if match.offsides_home > match.offsides_away else "2" + return BetOutcome.WIN if actual == self.team_bet else BetOutcome.LOSE + + @dataclass class UnknownBet(Bet): """Bet type that could not be classified""" diff --git a/src/beaky/link_classifier/classifier.py b/src/beaky/link_classifier/classifier.py index c9f1707..4eea04e 100644 --- a/src/beaky/link_classifier/classifier.py +++ b/src/beaky/link_classifier/classifier.py @@ -8,8 +8,14 @@ from beaky.datamodels.ticket import ( Bet, BetType, BothTeamScored, + CornerAmount, GoalAmount, GoalHandicap, + HalfTimeDouble, + HalfTimeFullTime, + HalfTimeResult, + MoreOffsides, + TeamCornerAmount, Ticket, UnknownBet, WinDrawLose, @@ -80,6 +86,41 @@ def _classify_bet(bet_text: str, team1: str, team2: str, date: datetime, league: handicap = sign * float(m.group(2)) return GoalHandicap(ticketType=BetType.GOAL_HANDICAP, team_bet=team_bet, handicap_amount=handicap, **common) # type: ignore[arg-type] + # HalfTimeFullTime: "Výsledek 1. poločasu/výsledek zápasu: 0/2" (before HalfTimeResult) + m = re.search(r"poločasu/výsledek zápasu:\s*([012])/([012])", bet_text) + if m: + return HalfTimeFullTime(ticketType=BetType.HALF_TIME_FULL_TIME, ht_bet=m.group(1), ft_bet=m.group(2), **common) # type: ignore[arg-type] + + # HalfTimeDouble: "Výsledek 1. poločasu - dvojtip: 10" (before HalfTimeResult) + m = re.search(r"poločasu - dvojtip:\s*(\d+)", bet_text) + if m: + bet_type = "".join(sorted(m.group(1))) + return HalfTimeDouble(ticketType=BetType.HALF_TIME_DOUBLE, betType=bet_type, **common) # type: ignore[arg-type] + + # HalfTimeResult: "Výsledek 1. poločasu: 1" + m = re.search(r"poločasu:\s*([012])\s*$", bet_text.strip()) + if m: + return HalfTimeResult(ticketType=BetType.HALF_TIME_RESULT, betType=m.group(1), **common) # type: ignore[arg-type] + + # CornerAmount: "Počet rohových kopů v zápasu 8.5: + 8.5" + m = re.search(r"Počet rohových kopů v zápasu\s+(\d+(?:\.\d+)?):\s*([+-])", bet_text) + if m: + return CornerAmount(ticketType=BetType.CORNER_AMOUNT, line=float(m.group(1)), over=m.group(2) == "+", **common) + + # TeamCornerAmount: "RB Leipzig počet rohových kopů v zápasu: +7.5" + m = re.search(r"počet rohových kopů v zápasu:\s*([+-])\s*(\d+(?:\.\d+)?)", bet_text) + if m: + bet_lower = bet_text.lower() + team_bet = "1" if team1.lower() in bet_lower else ("2" if team2.lower() in bet_lower else None) + if team_bet is None: + return UnknownBet(ticketType=BetType.UNKNOWN, raw_text=bet_text, **common) + return TeamCornerAmount(ticketType=BetType.TEAM_CORNER_AMOUNT, team_bet=team_bet, line=float(m.group(2)), over=m.group(1) == "+", **common) # type: ignore[arg-type] + + # MoreOffsides: "Více ofsajdů v zápasu: 1" + m = re.search(r"Více ofsajdů v zápasu:\s*([12])", bet_text) + if m: + return MoreOffsides(ticketType=BetType.MORE_OFFSIDES, team_bet=m.group(1), **common) # type: ignore[arg-type] + return UnknownBet(ticketType=BetType.UNKNOWN, raw_text=bet_text, **common) diff --git a/src/beaky/resolvers/resolver.py b/src/beaky/resolvers/resolver.py index 8cb6267..24d1082 100644 --- a/src/beaky/resolvers/resolver.py +++ b/src/beaky/resolvers/resolver.py @@ -45,6 +45,7 @@ class ResolvedBet: date_proximity: float = 0.0 league_found: float = 0.0 match_finished: float = 0.0 + match_info: MatchInfo | None = None @dataclass @@ -117,9 +118,11 @@ class TicketResolver: confidence = round((name_match + date_prox + league_conf + finished) / 4, 3) if finished == 1.0: + fixture = {**fixture, "statistics": self._get_statistics(fixture["fixture"]["id"])} match_info = _fixture_to_match_info(fixture) outcome = bet.resolve(match_info) else: + match_info = None outcome = BetOutcome.UNKNOWN goals = fixture["goals"] @@ -142,8 +145,21 @@ class TicketResolver: date_proximity=round(date_prox, 3), league_found=league_conf, match_finished=finished, + match_info=match_info, ) + def _get_statistics(self, fixture_id: int) -> list[dict[str, Any]]: + cache_key = ("stats", fixture_id) + if cache_key in self._disk_cache: + print(_ansi.gray(f" │ /fixtures/statistics served from disk cache (fixture={fixture_id})")) + return self._disk_cache[cache_key] # type: ignore[no-any-return] + print(_ansi.gray(f" │ GET /fixtures/statistics fixture={fixture_id}")) + resp = _get(f"{_API_BASE}/fixtures/statistics", headers=self._headers, params={"fixture": fixture_id}) + resp.raise_for_status() + stats = resp.json().get("response", []) + self._disk_cache[cache_key] = stats + return stats + def _find_fixture(self, bet: Bet) -> tuple[dict[str, Any] | None, float, float, float]: """Returns (fixture, name_match, date_proximity, league_confidence).""" center = bet.date.date() @@ -213,17 +229,25 @@ def _fixture_to_match_info(fixture: dict[str, Any]) -> MatchInfo: corners_home: int | None = None corners_away: int | None = None + offsides_home: int | None = None + offsides_away: int | None = None for stat_entry in fixture.get("statistics", []): + home_team_id = fixture.get("teams", {}).get("home", {}).get("id") + team_id = stat_entry.get("team", {}).get("id") for stat in stat_entry.get("statistics", []): + value = stat.get("value") + if not isinstance(value, int): + continue 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 + if team_id == home_team_id: + corners_home = value + else: + corners_away = value + elif stat.get("type") == "Offsides": + if team_id == home_team_id: + offsides_home = value + else: + offsides_away = value return MatchInfo( goals_home=goals.get("home", 0), @@ -232,6 +256,8 @@ def _fixture_to_match_info(fixture: dict[str, Any]) -> MatchInfo: half_time_away=halftime.get("away"), corners_home=corners_home, corners_away=corners_away, + offsides_home=offsides_home, + offsides_away=offsides_away, )