Files
lottosight/ui/predictor_ui.py
nngo c05867a642
LottoSight CI / Syntax Check & Tests (push) Has been cancelled
05/23 update codes, add build scripts
2026-05-23 18:16:56 -04:00

972 lines
39 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
ui/predictor_ui.py
------------------
Prediction generator screen — four tabs inside a ttk.Notebook.
Generate tab — pick strategy + game + count → generate tickets → save to DB
Saved tab — browse all saved predictions, see match count vs last draw
Check Ticket tab — compare a user ticket against all historical draws
Wheel tab — full-cover wheeling: pick N numbers → all C(N,k) tickets
"""
import os
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import logging
from db.models import (
get_all_games, get_game_by_name, get_last_draw,
insert_prediction, get_predictions,
delete_prediction, delete_all_predictions,
)
from core.predictor import (
hot_numbers, due_numbers, weighted_random, monte_carlo,
positional_pick, ensemble, quick_pick,
)
from core.checker import check_ticket, parse_numbers
from core.wheeling import wheel_full, wheel_count, MAX_TICKETS
from core.exporter import export_predictions_excel, export_predictions_csv, ensure_exports_dir
from ui.widgets import BallsBar
logger = logging.getLogger(__name__)
_STRATEGIES = {
"Ensemble": ensemble,
"Hot Numbers": hot_numbers,
"Due Numbers": due_numbers,
"Weighted Random": weighted_random,
"Monte Carlo": monte_carlo,
"Positional": positional_pick,
"Quick Pick": quick_pick,
}
_DESCRIPTIONS = {
"Ensemble": "Runs all 5 strategies and picks numbers with the most cross-strategy votes.",
"Hot Numbers": "Top 5 most frequent numbers from the last 100 draws (recency-weighted).",
"Due Numbers": "Numbers most overdue based on expected frequency gap.",
"Weighted Random": "Random pick weighted by recency-adjusted historical frequency.",
"Monte Carlo": "10,000 simulated draws — pick the most often-selected numbers.",
"Positional": "Most frequent number at each draw position (15).",
"Quick Pick": "Pure random selection — no historical data required.",
}
_TICKET_COUNTS = [str(n) for n in range(1, 11)]
def _parse_raw_numbers(raw: str) -> list[int]:
"""Parse space- or comma-separated integers from a string, ignoring non-numeric tokens."""
result = []
for tok in raw.replace(",", " ").split():
try:
result.append(int(tok))
except ValueError:
pass
return result
def _count_matches(pred_numbers: str, last_draw) -> str:
"""Compare prediction numbers to last draw, return 'X/5' string."""
if last_draw is None:
return "—"
try:
pred_set = {int(n) for n in pred_numbers.split(",")}
draw_set = {int(n) for n in last_draw["numbers"].split(",")}
matched = len(pred_set & draw_set)
return f"{matched}/{len(pred_set)}"
except Exception:
return "—"
class PredictorScreen(ttk.Frame):
def __init__(self, parent, **kwargs):
super().__init__(parent, **kwargs)
self._game_id: int | None = None
self._tickets: list[dict] = []
self._strategy_name: str = "Hot Numbers"
self._wheel_tickets: list[list[int]] = []
self._build_ui()
# ── UI construction ───────────────────────────────────────────────────────
def _build_ui(self):
nb = ttk.Notebook(self)
nb.pack(fill="both", expand=True)
gen_frame = ttk.Frame(nb)
saved_frame = ttk.Frame(nb)
check_frame = ttk.Frame(nb)
wheel_frame = ttk.Frame(nb)
nb.add(gen_frame, text="Generate")
nb.add(saved_frame, text="Saved")
nb.add(check_frame, text="Check Ticket")
nb.add(wheel_frame, text="Wheel")
self._build_generate_tab(gen_frame)
self._build_saved_tab(saved_frame)
self._build_check_tab(check_frame)
self._build_wheel_tab(wheel_frame)
# ── Generate tab ──────────────────────────────────────────────────────────
def _build_generate_tab(self, parent):
bar = ttk.Frame(parent, padding=(6, 6, 6, 4))
bar.pack(fill="x")
ttk.Label(bar, text="Game:").pack(side="left")
self._game_var = tk.StringVar()
self._game_cb = ttk.Combobox(
bar, textvariable=self._game_var, state="readonly", width=15
)
self._game_cb.pack(side="left", padx=(4, 14))
self._game_cb.bind("<<ComboboxSelected>>", lambda _: self._on_game_change())
ttk.Label(bar, text="Strategy:").pack(side="left")
self._strategy_var = tk.StringVar(value="Hot Numbers")
strategy_cb = ttk.Combobox(
bar, textvariable=self._strategy_var,
values=list(_STRATEGIES.keys()),
state="readonly", width=16,
)
strategy_cb.pack(side="left", padx=(4, 14))
strategy_cb.bind("<<ComboboxSelected>>", lambda _: self._on_strategy_change())
ttk.Label(bar, text="Tickets:").pack(side="left")
self._count_var = tk.StringVar(value="1")
ttk.Combobox(
bar, textvariable=self._count_var,
values=_TICKET_COUNTS, state="readonly", width=4,
).pack(side="left", padx=(4, 0))
ttk.Label(bar, text="Exclude:", padding=(12, 0, 0, 0)).pack(side="left")
self._exclude_var = tk.StringVar()
ttk.Entry(bar, textvariable=self._exclude_var, width=14).pack(side="left", padx=(4, 0))
self._gen_btn = ttk.Button(bar, text="Generate", command=self._generate)
self._gen_btn.pack(side="left", padx=(12, 0))
# Results treeview
tree_frame = ttk.Frame(parent)
tree_frame.pack(fill="both", expand=True, padx=6, pady=(4, 0))
cols = ("#", "numbers", "bonus")
self._tree = ttk.Treeview(
tree_frame, columns=cols, show="headings", selectmode="browse"
)
self._tree.heading("#", text="#")
self._tree.heading("numbers", text="Numbers")
self._tree.heading("bonus", text="Bonus")
self._tree.column("#", width=40, anchor="center", stretch=False)
self._tree.column("numbers", width=280, anchor="w")
self._tree.column("bonus", width=70, anchor="center", stretch=False)
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._tree.yview)
self._tree.configure(yscrollcommand=vsb.set)
self._tree.pack(side="left", fill="both", expand=True)
vsb.pack(side="right", fill="y")
self._tree.bind("<<TreeviewSelect>>", self._on_gen_row_select)
# ── Ball detail strip ─────────────────────────────────────────────────
ttk.Separator(parent, orient="horizontal").pack(fill="x", padx=6)
self._gen_detail = ttk.Frame(parent, padding=(8, 3))
self._gen_detail.pack(fill="x")
# Bottom bar
bottom = ttk.Frame(parent, padding=(6, 4))
bottom.pack(fill="x")
self._desc_var = tk.StringVar(value=_DESCRIPTIONS["Hot Numbers"])
ttk.Label(
bottom, textvariable=self._desc_var,
foreground="#555555", anchor="w",
).pack(side="left", fill="x", expand=True)
self._status_var = tk.StringVar()
ttk.Label(bottom, textvariable=self._status_var,
foreground="#27ae60").pack(side="left", padx=(8, 0))
self._save_btn = ttk.Button(
bottom, text="Save to DB", command=self._save, state="disabled"
)
self._save_btn.pack(side="right")
self._copy_btn = ttk.Button(
bottom, text="Copy", command=self._copy_tickets, state="disabled"
)
self._copy_btn.pack(side="right", padx=(0, 4))
ttk.Button(bottom, text="Clear", command=self._clear
).pack(side="right", padx=(0, 4))
ttk.Button(bottom, text="Export CSV", command=self._export_csv
).pack(side="right", padx=(0, 4))
ttk.Button(bottom, text="Export Excel", command=self._export_excel
).pack(side="right", padx=(0, 4))
# ── Saved tab ─────────────────────────────────────────────────────────────
def _build_saved_tab(self, parent):
# Filter + action bar
bar = ttk.Frame(parent, padding=(6, 6, 6, 4))
bar.pack(fill="x")
ttk.Label(bar, text="Game:").pack(side="left")
self._saved_game_var = tk.StringVar(value="All Games")
self._saved_game_cb = ttk.Combobox(
bar, textvariable=self._saved_game_var, state="readonly", width=15
)
self._saved_game_cb.pack(side="left", padx=(4, 14))
self._saved_game_cb.bind("<<ComboboxSelected>>", lambda _: self._refresh_saved())
ttk.Button(bar, text="↻ Refresh", command=self._refresh_saved).pack(side="left", padx=(0, 4))
ttk.Button(bar, text="Delete Selected", command=self._delete_selected).pack(side="left", padx=(0, 4))
ttk.Button(bar, text="Clear All", command=self._clear_all_saved).pack(side="left")
self._saved_count_var = tk.StringVar(value="0 saved")
ttk.Label(bar, textvariable=self._saved_count_var,
foreground="#777777").pack(side="right")
# Treeview
tree_frame = ttk.Frame(parent)
tree_frame.pack(fill="both", expand=True, padx=6, pady=(2, 6))
s_cols = ("id", "game", "strategy", "numbers", "bonus", "matches", "saved")
self._saved_tree = ttk.Treeview(
tree_frame, columns=s_cols, show="headings", selectmode="extended"
)
self._saved_tree.heading("id", text="ID")
self._saved_tree.heading("game", text="Game")
self._saved_tree.heading("strategy", text="Strategy")
self._saved_tree.heading("numbers", text="Numbers")
self._saved_tree.heading("bonus", text="Bonus")
self._saved_tree.heading("matches", text="Matches")
self._saved_tree.heading("saved", text="Saved")
self._saved_tree.column("id", width=45, anchor="center", stretch=False)
self._saved_tree.column("game", width=120, anchor="w", stretch=False)
self._saved_tree.column("strategy", width=120, anchor="w", stretch=False)
self._saved_tree.column("numbers", width=220, anchor="w")
self._saved_tree.column("bonus", width=55, anchor="center", stretch=False)
self._saved_tree.column("matches", width=65, anchor="center", stretch=False)
self._saved_tree.column("saved", width=130, anchor="center", stretch=False)
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._saved_tree.yview)
hsb = ttk.Scrollbar(tree_frame, orient="horizontal", command=self._saved_tree.xview)
self._saved_tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self._saved_tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
hsb.grid(row=1, column=0, sticky="ew")
tree_frame.rowconfigure(0, weight=1)
tree_frame.columnconfigure(0, weight=1)
# Colour rows by match count
self._saved_tree.tag_configure("match_good", foreground="#1e8449")
self._saved_tree.tag_configure("match_zero", foreground="#aaaaaa")
# ── Refresh / data ────────────────────────────────────────────────────────
def refresh(self):
self._load_games()
self._load_check_games()
self._load_wheel_games()
self._refresh_saved()
def _load_games(self):
games = get_all_games(active_only=True)
names = [g["name"] for g in games]
self._game_cb["values"] = names
if not self._game_var.get() or self._game_var.get() not in names:
if names:
self._game_var.set(names[0])
game = get_game_by_name(self._game_var.get())
self._game_id = game["id"] if game else None
saved_names = ["All Games"] + names
self._saved_game_cb["values"] = saved_names
if self._saved_game_var.get() not in saved_names:
self._saved_game_var.set("All Games")
def _refresh_saved(self):
name = self._saved_game_var.get()
game_id = None
if name != "All Games":
g = get_game_by_name(name)
game_id = g["id"] if g else None
preds = get_predictions(game_id=game_id, limit=100_000)
# Cache last draw per game to avoid repeated DB hits
last_draw_cache: dict[int, object] = {}
self._saved_tree.delete(*self._saved_tree.get_children())
for p in preds:
gid = p["game_id"]
if gid not in last_draw_cache:
last_draw_cache[gid] = get_last_draw(gid)
last = last_draw_cache[gid]
match_str = _count_matches(p["numbers"], last)
nums_fmt = " ".join(f"{int(n):02d}" for n in p["numbers"].split(","))
saved_ts = p["created_at"][:16].replace("T", " ")
try:
matched_n = int(match_str.split("/")[0])
tag = "match_good" if matched_n > 0 else "match_zero"
except Exception:
tag = ""
self._saved_tree.insert("", "end",
iid=str(p["id"]),
values=(
p["id"],
p["game_name"],
p["strategy"],
nums_fmt,
p["bonus"] or "—",
match_str,
saved_ts,
),
tags=(tag,),
)
count = len(preds)
self._saved_count_var.set(f"{count} saved prediction{'s' if count != 1 else ''}")
# ── Generate tab callbacks ────────────────────────────────────────────────
def _on_game_change(self):
game = get_game_by_name(self._game_var.get())
self._game_id = game["id"] if game else None
self._clear()
def _on_strategy_change(self):
self._strategy_name = self._strategy_var.get()
self._desc_var.set(_DESCRIPTIONS.get(self._strategy_name, ""))
self._clear()
def _generate(self):
if self._game_id is None:
self._status_var.set("Select a game first.")
return
strategy_fn = _STRATEGIES[self._strategy_var.get()]
count = int(self._count_var.get())
self._clear()
self._gen_btn.config(state="disabled", text="Generating…")
self.update_idletasks()
try:
excl = self._parse_exclude()
tickets = [strategy_fn(self._game_id, exclude=excl) for _ in range(count)]
self._tickets = tickets
self._display(tickets)
self._save_btn.config(state="normal")
self._copy_btn.config(state="normal")
self._status_var.set(f"{len(tickets)} ticket{'s' if len(tickets) != 1 else ''} generated.")
logger.info("[PREDICT] %d ticket(s) generated via %s", count, self._strategy_var.get())
except Exception as e:
self._status_var.set(f"Error: {e}")
logger.error("[ERROR] Prediction failed: %s", e, exc_info=True)
finally:
self._gen_btn.config(state="normal", text="Generate")
def _display(self, tickets):
self._tree.delete(*self._tree.get_children())
game = get_game_by_name(self._game_var.get())
show_bonus = game is not None and game["bonus_count"] > 0
for i, t in enumerate(tickets, start=1):
nums_str = " ".join(f"{n:02d}" for n in t["numbers"])
bonus_str = str(t["bonus"]) if show_bonus and t["bonus"] is not None else "—"
self._tree.insert("", "end", values=(i, nums_str, bonus_str))
def _save(self):
if not self._tickets or self._game_id is None:
return
strategy_name = self._strategy_var.get()
saved = 0
for t in self._tickets:
insert_prediction(self._game_id, strategy_name, t["numbers"], t["bonus"])
saved += 1
self._status_var.set(f"Saved {saved} prediction{'s' if saved != 1 else ''} to DB.")
self._save_btn.config(state="disabled")
self._refresh_saved()
logger.info("[PREDICT] Saved %d prediction(s) to DB", saved)
def _parse_exclude(self) -> set:
raw = self._exclude_var.get().strip()
if not raw:
return set()
result = set()
for tok in raw.replace(",", " ").split():
try:
result.add(int(tok))
except ValueError:
pass
return result
def _copy_tickets(self):
if not self._tickets:
return
game = get_game_by_name(self._game_var.get())
show_bonus = game is not None and game["bonus_count"] > 0
lines = []
for i, t in enumerate(self._tickets, 1):
nums = " ".join(f"{n:02d}" for n in t["numbers"])
line = f"Ticket {i}: {nums}"
if show_bonus and t["bonus"] is not None:
line += f" + {t['bonus']:02d}"
lines.append(line)
self.clipboard_clear()
self.clipboard_append("\n".join(lines))
self._status_var.set("Copied to clipboard.")
def _on_gen_row_select(self, _event=None):
for w in self._gen_detail.winfo_children():
w.destroy()
sel = self._tree.selection()
if not sel:
return
vals = self._tree.item(sel[0], "values")
# vals: (#, numbers_fmt, bonus)
try:
nums = [int(x) for x in str(vals[1]).split() if x.isdigit()]
b_txt = str(vals[2])
bonus = int(b_txt) if b_txt.isdigit() else None
except Exception:
return
if nums:
BallsBar(self._gen_detail, numbers=nums, bonus=bonus).pack(anchor="w")
def _clear(self):
self._tickets = []
self._tree.delete(*self._tree.get_children())
for w in self._gen_detail.winfo_children():
w.destroy()
self._save_btn.config(state="disabled")
self._copy_btn.config(state="disabled")
self._status_var.set("")
def _export_excel(self):
ensure_exports_dir()
fp = filedialog.asksaveasfilename(
title="Export predictions",
defaultextension=".xlsx",
filetypes=[("Excel workbook", "*.xlsx")],
initialfile=f"predictions_{__import__('datetime').datetime.now().strftime('%Y%m%d')}.xlsx",
)
if not fp:
return
try:
export_predictions_excel(fp, game_id=self._game_id)
self._status_var.set(f"Exported → {os.path.basename(fp)}")
except Exception as e:
messagebox.showerror("Export failed", str(e))
def _export_csv(self):
ensure_exports_dir()
fp = filedialog.asksaveasfilename(
title="Export predictions",
defaultextension=".csv",
filetypes=[("CSV file", "*.csv")],
initialfile=f"predictions_{__import__('datetime').datetime.now().strftime('%Y%m%d')}.csv",
)
if not fp:
return
try:
export_predictions_csv(fp, game_id=self._game_id)
self._status_var.set(f"Exported → {os.path.basename(fp)}")
except Exception as e:
messagebox.showerror("Export failed", str(e))
# ── Saved tab callbacks ───────────────────────────────────────────────────
def _delete_selected(self):
selected = self._saved_tree.selection()
if not selected:
return
for iid in selected:
pred_id = int(self._saved_tree.item(iid, "values")[0])
delete_prediction(pred_id)
self._refresh_saved()
def _clear_all_saved(self):
name = self._saved_game_var.get()
game_id = None
if name != "All Games":
g = get_game_by_name(name)
game_id = g["id"] if g else None
scope = f" for {name}" if game_id else ""
if not messagebox.askyesno("Clear All",
f"Delete all saved predictions{scope}?"):
return
delete_all_predictions(game_id=game_id)
self._refresh_saved()
# ── Check Ticket tab ──────────────────────────────────────────────────────
def _build_check_tab(self, parent):
# Input bar
bar = ttk.Frame(parent, padding=(6, 8, 6, 4))
bar.pack(fill="x")
ttk.Label(bar, text="Game:").pack(side="left")
self._chk_game_var = tk.StringVar()
self._chk_game_cb = ttk.Combobox(
bar, textvariable=self._chk_game_var, state="readonly", width=15
)
self._chk_game_cb.pack(side="left", padx=(4, 14))
ttk.Label(bar, text="Numbers:").pack(side="left")
self._chk_nums_var = tk.StringVar()
chk_nums_entry = ttk.Entry(bar, textvariable=self._chk_nums_var, width=22)
chk_nums_entry.pack(side="left", padx=(4, 4))
chk_nums_entry.bind("<Return>", lambda e: self._run_check())
ttk.Label(bar, text="Bonus:").pack(side="left")
self._chk_bonus_var = tk.StringVar()
chk_bonus_entry = ttk.Entry(bar, textvariable=self._chk_bonus_var, width=5)
chk_bonus_entry.pack(side="left", padx=(4, 14))
chk_bonus_entry.bind("<Return>", lambda e: self._run_check())
ttk.Button(bar, text="Check", command=self._run_check).pack(side="left", padx=(0, 4))
ttk.Button(bar, text="Clear", command=self._clear_check).pack(side="left")
self._chk_status_var = tk.StringVar()
ttk.Label(bar, textvariable=self._chk_status_var,
foreground="#c0392b").pack(side="left", padx=(12, 0))
# Hint text below bar
hint = ttk.Frame(parent, padding=(6, 0, 6, 4))
hint.pack(fill="x")
ttk.Label(hint,
text="Enter space- or comma-separated main numbers, and optionally a bonus number.",
foreground="#888888", font=("TkDefaultFont", 8)).pack(anchor="w")
# Results treeview
tree_frame = ttk.Frame(parent)
tree_frame.pack(fill="both", expand=True, padx=6, pady=(0, 4))
c_cols = ("date", "draw_numbers", "bonus", "main", "bonus_hit", "tier")
self._chk_tree = ttk.Treeview(
tree_frame, columns=c_cols, show="headings", selectmode="browse"
)
self._chk_tree.heading("date", text="Draw Date")
self._chk_tree.heading("draw_numbers", text="Draw Numbers")
self._chk_tree.heading("bonus", text="Bonus")
self._chk_tree.heading("main", text="Main Hits")
self._chk_tree.heading("bonus_hit", text="Bonus Hit")
self._chk_tree.heading("tier", text="Prize Tier")
self._chk_tree.column("date", width=100, anchor="center", stretch=False)
self._chk_tree.column("draw_numbers", width=190, anchor="w")
self._chk_tree.column("bonus", width=55, anchor="center", stretch=False)
self._chk_tree.column("main", width=70, anchor="center", stretch=False)
self._chk_tree.column("bonus_hit", width=70, anchor="center", stretch=False)
self._chk_tree.column("tier", width=160, anchor="w", stretch=False)
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._chk_tree.yview)
hsb = ttk.Scrollbar(tree_frame, orient="horizontal", command=self._chk_tree.xview)
self._chk_tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self._chk_tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
hsb.grid(row=1, column=0, sticky="ew")
tree_frame.rowconfigure(0, weight=1)
tree_frame.columnconfigure(0, weight=1)
# Row colour tags
self._chk_tree.tag_configure("jackpot", foreground="#8e44ad", font=("TkDefaultFont", 9, "bold"))
self._chk_tree.tag_configure("high", foreground="#1e8449")
self._chk_tree.tag_configure("low", foreground="#555555")
self._chk_tree.bind("<<TreeviewSelect>>", self._on_chk_row_select)
# ── Ball detail strip ─────────────────────────────────────────────────
ttk.Separator(parent, orient="horizontal").pack(fill="x", padx=6)
self._chk_detail = ttk.Frame(parent, padding=(8, 3))
self._chk_detail.pack(fill="x")
# Summary label
self._chk_summary_var = tk.StringVar()
ttk.Label(parent, textvariable=self._chk_summary_var,
anchor="e", padding=(6, 2)).pack(fill="x")
def _run_check(self):
self._chk_status_var.set("")
self._chk_tree.delete(*self._chk_tree.get_children())
self._chk_summary_var.set("")
# Resolve game
game = get_game_by_name(self._chk_game_var.get())
if game is None:
self._chk_status_var.set("Select a game first.")
return
# Parse main numbers
try:
numbers = parse_numbers(self._chk_nums_var.get())
except ValueError as e:
self._chk_status_var.set(str(e))
return
if len(numbers) != game["main_count"]:
self._chk_status_var.set(
f"{game['name']} requires {game['main_count']} main numbers "
f"(got {len(numbers)})."
)
return
# Validate range
invalid = [n for n in numbers if not (1 <= n <= game["main_max"])]
if invalid:
self._chk_status_var.set(
f"Numbers out of range 1{game['main_max']}: {invalid}"
)
return
# Parse optional bonus
bonus = None
raw_bonus = self._chk_bonus_var.get().strip()
if raw_bonus:
if not raw_bonus.isdigit():
self._chk_status_var.set("Bonus must be a number.")
return
bonus = int(raw_bonus)
if game["bonus_max"] and not (1 <= bonus <= game["bonus_max"]):
self._chk_status_var.set(
f"Bonus out of range 1{game['bonus_max']}."
)
return
results = check_ticket(game["id"], numbers, bonus=bonus)
for r in results:
nums_fmt = " ".join(f"{n:02d}" for n in r["draw_numbers"])
bonus_str = str(r["draw_bonus"]) if r["draw_bonus"] is not None else "—"
bonus_hit = "✓" if r["bonus_match"] else "—"
tier = r["prize_tier"]
if tier == "Jackpot":
tag = "jackpot"
elif r["main_matches"] >= 3 or r["bonus_match"]:
tag = "high"
else:
tag = "low"
self._chk_tree.insert("", "end", values=(
r["draw_date"], nums_fmt, bonus_str,
r["main_matches"], bonus_hit, tier,
), tags=(tag,))
total = len(results)
best = results[0]["prize_tier"] if results else "—"
self._chk_summary_var.set(
f"{total} draw{'s' if total != 1 else ''} matched • Best: {best}"
)
def _on_chk_row_select(self, _event=None):
for w in self._chk_detail.winfo_children():
w.destroy()
sel = self._chk_tree.selection()
if not sel:
return
vals = self._chk_tree.item(sel[0], "values")
# vals: (date, draw_numbers_fmt, bonus, main_hits, bonus_hit, tier)
try:
draw_nums = [int(x) for x in str(vals[1]).split() if x.isdigit()]
db_txt = str(vals[2])
draw_bonus = int(db_txt) if db_txt.isdigit() else None
except Exception:
return
if not draw_nums:
return
# Ticket numbers from the input entries
try:
ticket_nums = parse_numbers(self._chk_nums_var.get())
except Exception:
ticket_nums = []
raw_b = self._chk_bonus_var.get().strip()
ticket_bonus = int(raw_b) if raw_b.isdigit() else None
ticket_set = set(ticket_nums)
draw_set = set(draw_nums)
_GREEN = ("#27ae60", "#ffffff")
_GREY = ("#cccccc", "#888888")
ticket_hi = {n: (_GREEN if n in draw_set else _GREY) for n in ticket_nums}
draw_hi = {n: _GREEN for n in draw_nums if n in ticket_set}
if ticket_bonus is not None:
ticket_hi[ticket_bonus] = (
_GREEN if draw_bonus is not None and ticket_bonus == draw_bonus else _GREY
)
if draw_bonus is not None and ticket_bonus is not None and draw_bonus == ticket_bonus:
draw_hi[draw_bonus] = _GREEN
grid = ttk.Frame(self._chk_detail)
grid.pack(anchor="w")
if ticket_nums:
ttk.Label(grid, text="Ticket:", foreground="#555555",
font=("TkDefaultFont", 8), width=7).grid(row=0, column=0, sticky="w")
BallsBar(grid, numbers=ticket_nums, bonus=ticket_bonus,
highlights=ticket_hi).grid(row=0, column=1, sticky="w")
ttk.Label(grid, text="Draw:", foreground="#555555",
font=("TkDefaultFont", 8), width=7).grid(row=1, column=0, sticky="w")
BallsBar(grid, numbers=draw_nums, bonus=draw_bonus,
highlights=draw_hi).grid(row=1, column=1, sticky="w")
def _clear_check(self):
self._chk_nums_var.set("")
self._chk_bonus_var.set("")
self._chk_status_var.set("")
self._chk_tree.delete(*self._chk_tree.get_children())
for w in self._chk_detail.winfo_children():
w.destroy()
self._chk_summary_var.set("")
# ── Wheel tab ─────────────────────────────────────────────────────────────
def _build_wheel_tab(self, parent):
bar = ttk.Frame(parent, padding=(6, 8, 6, 4))
bar.pack(fill="x")
ttk.Label(bar, text="Game:").pack(side="left")
self._whl_game_var = tk.StringVar()
self._whl_game_cb = ttk.Combobox(
bar, textvariable=self._whl_game_var, state="readonly", width=15
)
self._whl_game_cb.pack(side="left", padx=(4, 14))
self._whl_game_cb.bind("<<ComboboxSelected>>", lambda _: self._on_wheel_game_change())
ttk.Label(bar, text="Numbers:").pack(side="left")
self._whl_nums_var = tk.StringVar()
whl_entry = ttk.Entry(bar, textvariable=self._whl_nums_var, width=28)
whl_entry.pack(side="left", padx=(4, 4))
whl_entry.bind("<Return>", lambda e: self._run_wheel())
self._whl_nums_var.trace_add("write", lambda *_: self._update_wheel_preview())
ttk.Label(bar, text="Pick:").pack(side="left")
self._whl_pick_var = tk.IntVar(value=5)
self._whl_pick_sb = ttk.Spinbox(
bar, from_=1, to=10, textvariable=self._whl_pick_var, width=4,
command=self._update_wheel_preview,
)
self._whl_pick_sb.pack(side="left", padx=(4, 14))
self._whl_pick_var.trace_add("write", lambda *_: self._update_wheel_preview())
self._whl_btn = ttk.Button(bar, text="Generate Wheel", command=self._run_wheel)
self._whl_btn.pack(side="left", padx=(0, 4))
ttk.Button(bar, text="Clear", command=self._clear_wheel).pack(side="left")
# Preview + hint
hint = ttk.Frame(parent, padding=(6, 0, 6, 4))
hint.pack(fill="x")
self._whl_preview_var = tk.StringVar(
value=f"Enter more than pick count numbers. Max {MAX_TICKETS} tickets."
)
ttk.Label(hint, textvariable=self._whl_preview_var,
foreground="#888888", font=("TkDefaultFont", 8)).pack(anchor="w")
# Results treeview
tree_frame = ttk.Frame(parent)
tree_frame.pack(fill="both", expand=True, padx=6, pady=(0, 4))
w_cols = ("#", "numbers")
self._whl_tree = ttk.Treeview(
tree_frame, columns=w_cols, show="headings", selectmode="browse"
)
self._whl_tree.heading("#", text="#")
self._whl_tree.heading("numbers", text="Numbers")
self._whl_tree.column("#", width=50, anchor="center", stretch=False)
self._whl_tree.column("numbers", width=300, anchor="w")
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._whl_tree.yview)
self._whl_tree.configure(yscrollcommand=vsb.set)
self._whl_tree.pack(side="left", fill="both", expand=True)
vsb.pack(side="right", fill="y")
self._whl_tree.bind("<<TreeviewSelect>>", self._on_wheel_row_select)
# Ball detail strip
ttk.Separator(parent, orient="horizontal").pack(fill="x", padx=6)
self._whl_detail = ttk.Frame(parent, padding=(8, 3))
self._whl_detail.pack(fill="x")
# Bottom bar
bottom = ttk.Frame(parent, padding=(6, 4))
bottom.pack(fill="x")
self._whl_status_var = tk.StringVar()
ttk.Label(bottom, textvariable=self._whl_status_var,
foreground="#27ae60").pack(side="left", fill="x", expand=True)
self._whl_save_btn = ttk.Button(
bottom, text="Save to DB", command=self._save_wheel, state="disabled"
)
self._whl_save_btn.pack(side="right")
self._whl_copy_btn = ttk.Button(
bottom, text="Copy", command=self._copy_wheel, state="disabled"
)
self._whl_copy_btn.pack(side="right", padx=(0, 4))
def _load_wheel_games(self):
games = get_all_games(active_only=True)
names = [g["name"] for g in games]
self._whl_game_cb["values"] = names
if not self._whl_game_var.get() or self._whl_game_var.get() not in names:
if names:
self._whl_game_var.set(names[0])
self._on_wheel_game_change()
def _on_wheel_game_change(self):
game = get_game_by_name(self._whl_game_var.get())
if game:
self._whl_pick_var.set(game["main_count"])
self._clear_wheel()
self._update_wheel_preview()
def _update_wheel_preview(self):
raw = self._whl_nums_var.get().strip()
nums = _parse_raw_numbers(raw)
try:
k = int(self._whl_pick_var.get())
except (ValueError, tk.TclError):
self._whl_preview_var.set("Invalid pick count.")
return
if not nums:
self._whl_preview_var.set(
f"Enter more than pick count numbers. Max {MAX_TICKETS} tickets."
)
return
n = len(set(nums))
if k < 1 or k > n:
self._whl_preview_var.set(f"Pick count must be between 1 and {n}.")
return
from math import comb
count = comb(n, k)
if count > MAX_TICKETS:
self._whl_preview_var.set(
f"Would generate {count:,} tickets — exceeds limit of {MAX_TICKETS}. "
f"Reduce pool or pick count."
)
else:
self._whl_preview_var.set(
f"Will generate {count} ticket{'s' if count != 1 else ''} (C({n},{k}))."
)
def _run_wheel(self):
self._whl_status_var.set("")
raw = self._whl_nums_var.get().strip()
nums = _parse_raw_numbers(raw)
if not nums:
self._whl_status_var.set("Enter numbers first.")
return
game = get_game_by_name(self._whl_game_var.get())
if game is None:
self._whl_status_var.set("Select a game first.")
return
try:
k = int(self._whl_pick_var.get())
except (ValueError, tk.TclError):
self._whl_status_var.set("Invalid pick count.")
return
# Validate number ranges
bad = [n for n in nums if not (1 <= n <= game["main_max"])]
if bad:
self._whl_status_var.set(
f"Numbers out of range 1{game['main_max']}: {sorted(set(bad))}"
)
return
try:
tickets = wheel_full(nums, k)
except ValueError as e:
self._whl_status_var.set(str(e))
return
self._wheel_tickets = tickets
self._whl_tree.delete(*self._whl_tree.get_children())
for w in self._whl_detail.winfo_children():
w.destroy()
for i, combo in enumerate(tickets, 1):
nums_str = " ".join(f"{n:02d}" for n in combo)
self._whl_tree.insert("", "end", values=(i, nums_str))
self._whl_save_btn.config(state="normal")
self._whl_copy_btn.config(state="normal")
n_pool = len(set(nums))
self._whl_status_var.set(
f"{len(tickets)} ticket{'s' if len(tickets) != 1 else ''} — "
f"full cover of {n_pool} numbers pick {k}."
)
logger.info("[WHEEL] Generated %d tickets from %d-number pool", len(tickets), n_pool)
def _on_wheel_row_select(self, _event=None):
for w in self._whl_detail.winfo_children():
w.destroy()
sel = self._whl_tree.selection()
if not sel:
return
vals = self._whl_tree.item(sel[0], "values")
try:
nums = [int(x) for x in str(vals[1]).split() if x.isdigit()]
except Exception:
return
if nums:
BallsBar(self._whl_detail, numbers=nums).pack(anchor="w")
def _save_wheel(self):
if not self._wheel_tickets:
return
game = get_game_by_name(self._whl_game_var.get())
if game is None:
return
for combo in self._wheel_tickets:
insert_prediction(game["id"], "Wheel", combo, bonus=None)
saved = len(self._wheel_tickets)
self._whl_status_var.set(f"Saved {saved} ticket{'s' if saved != 1 else ''} to DB.")
self._whl_save_btn.config(state="disabled")
self._refresh_saved()
logger.info("[WHEEL] Saved %d wheel tickets to DB", saved)
def _copy_wheel(self):
if not self._wheel_tickets:
return
lines = [
f"Ticket {i}: {' '.join(f'{n:02d}' for n in combo)}"
for i, combo in enumerate(self._wheel_tickets, 1)
]
self.clipboard_clear()
self.clipboard_append("\n".join(lines))
self._whl_status_var.set("Copied to clipboard.")
def _clear_wheel(self):
self._wheel_tickets = []
if hasattr(self, "_whl_tree"):
self._whl_tree.delete(*self._whl_tree.get_children())
if hasattr(self, "_whl_detail"):
for w in self._whl_detail.winfo_children():
w.destroy()
if hasattr(self, "_whl_save_btn"):
self._whl_save_btn.config(state="disabled")
if hasattr(self, "_whl_copy_btn"):
self._whl_copy_btn.config(state="disabled")
if hasattr(self, "_whl_status_var"):
self._whl_status_var.set("")
def _load_check_games(self):
games = get_all_games(active_only=True)
names = [g["name"] for g in games]
self._chk_game_cb["values"] = names
if not self._chk_game_var.get() or self._chk_game_var.get() not in names:
if names:
self._chk_game_var.set(names[0])