Add remaining bets classifiing and resolving

This commit is contained in:
2026-03-22 12:59:41 +01:00
parent ebf8c78c79
commit e50ca19b94
5 changed files with 217 additions and 11 deletions

View File

@@ -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)
- <Tým> 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

View File

@@ -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__":

View File

@@ -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"""

View File

@@ -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)

View File

@@ -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,
)