refactor resolvers

This commit is contained in:
2026-03-22 11:19:17 +01:00
parent 1742a43d49
commit 04987555e5
5 changed files with 200 additions and 180 deletions

View File

@@ -5,6 +5,60 @@ screenshotter:
resolver: resolver:
api_key: 733f6882605be2de8980bbd074091ee4 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: img_classifier:
target_path: data/screenshots/ target_path: data/screenshots/

View File

@@ -3,18 +3,24 @@ import argparse
import yaml import yaml
from pydantic import ValidationError from pydantic import ValidationError
from beaky import _ansi
from beaky.config import Config from beaky.config import Config
from beaky.link_classifier.classifier import LinkClassifier 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.scanner.scanner import Links
from beaky.screenshotter.screenshotter import Screenshotter from beaky.screenshotter.screenshotter import Screenshotter
_VERDICT_COLOR = {
TicketVerdict.TRUTHFUL: _GREEN, def _verdict_str(verdict: TicketVerdict) -> str:
TicketVerdict.NOT_TRUTHFUL: _RED, text = f"VERDICT: {verdict.value.upper()}"
TicketVerdict.POSSIBLY_TRUTHFUL: _YELLOW, if verdict == TicketVerdict.TRUTHFUL:
TicketVerdict.UNKNOWN: _GRAY, 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: def load_config(path: str) -> Config | None:
with open(path) as f: with open(path) as f:
@@ -81,8 +87,7 @@ def main() -> None:
print(f"\n--- Resolving ticket {link.id} ---") print(f"\n--- Resolving ticket {link.id} ---")
resolved = resolver.resolve(ticket) resolved = resolver.resolve(ticket)
color = _VERDICT_COLOR.get(resolved.verdict, "") print(f"\n {_ansi.bold(_verdict_str(resolved.verdict))}")
print(f"\n {color}{_B}VERDICT: {resolved.verdict.value.upper()}{_R}")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -15,7 +15,23 @@ class BetType(str, Enum):
GOAL_AMOUNT = "goal_amount" GOAL_AMOUNT = "goal_amount"
GOAL_HANDICAP = "goal_handicap" GOAL_HANDICAP = "goal_handicap"
UNKNOWN = "unknown" 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 @dataclass
@@ -25,64 +41,99 @@ class Bet(ABC):
team2Name: str team2Name: str
date: datetime date: datetime
league: str league: str
@abstractmethod @abstractmethod
def resolve(self) -> None: pass def resolve(self, match: MatchInfo) -> BetOutcome: ...
@dataclass @dataclass
class WinDrawLose(Bet): class WinDrawLose(Bet):
"""Výsledek zápasu 1X2""" """Výsledek zápasu 1X2"""
betType: Literal["X", "0", "1", "2"] = "0" 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 @dataclass
class Advance(Bet): class Advance(Bet):
"""What team advances to next round""" """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 @dataclass
class WinDrawLoseDouble(Bet): class WinDrawLoseDouble(Bet):
"""Výsledek zápasu - double""" """Výsledek zápasu - double"""
betType: Literal["01", "12", "02"] = "01" 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 @dataclass
class WinLose(Bet): class WinLose(Bet):
"""Výsledek zápasu bez remízy""" """Výsledek zápasu bez remízy"""
betType: Literal["1", "2"] = "1" 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 @dataclass
class BothTeamScored(Bet): 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 @dataclass
class GoalAmount(Bet): class GoalAmount(Bet):
"""Počet gólů v zápasu — over/under total goals""" """Počet gólů v zápasu — over/under total goals"""
line: float = 0.0 # goal line, e.g. 2.5 line: float = 0.0 # goal line, e.g. 2.5
over: bool = True # True = more than line, False = less than line 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 @dataclass
class GoalHandicap(Bet): class GoalHandicap(Bet):
"""Goal handicap for a specific team — add handicap_amount to team's score, team wins = you win""" """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 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 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 @dataclass
class UnknownTicket(Bet): class UnknownTicket(Bet):
"""Bet type that could not be classified""" """Bet type that could not be classified"""
raw_text: str = "" raw_text: str = ""
def resolve(self) -> None:
...
def resolve(self, match: MatchInfo) -> BetOutcome:
return BetOutcome.UNKNOWN
@dataclass @dataclass

View File

@@ -4,3 +4,4 @@ from pydantic.dataclasses import dataclass
@dataclass @dataclass
class ResolverConfig: class ResolverConfig:
api_key: str api_key: str
league_map: dict[str, int]

View File

@@ -7,97 +7,20 @@ from typing import Any
import requests import requests
from beaky import _ansi
from beaky.datamodels.ticket import ( from beaky.datamodels.ticket import (
Bet, Bet,
BothTeamScored, BetOutcome,
GoalAmount, MatchInfo,
GoalHandicap,
Ticket, Ticket,
UnknownTicket, UnknownTicket,
WinDrawLose,
WinDrawLoseDouble,
WinLose,
) )
from beaky.resolvers.config import ResolverConfig from beaky.resolvers.config import ResolverConfig
_API_BASE = "https://v3.football.api-sports.io" _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 _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): class TicketVerdict(str, Enum):
TRUTHFUL = "truthful" TRUTHFUL = "truthful"
@@ -106,13 +29,6 @@ class TicketVerdict(str, Enum):
UNKNOWN = "unknown — could not resolve enough bets to decide" 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 @dataclass
class ResolvedBet: class ResolvedBet:
bet: Bet bet: Bet
@@ -166,6 +82,7 @@ def _get(url: str, headers: dict[str, str], params: dict[str, str | int], retrie
class TicketResolver: class TicketResolver:
def __init__(self, config: ResolverConfig): def __init__(self, config: ResolverConfig):
self._headers = {"x-apisports-key": config.api_key} 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 # Cache maps (center_date_str, league_id | None) -> list of fixture dicts
self._fixture_cache: dict[tuple[str, int | None], list[dict[str, Any]]] = {} self._fixture_cache: dict[tuple[str, int | None], list[dict[str, Any]]] = {}
# Cache maps league name -> (league_id, confidence) # Cache maps league name -> (league_id, confidence)
@@ -179,31 +96,40 @@ class TicketResolver:
def _resolve_bet(self, bet: Bet) -> ResolvedBet: def _resolve_bet(self, bet: Bet) -> ResolvedBet:
bet_type = type(bet).__name__ bet_type = type(bet).__name__
print(f"\n {_B}{_CYAN}┌─ [{bet_type}]{_R} {_B}{bet.team1Name} vs {bet.team2Name}{_R}" print(f"\n {_ansi.bold(_ansi.cyan(f'┌─ [{bet_type}]'))} {_ansi.bold(f'{bet.team1Name} vs {bet.team2Name}')}"
f" {_DIM}{bet.date.strftime('%Y-%m-%d')} | {bet.league}{_R}") f" {_ansi.dim(f'{bet.date.strftime('%Y-%m-%d')} | {bet.league}')}")
if isinstance(bet, UnknownTicket): if isinstance(bet, UnknownTicket):
print(f" {_GRAY}│ skipping — not implemented: {bet.raw_text!r}{_R}") print(_ansi.gray(f" │ skipping — not implemented: {bet.raw_text!r}"))
print(f" {_GRAY}└─ UNKNOWN{_R}") print(_ansi.gray(" └─ UNKNOWN"))
return ResolvedBet(bet=bet, outcome=BetOutcome.UNKNOWN) return ResolvedBet(bet=bet, outcome=BetOutcome.UNKNOWN)
fixture, name_match, date_prox, league_conf = self._find_fixture(bet) fixture, name_match, date_prox, league_conf = self._find_fixture(bet)
if fixture is None: 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) return ResolvedBet(bet=bet, outcome=BetOutcome.UNKNOWN, league_found=league_conf)
home_name = fixture["teams"]["home"]["name"] home_name = fixture["teams"]["home"]["name"]
away_name = fixture["teams"]["away"]["name"] away_name = fixture["teams"]["away"]["name"]
finished = _is_finished(fixture) finished = _is_finished(fixture)
confidence = round((name_match + date_prox + league_conf + finished) / 4, 3) 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"] goals = fixture["goals"]
color = _OUTCOME_COLOR.get(outcome.value, _GRAY) print(_ansi.dim(
print(f" {_DIM}│ matched #{fixture['fixture']['id']}: {home_name} vs {away_name}" f" │ matched #{fixture['fixture']['id']}: {home_name} vs {away_name}"
f" | {goals['home']}:{goals['away']} | {fixture['fixture']['status']['short']}" 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}") f" | confidence {confidence} (name={name_match:.2f} date={date_prox:.2f} league={league_conf} finished={finished})"
print(f" {color}{_B}└─ {outcome.value.upper()}{_R}") ))
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( return ResolvedBet(
bet=bet, bet=bet,
@@ -230,13 +156,13 @@ class TicketResolver:
if league_id is not None: if league_id is not None:
params["league"] = league_id params["league"] = league_id
params["season"] = center.year if center.month >= 7 else center.year - 1 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 = _get(f"{_API_BASE}/fixtures", headers=self._headers, params=params)
resp.raise_for_status() resp.raise_for_status()
self._fixture_cache[cache_key] = resp.json().get("response", []) 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: 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( fixture, name_match, date_prox = _best_fixture_match(
self._fixture_cache[cache_key], bet.team1Name, bet.team2Name, center self._fixture_cache[cache_key], bet.team1Name, bet.team2Name, center
@@ -248,28 +174,57 @@ class TicketResolver:
if key in self._league_cache: if key in self._league_cache:
return self._league_cache[key] 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: 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) self._league_cache[key] = (league_id, 1.0)
return league_id, 1.0 return league_id, 1.0
# Fall back to API search — lower confidence since first result is taken unverified # 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]}) resp = _get(f"{_API_BASE}/leagues", headers=self._headers, params={"search": league_name[:20]})
results = resp.json().get("response", []) results = resp.json().get("response", [])
if results: if results:
league_id = results[0]["league"]["id"] league_id = results[0]["league"]["id"]
league_found_name = results[0]["league"]["name"] 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) self._league_cache[key] = (league_id, 0.7)
return 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) self._league_cache[key] = (None, 0.3)
return 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: def _similarity(a: str, b: str) -> float:
return SequenceMatcher(None, a.lower(), b.lower()).ratio() 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: def _is_finished(fixture: dict[str, Any]) -> float:
status = fixture.get("fixture", {}).get("status", {}).get("short", "") status = fixture.get("fixture", {}).get("status", {}).get("short", "")
return 1.0 if status in ("FT", "AET", "PEN", "AWD", "WO") else 0.0 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