Add remaining bets classifiing and resolving
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user