Compare commits

..
10 Commits
Author SHA1 Message Date
nngo c05867a642 05/23 update codes, add build scripts
LottoSight CI / Syntax Check & Tests (push) Has been cancelled
2026-05-23 18:16:56 -04:00
nngo c61c09db94 05/23 update prediction accuration 2026-05-23 17:50:15 -04:00
nngo 59789f3cfe 05/23 Phase 22 2026-05-23 17:36:33 -04:00
nngo edd2ed5660 05/23 Update new games data 2026-05-23 17:14:23 -04:00
nngo c792d9878e 05/23 Add Pick 5, Millionair Balls, etc 2026-05-23 17:04:40 -04:00
nngo b5e03fbf10 05/23 Fix ci.yml 2026-05-23 16:44:47 -04:00
nngo ea30c7d838 05/23 Phase 20 2026-05-23 16:38:20 -04:00
nngo 2553406254 05/23 Phase 18,19 2026-05-23 16:31:55 -04:00
nngo 7506e94d61 05/23 Phase 16 2026-05-23 16:18:08 -04:00
nngo 57c777dc82 05/23 Phase 16 2026-05-23 16:11:16 -04:00
36 changed files with 3487 additions and 151 deletions
+18 -1
View File
@@ -14,7 +14,24 @@
"Bash(python -c \"from ui.dashboard import DashboardScreen\")",
"Bash(python -m pytest tests/test_analysis_charts.py -v --tb=short)",
"Bash(python -m pytest tests/ -q)",
"Bash(python -c ' *)"
"Bash(python -c ' *)",
"Bash(python -m pytest tests/test_backup.py -v)",
"Bash(python -m pytest --tb=short -q)",
"Bash(python -m pytest tests/test_wheeling.py -v)",
"WebFetch(domain:www.valottery.com)",
"Bash(pip install *)",
"Bash(python -m pytest tests/ -x -q)",
"Bash(python -m pytest tests/test_phase22.py -v)",
"Bash(python -m pytest tests/test_filters.py -v)",
"Bash(python -m pytest tests/test_recency_ensemble.py -v)",
"Bash(Get-ChildItem d:\\\\Projects\\\\lottosight -Filter \"*.spec\",\"*.bat\",\"*.ps1\")",
"PowerShell(Get-ChildItem d:\\\\Projects\\\\lottosight | Where-Object { $_.Extension -in \".spec\",\".bat\",\".ps1\" })",
"PowerShell(python --version)",
"PowerShell(python -m PyInstaller --version 2>&1)",
"PowerShell(powershell -NoProfile -ExecutionPolicy Bypass -File \"d:\\\\Projects\\\\lottosight\\\\build.ps1\" -WhatIf 2>&1)",
"PowerShell(powershell -NoProfile -ExecutionPolicy Bypass -Command \"& { . 'd:\\\\Projects\\\\lottosight\\\\build.ps1' -WhatIf }\" 2>&1 | head -5)",
"PowerShell($null = [System.Management.Automation.Language.Parser]::ParseFile\\(\"d:\\\\Projects\\\\lottosight\\\\build.ps1\", [ref]$null, [ref]$null\\); Write-Host \"Parse OK\")",
"PowerShell(Set-Location d:\\\\Projects\\\\lottosight; powershell -NoProfile -ExecutionPolicy Bypass -File \"build.ps1\" -SkipDeps 2>&1)"
]
}
}
+7 -3
View File
@@ -27,14 +27,18 @@ jobs:
restore-keys: |
${{ runner.os }}-pip-
# ── 4. Install dependencies ────────────────────────────────────────
# ── 4. Install system dependencies (Tk for UI tests) ─────────────
- name: Install system dependencies
run: sudo apt-get install -y python3-tk
# ── 5. Install Python dependencies ────────────────────────────────
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov
# ── 5. Syntax check — compile all .py files ────────────────────────
# ── 6. Syntax check — compile all .py files ────────────────────────
- name: Syntax check (py_compile)
run: |
echo "Running syntax check on all .py files..."
@@ -45,7 +49,7 @@ jobs:
| xargs python -m py_compile
echo "Syntax check passed."
# ── 6. Run pytest ──────────────────────────────────────────────────
# ── 7. Run pytest ──────────────────────────────────────────────────
- name: Run tests (pytest)
run: |
pytest tests/ \
+121
View File
@@ -341,6 +341,127 @@ All actions are logged to console and optionally to a log file:
---
### ✅ Phase 21 — Virginia Lottery Combo Games
- [x] Seed 3 new games in `db/database.py` `_seed_games()`
- Cash 5: `main_count=5, main_max=45, bonus_count=0`
- Millionaire for Life: `main_count=5, main_max=58, bonus_count=1, bonus_max=5`
- Bank a Million: `main_count=6, main_max=40, bonus_count=1, bonus_max=40`
- [x] Extend `_BUILTIN_GAMES` in `db/models.py` to include all 3 new games
- [x] Add `_fetch_va_lottery()` generic parser to `core/fetcher.py`
- Format: `M/D/YYYY; N1,N2,...[; Label: bonus]` — optional "Results for" header skipped
- API: `https://www.valottery.com/api/v1/downloadall?gameId=<id>`
- [x] Add `fetch_cash5_va()`, `fetch_millionaireforlife_va()`, `fetch_bankamillion_va()` to `core/fetcher.py`
- [x] Wire all 3 into `fetch_all()` (now 6 sources total)
- [x] Add 3 new source entries to `_ALL_SOURCES` / `_SOURCE_NAMES` in `ui/settings.py`
- [x] Update `tests/test_fetcher.py` — 16 new tests, fixed `fetch_all` count (2 → 6), 31 total
- [x] Fix pre-existing flaky Tkinter guard in `tests/test_dashboard.py`
- [x] Update hardcoded game-count assertions in `test_database.py` and `test_models.py`
- [x] 390/390 total tests passing
---
### ✅ Phase 20 — Number Wheeling System
- [x] Write `core/wheeling.py`
- [x] `wheel_count(numbers, k) -> int` — C(n, k) preview, deduplicates input
- [x] `wheel_full(numbers, k) -> list[list[int]]` — all combinations, each sorted ascending
- [x] Raises `ValueError` for invalid k, k > pool size, or count > `MAX_TICKETS` (200)
- [x] Update `ui/predictor_ui.py`
- [x] Add "Wheel" as 4th tab in ttk.Notebook
- [x] Game dropdown (synced on refresh), Numbers entry, Pick spinbox (defaults to game's main_count)
- [x] Live preview label: "Will generate X tickets (C(n,k))" updates as user types
- [x] Range validation against game's main_max before generating
- [x] Results treeview (#, Numbers) + BallsBar detail strip on row select
- [x] Save to DB (strategy="Wheel") + Copy + Clear buttons
- [x] Write `tests/test_wheeling.py` — 18 tests (379/379 total passing)
---
### ✅ Phase 22 — Incremental VA Fetch + Top Prize + Dashboard Filter + Auto-Check
- [x] Incremental VA fetch in `core/fetcher.py`
- [x] `_fetch_va_lottery()` calls `get_last_draw()` for last known date
- [x] Breaks out of parsing loop when `draw_date <= last_date` (data is newest-first)
- [x] Result: subsequent fetches only download new records, not all 10K+ rows
- [x] `top_prize` column in `db/database.py`
- [x] Added `top_prize TEXT NOT NULL DEFAULT ''` to `CREATE TABLE games`
- [x] ALTER TABLE migration for existing databases (checks via `PRAGMA table_info`)
- [x] Updated `_seed_games()` with prize strings for all 5 builtin games
- [x] Back-fills `top_prize` for existing rows where empty (safe to re-run)
- [x] Dashboard game filter in `ui/dashboard.py`
- [x] `_filter_var` combobox at top ("All Games" + active game names)
- [x] `_update_filter_options()` — keeps combobox in sync with active games
- [x] `_filtered_games()` — returns subset based on selection
- [x] Last Draws, Hot Numbers, Overdue sections all respect filter
- [x] Top prize display in last-draw cards (`ui/dashboard.py`)
- [x] Shows "Top prize: $X" in purple above draw date if `top_prize` is set
- [x] VA source names added to `ui/statusbar.py` `_SOURCE_NAMES`
- [x] `append_match_alert(msg)` method added to `StatusBar`
- [x] Auto-check predictions after fetch in `main.py`
- [x] `_check_predictions_vs_latest()` — compares all saved predictions against newest draw per game
- [x] Called from `_on_fetch_done()` when `total_added > 0`
- [x] Status bar shows alert when ≥1 prediction matches ≥2 main numbers or bonus
- [x] `tests/test_phase22.py` — 14 tests (403/403 total passing)
---
### ✅ Phase 19 — Dashboard Overdue Alert + DB Backup
- [x] Add `backup_db(dest_dir=None) -> str` to `db/database.py` — copies live DB to timestamped file, creates dest dir if needed
- [x] Add `restore_db(source_path: str) -> None` to `db/database.py` — overwrites live DB with backup file, raises FileNotFoundError if missing
- [x] Update `ui/dashboard.py`
- [x] Import `gap_analysis` from `core.analyzer`
- [x] Add "Most Overdue Numbers" section below Hot Numbers
- [x] `_refresh_overdue()` — top-5 highest-gap numbers per active game, shown as orange-highlighted `BallsBar` with gap counts
- [x] Called from `refresh()`
- [x] Update `ui/settings.py`
- [x] Import `backup_db, restore_db` from `db.database`
- [x] Add "Database Actions" section with Backup DB + Restore DB buttons
- [x] `_backup_db()` — runs backup, shows path in success dialog
- [x] `_restore_db()` — file dialog, confirm prompt, restores, advises restart
- [x] Write `tests/test_backup.py` — 10 tests (361/361 total passing)
---
### ✅ Phase 18 — Draw CSV Import
- [x] Write `core/importer.py`
- [x] `_parse_date(text)` — accepts ISO (YYYY-MM-DD), US slash (MM/DD/YYYY), US dash, day-first formats
- [x] `_is_header(row)` — heuristic: first cell is not a valid date
- [x] `_parse_row_lottosight` — handles LottoSight's own export format (Game, Date, Numbers, Bonus, …)
- [x] `_parse_row_generic` — handles wide format (Date, N1, N2, …) and packed format (Date, "N1,N2,…")
- [x] `import_draws_csv(game_id, filepath) -> {added, skipped, errors}` — validates count + range, calls `insert_draw`, deduplicates via existing DB logic
- [x] Update `ui/settings.py`
- [x] Add `filedialog` import
- [x] "Import CSV" button on every game row (builtin and custom)
- [x] `_import_csv(game_id, game_name)` — file dialog → import → show summary with error preview → refresh games
- [x] Write `tests/test_importer.py` — 23 tests (351/351 total passing)
---
### ✅ Phase 17 — Predictor Power Features
- [x] Add `quick_pick(game_id, exclude=None)` to `core/predictor.py` — pure random, no draw history required
- [x] Add `exclude: set | None = None` parameter to all 5 existing strategies + `_random_ticket` + `_fill_to_count`
- [x] Falls back silently to full pool if excluded numbers would leave fewer candidates than `main_count`
- [x] Add `_safe_pool(game, exclude)` helper used by weighted_random and monte_carlo
- [x] Update `ui/predictor_ui.py`
- [x] Add "Quick Pick" to `_STRATEGIES` and `_DESCRIPTIONS`
- [x] Add "Exclude:" entry field to Generate tab bar (comma/space-separated integers)
- [x] Pass parsed exclusion set to strategy on generate
- [x] Add "Copy" button — copies all generated tickets to clipboard as formatted text; enabled/disabled with generate/clear
- [x] Write `tests/test_quick_pick.py` — 20 tests (326/326 total passing)
---
### ✅ Phase 16 — Lottery Ball Display
- [x] Write `ui/widgets.py`
- [x] `ball_color(number, is_bonus) -> (bg, fg)` — range-based colour lookup (pure, no Tk)
- [x] `BallsBar(tk.Canvas)` — draws numbered balls; supports `highlights` dict for per-ball colour override; inherits parent background
- [x] Update `ui/dashboard.py` — last-draw cards use `BallsBar` for numbers; hot-numbers section uses small-radius (`r=11`) balls
- [x] Update `ui/history.py` — ball detail strip below treeview; shows `BallsBar` for selected row; clears on filter/populate
- [x] Update `ui/predictor_ui.py`
- [x] Generate tab: detail strip shows selected ticket as balls
- [x] Check Ticket tab: detail strip shows ticket (green=matched, grey=unmatched) and draw numbers (green=matched) side by side
- [x] Write `tests/test_widgets.py` — 12 tests (308/308 total passing)
---
### ✅ Phase 15 — Odds Calculator
- [x] Write `core/odds.py`
- [x] `total_combinations(main_count, main_max, bonus_count, bonus_max) -> int`
+219 -2
View File
@@ -1,3 +1,220 @@
# lottosight
# LottoSight
Lottery prediction app
A desktop application for analysing historical lottery draw data and generating statistically-informed number predictions.
Built with Python and Tkinter. Runs entirely offline — no accounts, no subscriptions, no cloud. All data is stored in a local SQLite database.
---
## Features at a glance
| Category | Highlights |
|---|---|
| **Data** | Auto-fetches from 6 live sources on launch and every 24 hours |
| **History** | Searchable, filterable, sortable draw history table |
| **Analysis** | 8 chart types — frequency, gaps, heatmap, pairs, odd/even, sums, deltas, odds |
| **Prediction** | 7 strategies with recency weighting and combination-quality filtering |
| **Checking** | Compare any ticket against the full draw history |
| **Wheeling** | Full-cover wheeling up to 200 tickets |
| **Export** | Excel and CSV export for draws, predictions, and frequency data |
| **Import** | Import draw history from CSV files |
| **Backup** | One-click database backup and restore |
---
## Supported games
| Game | Balls | Pool | Bonus | Source |
|---|---|---|---|---|
| Powerball | 5 | 169 | 1 (126) | NY Open Data API |
| Mega Millions | 5 | 170 | 1 (125) | NY Open Data API + Texas Lottery CSV |
| Cash 5 (VA) | 5 | 145 | — | Virginia Lottery API |
| Millionaire for Life (VA) | 5 | 158 | 1 (15) | Virginia Lottery API |
| Bank a Million (VA) | 6 | 140 | 1 (140) | Virginia Lottery API |
Custom games with configurable ball counts and pools can also be added.
---
## Screens
### Dashboard
The home screen shown on launch. Displays:
- Most recent draw result per game (numbers, bonus, multiplier, top prize)
- Next scheduled draw date for Powerball and Mega Millions
- Hot numbers — top 5 most frequent in the last 100 draws
- Most overdue numbers — highest gap since last appearance
- Database summary — total draw count and saved predictions per game
- Game filter to focus all sections on a single game
### History
Full draw history browser with:
- Filter by game, date range, or a specific number
- Sort by any column (date, numbers, bonus, multiplier, source)
- Ball display strip for the selected row
- Export the filtered view to Excel or CSV
### Analysis
Eight chart tabs, all game-selectable with a frequency window slider:
| Tab | What it shows |
|---|---|
| Frequency | Bar chart — how often each number has appeared |
| Positional | Heatmap — frequency broken down by draw position |
| Gaps | Bar chart — draws since each number last appeared (blue = recent, red = overdue) |
| Pairs | Horizontal bar — top-20 most common two-number combinations |
| Odd/Even | Bar chart — distribution of odd vs even split per draw |
| Sum Range | Histogram — distribution of draw totals |
| Deltas | Bar chart — gaps between consecutive numbers within draws |
| Odds | Table — prize tiers with exact odds and probability (no chart, no matplotlib) |
All chart tabs include a Matplotlib navigation toolbar for zoom and pan. The Frequency chart data can be exported to Excel from this screen.
### Predictor
Four tabs:
**Generate** — pick a game, strategy, and ticket count, then generate tickets. An optional Exclude field lets you block specific numbers. All strategies apply combination-quality filters automatically (see below). Results show as colour-coded lottery balls. Generated tickets can be saved to the database or copied to clipboard.
**Saved** — browse all predictions stored in the database, filtered by game. Shows how many numbers each prediction matched against the most recent real draw. Supports deleting selected rows or clearing all predictions.
**Check Ticket** — enter your own numbers and bonus ball, then compare against the entire draw history. Results table shows draw date, draw numbers, main hits, bonus hit, and prize tier. Rows are colour-coded: purple for jackpot, green for ≥3 matches, grey otherwise.
**Wheel** — enter a pool of numbers and a pick size; generates every C(n, k) combination up to a cap of 200 tickets. Live preview shows the ticket count before generating. Results can be saved to the database or copied to clipboard.
### Settings
- Enable or disable games (disabled games are hidden everywhere)
- Add custom games with configurable parameters
- Delete custom games (protected if draw records exist)
- Import draw history from a CSV file per game
- Manual fetch button with last-fetch status per source
- Database backup (timestamped copy) and restore
- Fetch interval display (24 hours, runs automatically)
---
## Prediction strategies
All strategies apply three combination-quality filters after generation, retrying or swapping numbers as needed:
- **All-even / all-odd** — rejected when the ticket has 4 or more numbers and all share the same parity
- **4+ consecutive** — rejected if the sorted ticket contains a run of four or more consecutive integers
- **Sum outlier** — rejected if the total falls outside the historical 10th90th percentile (requires ≥10 draws in the database)
| Strategy | Description |
|---|---|
| **Ensemble** | Runs all five core strategies and picks numbers with the most cross-strategy votes. Consensus numbers appear in multiple independent analyses. |
| **Hot Numbers** | Top-frequency numbers from the last 100 draws, weighted so recent draws contribute more than older ones (exponential decay, half-life ≈ 69 draws). |
| **Due Numbers** | Numbers with the largest gap since their last appearance — most overdue relative to expected frequency. |
| **Weighted Random** | Random draw with probability proportional to recency-adjusted historical frequency. Retries until a combination passes all filters. |
| **Monte Carlo** | Runs 10,000 weighted-random simulations and returns the numbers selected most often. |
| **Positional** | Selects the most frequent number at each draw position (position 1, 2, 3 …). |
| **Quick Pick** | Pure random selection. No historical data required. |
---
## Data fetch
Draws are fetched automatically on app launch and every 24 hours in a background thread. A **Fetch Now** button in the toolbar triggers an immediate fetch.
Sources are incremental for Virginia Lottery games — only draws newer than the most recent record in the database are processed, so repeat fetches complete quickly regardless of total history size.
After each fetch that adds new draws, saved predictions are automatically compared against the latest draw. A match alert appears in the status bar if any prediction matched two or more main numbers or the bonus ball.
---
## Getting started
### Requirements
- Python 3.12 or higher
- Windows (Tkinter + truststore for SSL; other platforms untested)
### Run from source
```bash
# Install dependencies
pip install -r requirements.txt
# Launch the app
python main.py
```
The database is created automatically at `data/lottosight.db` on first launch. Draw data is fetched from live sources on startup.
### Build a standalone executable
```powershell
# Full build (installs deps, then builds)
.\build.ps1
# Skip pip install on repeat builds
.\build.ps1 -SkipDeps
# Clean build (wipes dist\ and build\ first)
.\build.ps1 -Clean
```
Or double-click `build.bat` from Explorer. Output lands in `dist\LottoSight\LottoSight.exe` (~111 MB folder).
---
## Project structure
```
lottosight/
├── main.py # Entry point, main window, auto-fetch wiring
├── build.ps1 # Build script (PyInstaller)
├── build.bat # Double-click wrapper for build.ps1
├── lottosight.spec # PyInstaller spec file
├── requirements.txt
├── db/
│ ├── database.py # SQLite init, schema, migrations, backup/restore
│ └── models.py # CRUD operations
├── core/
│ ├── analyzer.py # All analysis functions (frequency, gaps, pairs, …)
│ ├── predictor.py # 7 prediction strategies
│ ├── filters.py # Combination-quality filters
│ ├── checker.py # Ticket checker — compare against draw history
│ ├── fetcher.py # Fetch logic for all 6 data sources
│ ├── importer.py # CSV import parser
│ ├── exporter.py # Excel/CSV export + icon generator
│ ├── odds.py # Prize tier odds calculator
│ ├── wheeling.py # Full-cover wheel generator
│ └── paths.py # Frozen/dev path resolution
├── ui/
│ ├── dashboard.py # Home screen
│ ├── history.py # Draw history browser
│ ├── analysis.py # Charts and analysis screen
│ ├── predictor_ui.py # Prediction generator (4 tabs)
│ ├── settings.py # Settings screen
│ ├── statusbar.py # Bottom status bar
│ └── widgets.py # BallsBar canvas widget
├── tests/ # 451 tests (pytest)
└── assets/
└── icon.png
```
---
## Tech stack
| Component | Library |
|---|---|
| UI | Tkinter + ttk |
| Charts | Matplotlib (embedded via FigureCanvasTkAgg) |
| Database | SQLite via sqlite3 |
| HTTP | requests |
| Analysis | numpy, collections, itertools |
| Scheduler | APScheduler |
| Export | openpyxl |
| SSL (Windows) | truststore |
| Packaging | PyInstaller |
---
## Running tests
```bash
pytest tests/
```
451 tests covering database models, fetch logic, all analysis functions, all prediction strategies, combination filters, ticket checker, wheeling, import/export, and UI smoke tests.
+16
View File
@@ -0,0 +1,16 @@
@echo off
:: build.bat — double-click or run from cmd to build LottoSight.exe
:: Calls build.ps1 with execution-policy bypass so no PS config is needed.
::
:: Flags are passed through:
:: build.bat -Clean wipe dist\ and build\ first
:: build.bat -SkipDeps skip pip install
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*
if %ERRORLEVEL% neq 0 (
echo.
echo Build failed. See errors above.
pause
exit /b 1
)
pause
+120
View File
@@ -0,0 +1,120 @@
# build.ps1
# ---------
# Builds LottoSight into a standalone Windows executable.
# Output: dist\LottoSight\LottoSight.exe
#
# Usage:
# .\build.ps1 # install deps + build
# .\build.ps1 -SkipDeps # skip pip install (faster when deps are current)
# .\build.ps1 -Clean # wipe dist\ and build\ before building
param(
[switch]$Clean,
[switch]$SkipDeps
)
$ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot
# --- Helpers ------------------------------------------------------------------
function Write-Step {
param([string]$Msg)
Write-Host ""
Write-Host (" " + $Msg) -ForegroundColor Cyan
Write-Host (" " + ("-" * $Msg.Length)) -ForegroundColor DarkGray
}
function Fail {
param([string]$Msg)
Write-Host ""
Write-Host " ERROR: $Msg" -ForegroundColor Red
Write-Host ""
exit 1
}
# --- Python check -------------------------------------------------------------
Write-Step "Checking Python"
$pyCmd = Get-Command python -ErrorAction SilentlyContinue
if (-not $pyCmd) {
Fail "Python not found. Install Python 3.12+ and add it to PATH."
}
# Use exit code to verify version - avoids string parsing
python -c "import sys; sys.exit(0 if sys.version_info >= (3,12) else 1)" 2>$null
if ($LASTEXITCODE -ne 0) {
$found = python -c "import sys; print(sys.version)"
Fail "Python 3.12 or higher required. Found: $found"
}
$pyVersion = python -c "import sys; print(sys.version.split()[0])"
Write-Host " Python $pyVersion OK" -ForegroundColor Green
# --- Dependencies -------------------------------------------------------------
if (-not $SkipDeps) {
Write-Step "Installing dependencies"
python -m pip install --upgrade pip --quiet
if ($LASTEXITCODE -ne 0) { Fail "pip upgrade failed." }
python -m pip install -r requirements.txt pyinstaller --quiet
if ($LASTEXITCODE -ne 0) { Fail "pip install failed." }
Write-Host " All packages up to date OK" -ForegroundColor Green
} else {
Write-Host ""
Write-Host " -SkipDeps: skipping pip install" -ForegroundColor DarkGray
}
# --- Assets -------------------------------------------------------------------
Write-Step "Checking assets"
if (-not (Test-Path "assets\icon.png")) {
Write-Host " icon.png missing - generating..." -ForegroundColor Yellow
New-Item -ItemType Directory -Force "assets" | Out-Null
python -c "from core.exporter import create_icon_png; create_icon_png('assets/icon.png')"
if ($LASTEXITCODE -ne 0) { Fail "Could not generate icon.png." }
Write-Host " icon.png created OK" -ForegroundColor Green
} else {
Write-Host " assets\icon.png OK" -ForegroundColor Green
}
# --- Clean --------------------------------------------------------------------
if ($Clean) {
Write-Step "Cleaning previous build"
Remove-Item -Recurse -Force "dist", "build" -ErrorAction SilentlyContinue
Write-Host " dist\ and build\ removed OK" -ForegroundColor Green
}
# --- Build --------------------------------------------------------------------
Write-Step "Running PyInstaller"
python -m PyInstaller lottosight.spec --clean --noconfirm
if ($LASTEXITCODE -ne 0) { Fail "PyInstaller build failed." }
# --- Verify and report --------------------------------------------------------
Write-Step "Build result"
$exePath = "dist\LottoSight\LottoSight.exe"
$distDir = "dist\LottoSight"
if (-not (Test-Path $exePath)) {
Fail "Expected exe not found at: $exePath"
}
$exeSizeMB = [math]::Round((Get-Item $exePath).Length / 1MB, 1)
$allFiles = Get-ChildItem $distDir -Recurse
$dirSizeMB = [math]::Round(($allFiles | Measure-Object -Property Length -Sum).Sum / 1MB, 0)
$absDir = (Resolve-Path $distDir).Path
Write-Host ""
Write-Host " Build successful!" -ForegroundColor Green
Write-Host ""
Write-Host " Executable : $exePath ($exeSizeMB MB)"
Write-Host " Folder : $absDir"
Write-Host " Total size : ~$dirSizeMB MB"
Write-Host ""
Write-Host " To run the app:" -ForegroundColor Yellow
Write-Host " .\dist\LottoSight\LottoSight.exe" -ForegroundColor Yellow
Write-Host ""
+22 -8
View File
@@ -6,27 +6,41 @@ Each function takes game_id and returns structured Python data
(dicts / lists) — no UI concerns here.
"""
import math
from collections import Counter
from itertools import combinations
from db.models import get_all_draws_numbers, get_game_by_id
def frequency_analysis(game_id, last_n=None):
def frequency_analysis(game_id, last_n=None, decay: float = 0.0):
"""
Count appearances of each main ball.
Count (or weight) appearances of each main ball.
last_n: restrict to the most recent N draws (None = all).
Returns {number: count} sorted high → low.
decay: exponential recency weight per draw step (0 = uniform / off).
With decay=0.01 the draw 69 steps back carries ~50% of the
latest draw's weight; draws >300 steps back are near-zero.
Returns {number: count_or_weight} sorted high → low.
"""
draws = get_all_draws_numbers(game_id) # ASC order
draws = get_all_draws_numbers(game_id) # ASC order, oldest first
if last_n and last_n > 0:
draws = draws[-last_n:]
counter = Counter()
for draw in draws:
counter.update(draw["numbers"])
if not decay:
counter = Counter()
for draw in draws:
counter.update(draw["numbers"])
return dict(sorted(counter.items(), key=lambda kv: kv[1], reverse=True))
return dict(sorted(counter.items(), key=lambda kv: kv[1], reverse=True))
# Decayed path: newest draw (i = n-1) gets weight 1.0; older draws decay
n = len(draws)
freq: dict[int, float] = {}
for i, draw in enumerate(draws):
w = math.exp(-decay * (n - 1 - i))
for num in draw["numbers"]:
freq[num] = freq.get(num, 0.0) + w
return dict(sorted(freq.items(), key=lambda kv: kv[1], reverse=True))
def gap_analysis(game_id):
+128 -2
View File
@@ -12,8 +12,9 @@ import logging
import threading
import requests
from datetime import datetime
from db.models import get_game_by_name, insert_draw, insert_fetch_log
from db.models import get_game_by_name, get_last_draw, insert_draw, insert_fetch_log
logger = logging.getLogger(__name__)
@@ -26,6 +27,10 @@ MEGAMILLIONS_TX_URL = (
"Mega_Millions/Winning_Numbers/download.html"
)
VA_CASH5_URL = "https://www.valottery.com/api/v1/downloadall?gameId=1030"
VA_MILLIONAIREFORLIFE_URL = "https://www.valottery.com/api/v1/downloadall?gameId=1075"
VA_BANKAMILLION_URL = "https://www.valottery.com/api/v1/downloadall?gameId=1070"
_fetch_lock = threading.Lock()
@@ -231,6 +236,120 @@ def fetch_megamillions_tx():
return _error_result(source, added, skipped, f"Network error: {e}")
# ── Virginia Lottery (shared parser) ─────────────────────────────────────────
def _parse_va_date(raw: str) -> str | None:
"""'5/22/2026' or '05/22/2026''2026-05-22'."""
parts = raw.strip().split("/")
if len(parts) != 3:
return None
try:
return f"{parts[2]}-{parts[0].zfill(2)}-{parts[1].zfill(2)}"
except (IndexError, ValueError):
return None
def _fetch_va_lottery(game_name: str, source: str, url: str) -> dict:
"""
Fetch VA Lottery draw data from the valottery.com download API.
Format per line: 'M/D/YYYY; N1,N2,...[; Label: bonus]'
First line may be a 'Results for ...' header — skipped automatically.
"""
game = get_game_by_name(game_name)
if not game:
return _error_result(source, 0, 0, f"{game_name} game not found in DB")
game_id = game["id"]
added = skipped = 0
# Incremental fetch: VA data is newest-first; stop once we reach known dates
last_draw = get_last_draw(game_id)
last_date = last_draw["draw_date"] if last_draw else None
try:
resp = requests.get(url, timeout=30)
resp.raise_for_status()
for line in resp.text.splitlines():
line = line.strip()
if not line or line.lower().startswith("results for"):
continue
parts = [p.strip() for p in line.split(";")]
if len(parts) < 2:
continue
draw_date = _parse_va_date(parts[0])
if draw_date is None:
continue
# Data comes newest-first; stop when we reach already-stored dates
if last_date and draw_date <= last_date:
break
try:
p1 = parts[1].strip()
p1_lower = p1.lower()
if p1_lower.startswith("day:"):
# Old twice-daily format: "Day: N1,...; Night: N1,..."
# Use Night draw only for consistency with modern single-draw records
if len(parts) >= 3 and "night:" in parts[2].lower():
night_str = parts[2][parts[2].lower().find("night:") + 6:]
numbers = [int(n.strip()) for n in night_str.split(",") if n.strip()]
else:
continue
elif p1_lower.startswith("night:"):
# Night-only historical record
numbers = [int(n.strip()) for n in p1[p1_lower.find("night:") + 6:].split(",") if n.strip()]
else:
numbers = [int(n.strip()) for n in p1.split(",") if n.strip()]
except ValueError as e:
logger.warning("[FETCH] VA %s parse error %r: %s", source, line, e)
continue
bonus = None
if len(parts) >= 3:
p_last = parts[-1].strip()
if "night:" not in p_last.lower() and "day:" not in p_last.lower():
colon = p_last.rfind(":")
if colon >= 0:
try:
bonus = int(p_last[colon + 1:].strip())
except ValueError:
pass
result = insert_draw(game_id, draw_date, numbers, bonus=bonus, source=source)
if result == "inserted":
added += 1
else:
skipped += 1
logger.info("[FETCH] %s done — added=%d skipped=%d", source, added, skipped)
insert_fetch_log(source, added, skipped, "success")
return {"source": source, "added": added, "skipped": skipped,
"status": "success", "message": None}
except requests.RequestException as e:
logger.error("[FETCH] %s error: %s", source, e)
return _error_result(source, added, skipped, f"Network error: {e}")
def fetch_cash5_va():
"""Fetch Cash 5 draws from VA Lottery download API."""
return _fetch_va_lottery("Cash 5", "cash5_va", VA_CASH5_URL)
def fetch_millionaireforlife_va():
"""Fetch Millionaire for Life draws from VA Lottery download API."""
return _fetch_va_lottery("Millionaire for Life", "millionaireforlife_va",
VA_MILLIONAIREFORLIFE_URL)
def fetch_bankamillion_va():
"""Fetch Bank a Million draws from VA Lottery download API."""
return _fetch_va_lottery("Bank a Million", "bankamillion_va", VA_BANKAMILLION_URL)
# ── fetch_all ─────────────────────────────────────────────────────────────────
def fetch_all():
@@ -241,7 +360,14 @@ def fetch_all():
"""
logger.info("[FETCH] fetch_all() starting")
results = []
for fn in (fetch_powerball_ny, fetch_megamillions_ny, fetch_megamillions_tx):
for fn in (
fetch_powerball_ny,
fetch_megamillions_ny,
fetch_megamillions_tx,
fetch_cash5_va,
fetch_millionaireforlife_va,
fetch_bankamillion_va,
):
try:
results.append(fn())
except Exception as e:
+73
View File
@@ -0,0 +1,73 @@
"""
core/filters.py
---------------
Combination-quality filters for generated lottery tickets.
A ticket is considered "weak" if it falls into a pattern that is
statistically underrepresented in real draws:
• All main numbers are even (when count >= 4)
• All main numbers are odd (when count >= 4)
• 4 or more consecutive numbers (e.g. 12-13-14-15)
• Sum of main numbers is outside the historical 10th90th percentile
None of these filters improve expected-value (all draws are IID), but
they remove tickets that players and statisticians alike would call "weak"
and shift the distribution toward historically common patterns.
"""
import numpy as np
from db.models import get_all_draws_numbers
def _has_consecutive_run(numbers: list[int], min_len: int = 4) -> bool:
"""Return True if `numbers` contains a run of at least min_len consecutive integers."""
s = sorted(numbers)
run = 1
for i in range(1, len(s)):
if s[i] == s[i - 1] + 1:
run += 1
if run >= min_len:
return True
else:
run = 1
return False
def sum_range_percentiles(
game_id: int,
low_pct: float = 10.0,
high_pct: float = 90.0,
) -> tuple[int, int] | None:
"""
Return (low, high) sum bounds derived from historical draw data.
Returns None when fewer than 10 draws exist (not enough data).
"""
draws = get_all_draws_numbers(game_id)
if len(draws) < 10:
return None
sums = [sum(d["numbers"]) for d in draws]
return int(np.percentile(sums, low_pct)), int(np.percentile(sums, high_pct))
def passes_filters(
numbers: list[int],
sum_range: tuple[int, int] | None = None,
) -> bool:
"""
Return True if the ticket passes all combination-quality checks.
Pass a pre-computed sum_range (from sum_range_percentiles) to avoid
re-querying the database on every retry.
"""
if len(numbers) >= 4:
if all(n % 2 == 0 for n in numbers):
return False
if all(n % 2 != 0 for n in numbers):
return False
if _has_consecutive_run(numbers, 4):
return False
if sum_range is not None:
lo, hi = sum_range
if not (lo <= sum(numbers) <= hi):
return False
return True
+183
View File
@@ -0,0 +1,183 @@
"""
core/importer.py
----------------
CSV draw importer for LottoSight.
Supported formats (auto-detected):
1. LottoSight export — Game, Date, "N1,N2,...", Bonus, Multiplier, Source
2. Wide format — Date, N1, N2, ..., Nn [, Bonus]
3. Packed format — Date, "N1,N2,...", [Bonus]
Header rows are auto-detected and skipped.
Dates accepted: YYYY-MM-DD, MM/DD/YYYY, M/D/YYYY, MM-DD-YYYY.
Duplicate draws (same game + date already in DB) are skipped, not errored.
"""
import csv
from datetime import datetime
from db.models import get_game_by_id, insert_draw
# ── Date parsing ──────────────────────────────────────────────────────────────
_DATE_FORMATS = ("%Y-%m-%d", "%m/%d/%Y", "%m-%d-%Y", "%d/%m/%Y", "%-m/%-d/%Y")
def _parse_date(text: str) -> str | None:
"""Return ISO date string (YYYY-MM-DD) or None."""
text = text.strip()
for fmt in _DATE_FORMATS:
try:
return datetime.strptime(text, fmt).strftime("%Y-%m-%d")
except ValueError:
pass
return None
def _is_header(row: list[str]) -> bool:
"""Heuristic: first cell is not a recognisable date → row is a header."""
return bool(row) and _parse_date(row[0]) is None
# ── Row parsing ───────────────────────────────────────────────────────────────
def _parse_row_lottosight(cells: list[str]) -> dict | None:
"""Parse LottoSight export row: Game, Date, Numbers, Bonus, ..."""
if len(cells) < 3:
return None
date_str = _parse_date(cells[1])
if date_str is None:
return None
try:
numbers = [int(n.strip()) for n in cells[2].split(",") if n.strip()]
bonus = int(cells[3]) if len(cells) > 3 and cells[3].strip().isdigit() else None
except (ValueError, IndexError):
return None
return {"date": date_str, "numbers": numbers, "bonus": bonus}
def _parse_row_generic(cells: list[str], game: dict) -> dict | None:
"""Parse wide or packed format: Date, [nums...]"""
date_str = _parse_date(cells[0])
if date_str is None:
return None
rest = cells[1:]
if not rest:
return None
# Packed: second cell contains comma-separated numbers
if "," in rest[0]:
try:
numbers = [int(n.strip()) for n in rest[0].split(",") if n.strip()]
bonus = int(rest[1]) if len(rest) > 1 and rest[1].strip().isdigit() else None
return {"date": date_str, "numbers": numbers, "bonus": bonus}
except ValueError:
pass
# Wide: each number in its own column
nums: list[int] = []
for col in rest:
col = col.strip()
if col.isdigit():
nums.append(int(col))
else:
break # stop at first non-numeric cell (e.g. multiplier string)
if len(nums) < game["main_count"]:
return None
numbers = nums[: game["main_count"]]
bonus = (nums[game["main_count"]]
if game["bonus_count"] > 0 and len(nums) > game["main_count"]
else None)
return {"date": date_str, "numbers": numbers, "bonus": bonus}
# ── Public API ────────────────────────────────────────────────────────────────
def import_draws_csv(game_id: int, filepath: str) -> dict:
"""
Import draw records from *filepath* into the database for *game_id*.
Returns:
{"added": int, "skipped": int, "errors": list[str]}
"""
game = get_game_by_id(game_id)
if game is None:
return {"added": 0, "skipped": 0, "errors": [f"Unknown game_id {game_id}"]}
added = skipped = 0
errors: list[str] = []
# Read file
try:
with open(filepath, newline="", encoding="utf-8-sig") as f:
sample = f.read(4096)
f.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",\t;")
except csv.Error:
dialect = csv.excel
rows = list(csv.reader(f, dialect))
except FileNotFoundError:
return {"added": 0, "skipped": 0, "errors": [f"File not found: {filepath}"]}
except Exception as e:
return {"added": 0, "skipped": 0, "errors": [f"Could not read file: {e}"]}
if not rows:
return {"added": 0, "skipped": 0, "errors": ["File is empty"]}
# Detect LottoSight export format by header
lottosight_fmt = (
len(rows[0]) >= 3
and rows[0][0].strip().lower() == "game"
and rows[0][1].strip().lower() == "date"
)
# Determine start row (skip header if present)
start = 1 if (lottosight_fmt or _is_header(rows[0])) else 0
for line_num, row in enumerate(rows[start:], start=start + 1):
cells = [c.strip() for c in row]
if not any(cells):
continue # blank line
if lottosight_fmt:
parsed = _parse_row_lottosight(cells)
else:
parsed = _parse_row_generic(cells, game)
if parsed is None:
errors.append(f"Line {line_num}: could not parse — {row}")
continue
numbers = parsed["numbers"]
# Validate count
if len(numbers) != game["main_count"]:
errors.append(
f"Line {line_num}: expected {game['main_count']} numbers, "
f"got {len(numbers)}"
)
continue
# Validate range
bad = [n for n in numbers if not (1 <= n <= game["main_max"])]
if bad:
errors.append(
f"Line {line_num}: numbers out of range 1{game['main_max']}: {bad}"
)
continue
result = insert_draw(
game_id, parsed["date"], numbers,
bonus=parsed["bonus"], source="csv_import",
)
if result == "inserted":
added += 1
else:
skipped += 1
return {"added": added, "skipped": skipped, "errors": errors}
+214 -47
View File
@@ -1,11 +1,16 @@
"""
core/predictor.py
-----------------
Five prediction strategies for LottoSight.
Six prediction strategies for LottoSight.
Every function accepts game_id and returns:
{"numbers": [int, ...], "bonus": int | None}
where numbers is sorted, length == game.main_count,
all values in 1..main_max, and bonus in 1..bonus_max (or None).
All strategies accept an optional `exclude` keyword argument (set[int])
that removes specific main-ball numbers from consideration. If excluding
those numbers would leave fewer candidates than main_count, the exclusion
is silently ignored (fallback to the full pool).
"""
import random
@@ -15,22 +20,36 @@ import numpy as np
from db.models import get_all_draws_numbers, get_game_by_id
from core.analyzer import frequency_analysis, gap_analysis, positional_frequency
from core.filters import passes_filters, sum_range_percentiles
_MAX_FILTER_TRIES = 50
# Exponential decay rate applied to historical draws.
# Half-life ≈ ln(2) / 0.01 ≈ 69 draws (~5-6 months of Powerball draws).
_DEFAULT_DECAY = 0.01
# ── Internal helpers ──────────────────────────────────────────────────────────
def _random_ticket(game):
"""Fully random fallback ticket."""
numbers = sorted(random.sample(range(1, game["main_max"] + 1), game["main_count"]))
def _safe_pool(game: dict, exclude: set) -> list[int]:
"""Full main-ball pool minus excluded numbers; falls back to full pool if too few."""
full = list(range(1, game["main_max"] + 1))
filtered = [n for n in full if n not in exclude]
return filtered if len(filtered) >= game["main_count"] else full
def _random_ticket(game: dict, exclude: set | None = None) -> dict:
"""Fully random fallback ticket, respecting exclusions."""
pool = _safe_pool(game, exclude or set())
numbers = sorted(random.sample(pool, game["main_count"]))
bonus = random.randint(1, game["bonus_max"]) if game["bonus_count"] > 0 else None
return {"numbers": numbers, "bonus": bonus}
def _random_bonus(game):
def _random_bonus(game: dict) -> int | None:
return random.randint(1, game["bonus_max"]) if game["bonus_count"] > 0 else None
def _hot_bonus(draws, game):
def _hot_bonus(draws, game: dict) -> int | None:
"""Most frequent historical bonus ball, or random if no data."""
if game["bonus_count"] == 0:
return None
@@ -40,91 +59,156 @@ def _hot_bonus(draws, game):
return random.randint(1, game["bonus_max"])
def _fill_to_count(chosen: list, game: dict) -> list:
def _retry_filter(generate_fn, sum_range, max_tries: int = _MAX_FILTER_TRIES) -> list[int]:
"""
Call generate_fn() up to max_tries times; return the first numbers list
that passes combination filters, or the last generated if none do.
Used for stochastic strategies where each call produces a new candidate.
"""
last = generate_fn()
if passes_filters(last, sum_range):
return last
for _ in range(max_tries - 1):
attempt = generate_fn()
if passes_filters(attempt, sum_range):
return attempt
return last
def _swap_filter(numbers: list[int], ranked_pool: list[int],
sum_range) -> list[int]:
"""
For deterministic strategies: try swapping the weakest-ranked number in
the ticket for the best available alternative until filters pass.
ranked_pool must contain candidates sorted best-first, excluding numbers
already in the ticket.
Returns the first passing combination, or the original if none found.
"""
if passes_filters(numbers, sum_range):
return numbers
ticket = list(numbers)
for swap_idx in range(len(ticket) - 1, -1, -1):
original = ticket[swap_idx]
for alt in ranked_pool:
if alt not in ticket:
ticket[swap_idx] = alt
candidate = sorted(ticket)
if passes_filters(candidate, sum_range):
return candidate
ticket[swap_idx] = original
return numbers # graceful fallback: return original if no swap helped
def _fill_to_count(chosen: list, game: dict, exclude: set | None = None) -> list:
"""Pad chosen with random unused numbers if fewer than main_count."""
excl = exclude or set()
needed = game["main_count"] - len(chosen)
if needed > 0:
pool = [n for n in range(1, game["main_max"] + 1) if n not in set(chosen)]
chosen = chosen + random.sample(pool, needed)
used = set(chosen)
pool = [n for n in range(1, game["main_max"] + 1) if n not in used and n not in excl]
if len(pool) < needed:
pool = [n for n in range(1, game["main_max"] + 1) if n not in used]
chosen = chosen + random.sample(pool, min(needed, len(pool)))
return sorted(chosen[: game["main_count"]])
# ── Strategy 1: Hot Numbers ───────────────────────────────────────────────────
def hot_numbers(game_id, last_n=100):
def hot_numbers(game_id: int, last_n: int = 100, exclude: set | None = None) -> dict:
"""
Top main_count most-frequent numbers from the last last_n draws.
Top main_count most-frequent numbers from the last last_n draws,
weighted by recency (recent draws contribute more than older ones).
Bonus: most frequent historical bonus ball.
Falls back to random if there is no history.
Applies combination filters; swaps the weakest pick if the ticket is weak.
"""
excl = exclude or set()
game = get_game_by_id(game_id)
draws = get_all_draws_numbers(game_id)
if not draws:
return _random_ticket(game)
return _random_ticket(game, excl)
recent = draws[-last_n:] if last_n and last_n > 0 else draws
freq = frequency_analysis(game_id, last_n=last_n, decay=_DEFAULT_DECAY)
ranked = [n for n, _ in sorted(freq.items(), key=lambda kv: (-kv[1], kv[0]))
if n not in excl]
numbers = _fill_to_count(ranked[: game["main_count"]], game, excl)
counter = Counter()
for draw in recent:
counter.update(draw["numbers"])
sum_range = sum_range_percentiles(game_id)
ranked_pool = [n for n in ranked if n not in numbers]
numbers = _swap_filter(numbers, ranked_pool, sum_range)
# Sort by (-count, number) for deterministic tie-breaking
top = [n for n, _ in sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))]
numbers = _fill_to_count(top[: game["main_count"]], game)
bonus = _hot_bonus(draws, game)
bonus = _hot_bonus(draws, game)
return {"numbers": numbers, "bonus": bonus}
# ── Strategy 2: Due Numbers ───────────────────────────────────────────────────
def due_numbers(game_id):
def due_numbers(game_id: int, exclude: set | None = None) -> dict:
"""
Numbers with the largest gap (most overdue) based on historical frequency.
Falls back to random if there is no history.
Applies combination filters; swaps the lowest-gap pick if the ticket is weak.
"""
excl = exclude or set()
game = get_game_by_id(game_id)
gaps = gap_analysis(game_id) # {number: gap} — empty dict if no draws
gaps = gap_analysis(game_id)
if not gaps:
return _random_ticket(game)
return _random_ticket(game, excl)
# Sort by (-gap, number) — most overdue first, tie-break by number
top = [n for n, _ in sorted(gaps.items(), key=lambda kv: (-kv[1], kv[0]))]
numbers = _fill_to_count(top[: game["main_count"]], game)
bonus = _random_bonus(game)
ranked = [n for n, _ in sorted(gaps.items(), key=lambda kv: (-kv[1], kv[0]))
if n not in excl]
numbers = _fill_to_count(ranked[: game["main_count"]], game, excl)
sum_range = sum_range_percentiles(game_id)
ranked_pool = [n for n in ranked if n not in numbers]
numbers = _swap_filter(numbers, ranked_pool, sum_range)
bonus = _random_bonus(game)
return {"numbers": numbers, "bonus": bonus}
# ── Strategy 3: Weighted Random ───────────────────────────────────────────────
def weighted_random(game_id):
def weighted_random(game_id: int, exclude: set | None = None) -> dict:
"""
Random draw with probability proportional to historical frequency.
Numbers that have never appeared receive a minimum weight of 1
so they remain in contention.
Retries up to _MAX_FILTER_TRIES times to find a combination-quality ticket.
"""
excl = exclude or set()
game = get_game_by_id(game_id)
freq = frequency_analysis(game_id) # {number: count}
freq = frequency_analysis(game_id, decay=_DEFAULT_DECAY)
pool = list(range(1, game["main_max"] + 1))
pool = _safe_pool(game, excl)
weights = np.array([freq.get(n, 1) for n in pool], dtype=float)
weights /= weights.sum()
chosen = np.random.choice(pool, size=game["main_count"], replace=False, p=weights)
sum_range = sum_range_percentiles(game_id)
def _generate():
chosen = np.random.choice(pool, size=game["main_count"], replace=False, p=weights)
return sorted(chosen.tolist())
numbers = _retry_filter(_generate, sum_range)
bonus = _random_bonus(game)
return {"numbers": sorted(chosen.tolist()), "bonus": bonus}
return {"numbers": numbers, "bonus": bonus}
# ── Strategy 4: Monte Carlo ───────────────────────────────────────────────────
def monte_carlo(game_id, simulations=10_000):
def monte_carlo(game_id: int, simulations: int = 10_000,
exclude: set | None = None) -> dict:
"""
Run `simulations` weighted-random draws; tally how often each number
is selected; return the top main_count by tally count.
Applies combination filters; swaps the lowest-tally pick if ticket is weak.
"""
excl = exclude or set()
game = get_game_by_id(game_id)
freq = frequency_analysis(game_id)
freq = frequency_analysis(game_id, decay=_DEFAULT_DECAY)
pool = list(range(1, game["main_max"] + 1))
pool = _safe_pool(game, excl)
weights = np.array([freq.get(n, 1) for n in pool], dtype=float)
weights /= weights.sum()
@@ -133,41 +217,124 @@ def monte_carlo(game_id, simulations=10_000):
ticket = np.random.choice(pool, size=game["main_count"], replace=False, p=weights)
tally.update(ticket.tolist())
top = [n for n, _ in tally.most_common(game["main_count"])]
numbers = _fill_to_count(top, game)
bonus = _random_bonus(game)
ranked = [n for n, _ in tally.most_common()]
numbers = _fill_to_count(ranked[: game["main_count"]], game, excl)
sum_range = sum_range_percentiles(game_id)
ranked_pool = [n for n in ranked if n not in numbers]
numbers = _swap_filter(numbers, ranked_pool, sum_range)
bonus = _random_bonus(game)
return {"numbers": numbers, "bonus": bonus}
# ── Strategy 5: Positional Pick ───────────────────────────────────────────────
def positional_pick(game_id):
def positional_pick(game_id: int, exclude: set | None = None) -> dict:
"""
For each draw position, select the most frequently appearing number
that has not already been chosen for a previous position.
Applies combination filters; swaps the lowest-positional-rank pick if weak.
"""
excl = exclude or set()
game = get_game_by_id(game_id)
pos_freq = positional_frequency(game_id) # {pos: {number: count}}
pos_freq = positional_frequency(game_id)
if not pos_freq or not any(pos_freq.values()):
return _random_ticket(game)
return _random_ticket(game, excl)
selected = []
used = set()
# Also collect per-position alternates (next-best) for the swap pool
alternates_pool = []
for pos in range(1, game["main_count"] + 1):
freqs = pos_freq.get(pos, {})
# Sort candidates by count desc, then number asc for tie-breaking
freqs = pos_freq.get(pos, {})
ranked = sorted(freqs.items(), key=lambda kv: (-kv[1], kv[0]))
picked = next((n for n, _ in ranked if n not in used), None)
picked = next((n for n, _ in ranked if n not in used and n not in excl), None)
if picked is None:
# All top numbers already used — pick any unused
available = [n for n in range(1, game["main_max"] + 1) if n not in used]
available = [n for n in range(1, game["main_max"] + 1)
if n not in used and n not in excl]
if not available:
available = [n for n in range(1, game["main_max"] + 1) if n not in used]
picked = random.choice(available)
else:
# Collect alternates for this position (for potential swap)
for n, _ in ranked:
if n != picked and n not in excl:
alternates_pool.append(n)
selected.append(picked)
used.add(picked)
numbers = sorted(selected)
sum_range = sum_range_percentiles(game_id)
# Alternates sorted by first-occurrence (positional best-first)
seen = set()
ranked_pool = [n for n in alternates_pool
if n not in numbers and not (seen.add(n) or n in seen)]
numbers = _swap_filter(numbers, ranked_pool, sum_range)
bonus = _random_bonus(game)
return {"numbers": sorted(selected), "bonus": bonus}
return {"numbers": numbers, "bonus": bonus}
# ── Strategy 6: Ensemble ─────────────────────────────────────────────────────
def ensemble(game_id: int, exclude: set | None = None) -> dict:
"""
Run all 5 core strategies and tally votes per number.
Numbers that appear across the most strategies are selected first
they have multi-angle statistical support (hot AND due AND positional).
Bonus: most commonly suggested bonus across strategies.
Falls back gracefully if any individual strategy errors.
"""
excl = exclude or set()
game = get_game_by_id(game_id)
tally = Counter()
bonus_tally = Counter()
for fn in (hot_numbers, due_numbers, weighted_random, monte_carlo, positional_pick):
try:
result = fn(game_id, exclude=excl)
tally.update(result["numbers"])
if result["bonus"] is not None:
bonus_tally[result["bonus"]] += 1
except Exception:
pass
ranked = [n for n, _ in tally.most_common()]
numbers = _fill_to_count(ranked[: game["main_count"]], game, excl)
sum_range = sum_range_percentiles(game_id)
ranked_pool = [n for n in ranked if n not in numbers]
numbers = _swap_filter(numbers, ranked_pool, sum_range)
if bonus_tally:
bonus = bonus_tally.most_common(1)[0][0]
else:
bonus = _random_bonus(game)
return {"numbers": numbers, "bonus": bonus}
# ── Strategy 7: Quick Pick ────────────────────────────────────────────────────
def quick_pick(game_id: int, exclude: set | None = None) -> dict:
"""
Pure random selection from the full number pool.
Requires no historical draw data.
Retries up to _MAX_FILTER_TRIES times to find a combination-quality ticket.
"""
excl = exclude or set()
game = get_game_by_id(game_id)
sum_range = sum_range_percentiles(game_id)
def _generate():
pool = _safe_pool(game, excl)
return sorted(random.sample(pool, game["main_count"]))
numbers = _retry_filter(_generate, sum_range)
bonus = _random_bonus(game)
return {"numbers": numbers, "bonus": bonus}
+49
View File
@@ -0,0 +1,49 @@
"""
core/wheeling.py
----------------
Full-cover number wheeling.
wheel_count(numbers, k) C(n, k) ticket count preview
wheel_full(numbers, k) all C(n, k) sorted combinations
Raises ValueError for invalid inputs or if result exceeds MAX_TICKETS.
"""
from itertools import combinations
from math import comb
MAX_TICKETS = 200
def wheel_count(numbers: list | set, k: int) -> int:
"""Return how many tickets a full wheel would produce (C(n, k))."""
n = len(set(numbers))
if k < 1 or k > n:
return 0
return comb(n, k)
def wheel_full(numbers: list | set, k: int) -> list[list[int]]:
"""
Generate all C(n, k) combinations from *numbers*, each sorted ascending.
Duplicates in input are removed before wheeling.
Raises ValueError if k is out of range or count > MAX_TICKETS.
"""
pool = sorted(set(numbers))
n = len(pool)
if k < 1:
raise ValueError("Pick count must be at least 1.")
if k > n:
raise ValueError(
f"Pick count ({k}) exceeds the number pool size ({n})."
)
count = comb(n, k)
if count > MAX_TICKETS:
raise ValueError(
f"Wheel would produce {count:,} tickets (max {MAX_TICKETS:,}). "
f"Reduce your pool or pick count."
)
return [sorted(combo) for combo in combinations(pool, k)]
Binary file not shown.
+48 -6
View File
@@ -8,6 +8,8 @@ All table definitions live here. Call init_db() once on app startup.
import sqlite3
import logging
import os
import shutil
from datetime import datetime
from core.paths import user_data_dir
@@ -48,7 +50,8 @@ def init_db():
main_max INTEGER NOT NULL,
bonus_count INTEGER NOT NULL DEFAULT 0,
bonus_max INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1
active INTEGER NOT NULL DEFAULT 1,
top_prize TEXT NOT NULL DEFAULT ''
)
""")
@@ -103,6 +106,15 @@ def init_db():
)
""")
# Migration: add top_prize column to existing databases
cursor.execute("PRAGMA table_info(games)")
cols = {r["name"] for r in cursor.fetchall()}
if "top_prize" not in cols:
cursor.execute(
"ALTER TABLE games ADD COLUMN top_prize TEXT NOT NULL DEFAULT ''"
)
logger.info("[DB] Migrated: added top_prize column to games")
conn.commit()
logger.info("[DB] Tables created/verified OK")
@@ -120,19 +132,28 @@ def _seed_games(cursor, conn):
"""
Insert default game configs if they don't already exist.
Uses INSERT OR IGNORE to be idempotent on every launch.
Also back-fills top_prize for any existing rows where it is empty.
"""
defaults = [
# name main_count main_max bonus_count bonus_max active
("Powerball", 5, 69, 1, 26, 1),
("Mega Millions", 5, 70, 1, 25, 1),
# name mc mm bc bm active top_prize
("Powerball", 5, 69, 1, 26, 1, "Jackpot (variable)"),
("Mega Millions", 5, 70, 1, 25, 1, "Jackpot (variable)"),
("Cash 5", 5, 45, 0, 0, 1, "Jackpot from $200K"),
("Millionaire for Life", 5, 58, 1, 5, 1, "$1M/yr for life"),
("Bank a Million", 6, 40, 1, 40, 1, "$1M after taxes"),
]
for row in defaults:
cursor.execute("""
INSERT OR IGNORE INTO games
(name, main_count, main_max, bonus_count, bonus_max, active)
VALUES (?, ?, ?, ?, ?, ?)
(name, main_count, main_max, bonus_count, bonus_max, active, top_prize)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", row)
# Back-fill top_prize for rows inserted before this column existed
cursor.execute(
"UPDATE games SET top_prize = ? WHERE name = ? AND top_prize = ''",
(row[6], row[0]),
)
inserted = conn.total_changes
conn.commit()
@@ -143,6 +164,27 @@ def _seed_games(cursor, conn):
logger.info("[DB] Default games already seeded — skipped")
def backup_db(dest_dir: str | None = None) -> str:
"""Copy the live DB to dest_dir and return the backup file path."""
if dest_dir is None:
dest_dir = os.path.join(user_data_dir(), "exports")
os.makedirs(dest_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
dest = os.path.join(dest_dir, f"lottosight_backup_{ts}.db")
shutil.copy2(DB_PATH, dest)
logger.info("[DB] Backup created: %s", dest)
return dest
def restore_db(source_path: str) -> None:
"""Overwrite the live DB with a backup file."""
if not os.path.isfile(source_path):
raise FileNotFoundError(f"Backup file not found: {source_path}")
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
shutil.copy2(source_path, DB_PATH)
logger.info("[DB] Database restored from: %s", source_path)
def get_db_stats():
"""
Return a dict of basic DB stats for display in Settings screen.
+1 -1
View File
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
# GAMES
# ══════════════════════════════════════════════════════════════════════════════
_BUILTIN_GAMES = {"Powerball", "Mega Millions"}
_BUILTIN_GAMES = {"Powerball", "Mega Millions", "Cash 5", "Millionaire for Life", "Bank a Million"}
def add_game(name: str, main_count: int, main_max: int,
+2 -2
View File
@@ -29,8 +29,8 @@ hiddenimports = [
'apscheduler.jobstores.memory',
'apscheduler.triggers.interval',
'apscheduler.triggers.date',
# bs4 HTML parser back-end
'bs4.builder._htmlparser',
# truststore — Windows native certificate store injection
'truststore',
# Tkinter sub-modules
'tkinter',
'tkinter.ttk',
+38
View File
@@ -12,6 +12,12 @@ import threading
import tkinter as tk
from tkinter import ttk
try:
import truststore
truststore.inject_into_ssl()
except Exception:
pass # not installed or not needed on this platform
from apscheduler.schedulers.background import BackgroundScheduler
from db.database import init_db
@@ -161,10 +167,42 @@ class LottoSightApp(tk.Tk):
def _on_fetch_done(self, results: list):
self._fetch_btn.config(state="normal", text="Fetch Now")
self._statusbar.update_fetch_results(results)
total_added = sum(r.get("added", 0) for r in results)
if total_added > 0:
self._check_predictions_vs_latest()
# Refresh the current screen if it can show new data
if self._current_screen and hasattr(self._current_screen, "refresh"):
self._current_screen.refresh()
def _check_predictions_vs_latest(self):
"""Compare all saved predictions against the most recent draw per game."""
from db.models import get_predictions, get_last_draw
preds = get_predictions(limit=100_000)
if not preds:
return
best = 0
hit_count = 0
for pred in preds:
draw = get_last_draw(pred["game_id"])
if not draw:
continue
pred_nums = {int(n) for n in pred["numbers"].split(",") if n.strip().isdigit()}
draw_nums = {int(n) for n in draw["numbers"].split(",") if n.strip().isdigit()}
matches = len(pred_nums & draw_nums)
bonus_hit = (
pred.get("bonus") and draw.get("bonus")
and str(pred["bonus"]) == str(draw["bonus"])
)
if matches >= 2 or bonus_hit:
hit_count += 1
best = max(best, matches)
if hit_count > 0:
self._statusbar.append_match_alert(
f"{hit_count} saved prediction(s) matched ≥2 numbers (best: {best}/main)"
)
# ── Lifecycle ─────────────────────────────────────────────────────────────
def on_close(self):
+1
View File
@@ -4,3 +4,4 @@ numpy>=1.26.0
matplotlib>=3.8.0
openpyxl>=3.1.0
APScheduler>=3.10.0
truststore>=0.9.0
+104
View File
@@ -0,0 +1,104 @@
"""
tests/test_backup.py
---------------------
Tests for db.database backup_db() and restore_db().
"""
import os
import pytest
from db.database import backup_db, restore_db, DB_PATH, get_connection
from db.models import insert_draw, get_draws_with_game, get_game_by_name
# ── backup_db ─────────────────────────────────────────────────────────────────
def test_backup_creates_file(tmp_db, tmp_path):
dest = backup_db(dest_dir=str(tmp_path))
assert os.path.isfile(dest)
def test_backup_filename_contains_timestamp(tmp_db, tmp_path):
dest = backup_db(dest_dir=str(tmp_path))
basename = os.path.basename(dest)
assert basename.startswith("lottosight_backup_")
assert basename.endswith(".db")
def test_backup_returns_path_string(tmp_db, tmp_path):
result = backup_db(dest_dir=str(tmp_path))
assert isinstance(result, str)
assert len(result) > 0
def test_backup_file_is_valid_sqlite(tmp_db, tmp_path):
dest = backup_db(dest_dir=str(tmp_path))
import sqlite3
conn = sqlite3.connect(dest)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = {r[0] for r in cursor.fetchall()}
conn.close()
assert "games" in tables
assert "draws" in tables
def test_backup_preserves_draw_data(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
insert_draw(game["id"], "2024-06-01", [5, 14, 22, 36, 69], bonus=7)
dest = backup_db(dest_dir=str(tmp_path))
import sqlite3
conn = sqlite3.connect(dest)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT numbers FROM draws WHERE draw_date='2024-06-01'")
row = cursor.fetchone()
conn.close()
assert row is not None
assert "5" in row["numbers"]
def test_backup_creates_dest_dir_if_missing(tmp_db, tmp_path):
nested = str(tmp_path / "a" / "b" / "c")
dest = backup_db(dest_dir=nested)
assert os.path.isfile(dest)
def test_backup_default_dest_dir(tmp_db, monkeypatch, tmp_path):
from core.paths import user_data_dir as _udd
monkeypatch.setattr("db.database.user_data_dir", lambda: str(tmp_path))
dest = backup_db() # no dest_dir — should use exports/ sub-dir
assert os.path.isfile(dest)
assert "exports" in dest
# ── restore_db ────────────────────────────────────────────────────────────────
def test_restore_missing_file_raises(tmp_db):
with pytest.raises(FileNotFoundError):
restore_db("/nonexistent/backup.db")
def test_restore_replaces_live_db(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
insert_draw(game["id"], "2024-07-04", [3, 17, 28, 45, 62], bonus=12)
backup_path = backup_db(dest_dir=str(tmp_path))
# Delete the draw from the live DB
conn = get_connection()
conn.execute("DELETE FROM draws WHERE draw_date='2024-07-04'")
conn.commit()
conn.close()
draws_before = get_draws_with_game(game_id=game["id"])
assert not any(d["draw_date"] == "2024-07-04" for d in draws_before)
restore_db(backup_path)
draws_after = get_draws_with_game(game_id=game["id"])
assert any(d["draw_date"] == "2024-07-04" for d in draws_after)
def test_restore_returns_none(tmp_db, tmp_path):
backup_path = backup_db(dest_dir=str(tmp_path))
result = restore_db(backup_path)
assert result is None
+12 -3
View File
@@ -138,7 +138,10 @@ def test_dashboard_instantiates(tmp_db):
import tkinter as tk
from ui.dashboard import DashboardScreen
root = tk.Tk(); root.withdraw()
try:
root = tk.Tk(); root.withdraw()
except Exception:
pytest.skip("Tkinter init failed")
try:
screen = DashboardScreen(root)
screen.refresh()
@@ -155,7 +158,10 @@ def test_dashboard_refresh_with_data(tmp_db):
pb = get_game_by_name("Powerball")
insert_draw(pb["id"], "2024-01-01", [1, 13, 36, 61, 69], bonus=7, multiplier="2x")
root = tk.Tk(); root.withdraw()
try:
root = tk.Tk(); root.withdraw()
except Exception:
pytest.skip("Tkinter init failed")
try:
screen = DashboardScreen(root)
screen.refresh()
@@ -169,7 +175,10 @@ def test_dashboard_refresh_empty_db(tmp_db):
import tkinter as tk
from ui.dashboard import DashboardScreen
root = tk.Tk(); root.withdraw()
try:
root = tk.Tk(); root.withdraw()
except Exception:
pytest.skip("Tkinter init failed")
try:
screen = DashboardScreen(root)
screen.refresh()
+1 -1
View File
@@ -81,7 +81,7 @@ def test_init_db_idempotent(tmp_db):
count = cursor.fetchone()["cnt"]
conn.close()
assert count == 2, f"Expected 2 games, got {count} — possible duplicate seed"
assert count == 5, f"Expected 5 seeded games, got {count} — possible duplicate seed"
def test_unique_index_on_draws(tmp_db):
+146 -32
View File
@@ -15,6 +15,9 @@ from core.fetcher import (
fetch_megamillions_ny,
fetch_megamillions_tx,
fetch_powerball_ny,
fetch_cash5_va,
fetch_millionaireforlife_va,
fetch_bankamillion_va,
)
from db.models import get_draw_count, get_draws, get_game_by_name, get_last_fetch_log
@@ -216,54 +219,165 @@ def test_mm_tx_no_header_row(tmp_db):
assert result["added"] == 1
# ── VA Lottery mock data ──────────────────────────────────────────────────────
CASH5_VA_TEXT = (
"5/22/2026; 15,29,30,34,36\n"
"5/21/2026; 1,2,5,38,44\n"
)
MILLLIFE_VA_TEXT = (
"Results for Millionaire for Life\n"
"5/22/2026; 17,33,36,54,57; Millionaire Ball: 1\n"
"5/21/2026; 3,15,16,24,28; Millionaire Ball: 4\n"
)
BANKAMIL_VA_TEXT = (
"Results for Bank a Million\n"
"5/20/2026; 14,20,21,24,33,35; Bonus Ball: 18\n"
"5/16/2026; 6,8,17,20,29,39; Bonus Ball: 38\n"
)
# ── Cash 5 VA ─────────────────────────────────────────────────────────────────
def test_cash5_va_inserts_records(tmp_db):
with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT)):
result = fetch_cash5_va()
assert result["status"] == "success"
assert result["added"] == 2
assert result["skipped"] == 0
def test_cash5_va_skips_duplicates(tmp_db):
for _ in range(2):
with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT)):
result = fetch_cash5_va()
assert result["added"] == 0
# Incremental fetch breaks early once last known date is reached — skipped stays 0
assert result["skipped"] == 0
def test_cash5_va_parses_date_and_numbers(tmp_db):
with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT)):
fetch_cash5_va()
from db.models import get_game_by_name, get_draws
game = get_game_by_name("Cash 5")
draws = get_draws(game["id"], order="ASC")
assert draws[0]["draw_date"] == "2026-05-21"
assert draws[0]["numbers"] == "1,2,5,38,44"
assert draws[0]["bonus"] is None
assert draws[0]["source"] == "cash5_va"
def test_cash5_va_network_error(tmp_db):
with patch("core.fetcher.requests.get", side_effect=req_lib.RequestException("timeout")):
result = fetch_cash5_va()
assert result["status"] == "error"
assert "timeout" in result["message"]
# ── Millionaire for Life VA ───────────────────────────────────────────────────
def test_milllife_va_inserts_records(tmp_db):
with patch("core.fetcher.requests.get", return_value=_text_resp(MILLLIFE_VA_TEXT)):
result = fetch_millionaireforlife_va()
assert result["status"] == "success"
assert result["added"] == 2
def test_milllife_va_skips_header_line(tmp_db):
with patch("core.fetcher.requests.get", return_value=_text_resp(MILLLIFE_VA_TEXT)):
result = fetch_millionaireforlife_va()
assert result["added"] == 2 # header not counted
def test_milllife_va_parses_bonus(tmp_db):
with patch("core.fetcher.requests.get", return_value=_text_resp(MILLLIFE_VA_TEXT)):
fetch_millionaireforlife_va()
from db.models import get_game_by_name, get_draws
game = get_game_by_name("Millionaire for Life")
draws = get_draws(game["id"], order="DESC")
assert draws[0]["bonus"] == "1"
assert draws[0]["numbers"] == "17,33,36,54,57"
def test_milllife_va_network_error(tmp_db):
with patch("core.fetcher.requests.get", side_effect=req_lib.RequestException("refused")):
result = fetch_millionaireforlife_va()
assert result["status"] == "error"
# ── Bank a Million VA ─────────────────────────────────────────────────────────
def test_bankamil_va_inserts_records(tmp_db):
with patch("core.fetcher.requests.get", return_value=_text_resp(BANKAMIL_VA_TEXT)):
result = fetch_bankamillion_va()
assert result["status"] == "success"
assert result["added"] == 2
def test_bankamil_va_parses_bonus_ball(tmp_db):
with patch("core.fetcher.requests.get", return_value=_text_resp(BANKAMIL_VA_TEXT)):
fetch_bankamillion_va()
from db.models import get_game_by_name, get_draws
game = get_game_by_name("Bank a Million")
draws = get_draws(game["id"], order="DESC")
assert draws[0]["bonus"] == "18"
assert draws[0]["numbers"] == "14,20,21,24,33,35"
def test_bankamil_va_skips_duplicates(tmp_db):
for _ in range(2):
with patch("core.fetcher.requests.get", return_value=_text_resp(BANKAMIL_VA_TEXT)):
result = fetch_bankamillion_va()
assert result["added"] == 0
# Incremental fetch breaks early once last known date is reached — skipped stays 0
assert result["skipped"] == 0
def test_bankamil_va_network_error(tmp_db):
with patch("core.fetcher.requests.get", side_effect=req_lib.RequestException("connect")):
result = fetch_bankamillion_va()
assert result["status"] == "error"
# ── fetch_all ─────────────────────────────────────────────────────────────────
def test_fetch_all_returns_three_sources(tmp_db):
# Both NY APIs stop after 1 call (2 records < NY_API_LIMIT), so 3 calls total
with patch("core.fetcher.requests.get", side_effect=[
_json_resp(PB_NY_RECORDS),
_json_resp(MM_NY_RECORDS),
_text_resp(MM_TX_CSV),
]):
def test_fetch_all_returns_six_sources(tmp_db):
with (
patch("core.fetcher.fetch_powerball_ny", return_value={"source": "powerball_ny", "added": 2, "skipped": 0, "status": "success", "message": None}),
patch("core.fetcher.fetch_megamillions_ny", return_value={"source": "megamillions_ny", "added": 2, "skipped": 0, "status": "success", "message": None}),
patch("core.fetcher.fetch_megamillions_tx", return_value={"source": "megamillions_tx", "added": 2, "skipped": 0, "status": "success", "message": None}),
patch("core.fetcher.fetch_cash5_va", return_value={"source": "cash5_va", "added": 2, "skipped": 0, "status": "success", "message": None}),
patch("core.fetcher.fetch_millionaireforlife_va", return_value={"source": "millionaireforlife_va", "added": 2, "skipped": 0, "status": "success", "message": None}),
patch("core.fetcher.fetch_bankamillion_va", return_value={"source": "bankamillion_va", "added": 2, "skipped": 0, "status": "success", "message": None}),
):
results = fetch_all()
assert len(results) == 3
assert len(results) == 6
sources = {r["source"] for r in results}
assert sources == {"powerball_ny", "megamillions_ny", "megamillions_tx"}
def test_fetch_all_aggregated_counts(tmp_db):
with patch("core.fetcher.requests.get", side_effect=[
_json_resp(PB_NY_RECORDS),
_json_resp(MM_NY_RECORDS),
_text_resp(MM_TX_CSV),
]):
results = fetch_all()
by_source = {r["source"]: r for r in results}
assert by_source["powerball_ny"]["added"] == 2
assert by_source["megamillions_ny"]["added"] == 2
assert by_source["megamillions_tx"]["added"] == 2
assert sources == {"powerball_ny", "megamillions_ny", "megamillions_tx",
"cash5_va", "millionaireforlife_va", "bankamillion_va"}
def test_fetch_all_continues_after_one_error(tmp_db):
"""If one source errors, the remaining sources still complete."""
error_result = {"source": "powerball_ny", "added": 0, "skipped": 0, "status": "error", "message": "timeout"}
ny_result = {"source": "megamillions_ny", "added": 2, "skipped": 0, "status": "success", "message": None}
tx_result = {"source": "megamillions_tx", "added": 2, "skipped": 0, "status": "success", "message": None}
with (
patch("core.fetcher.fetch_powerball_ny", return_value=error_result),
patch("core.fetcher.fetch_megamillions_ny", return_value=ny_result),
patch("core.fetcher.fetch_megamillions_tx", return_value=tx_result),
patch("core.fetcher.fetch_powerball_ny", return_value={"source": "powerball_ny", "added": 0, "skipped": 0, "status": "error", "message": "timeout"}),
patch("core.fetcher.fetch_megamillions_ny", return_value={"source": "megamillions_ny", "added": 2, "skipped": 0, "status": "success", "message": None}),
patch("core.fetcher.fetch_megamillions_tx", return_value={"source": "megamillions_tx", "added": 2, "skipped": 0, "status": "success", "message": None}),
patch("core.fetcher.fetch_cash5_va", return_value={"source": "cash5_va", "added": 2, "skipped": 0, "status": "success", "message": None}),
patch("core.fetcher.fetch_millionaireforlife_va", return_value={"source": "millionaireforlife_va", "added": 2, "skipped": 0, "status": "success", "message": None}),
patch("core.fetcher.fetch_bankamillion_va", return_value={"source": "bankamillion_va", "added": 2, "skipped": 0, "status": "success", "message": None}),
):
results = fetch_all()
assert len(results) == 3
assert len(results) == 6
by_source = {r["source"]: r for r in results}
assert by_source["powerball_ny"]["status"] == "error"
assert by_source["megamillions_ny"]["status"] == "success"
assert by_source["megamillions_tx"]["status"] == "success"
assert by_source["cash5_va"]["status"] == "success"
# ── Fetch log ─────────────────────────────────────────────────────────────────
+223
View File
@@ -0,0 +1,223 @@
"""
tests/test_filters.py
---------------------
Tests for core/filters.py and the filter integration in core/predictor.py.
"""
import pytest
from db.models import get_game_by_name, insert_draw
from core.filters import _has_consecutive_run, sum_range_percentiles, passes_filters
from core.predictor import (
hot_numbers, due_numbers, weighted_random,
monte_carlo, positional_pick, quick_pick,
_swap_filter, _retry_filter,
)
# ── _has_consecutive_run ──────────────────────────────────────────────────────
def test_no_consecutive():
assert _has_consecutive_run([1, 5, 10, 20, 30]) is False
def test_run_of_three_not_flagged():
assert _has_consecutive_run([1, 2, 3, 10, 20]) is False # run=3, threshold=4
def test_run_of_four_flagged():
assert _has_consecutive_run([1, 2, 3, 4, 20]) is True
def test_run_of_five_flagged():
assert _has_consecutive_run([10, 11, 12, 13, 14]) is True
def test_run_at_end():
assert _has_consecutive_run([5, 20, 30, 31, 32, 33]) is True
def test_run_not_contiguous():
assert _has_consecutive_run([1, 3, 5, 7, 9]) is False
# ── passes_filters — odd/even ─────────────────────────────────────────────────
def test_all_even_rejected():
assert passes_filters([2, 14, 28, 42, 60]) is False
def test_all_odd_rejected():
assert passes_filters([1, 7, 13, 29, 69]) is False
def test_mixed_odd_even_accepted():
assert passes_filters([1, 14, 28, 42, 60]) is True
def test_all_even_under_4_accepted():
# Odd/even filter only kicks in for 4+ numbers
assert passes_filters([2, 4, 6]) is True
# ── passes_filters — consecutive ─────────────────────────────────────────────
def test_four_consecutive_rejected():
assert passes_filters([5, 6, 7, 8, 20]) is False
def test_three_consecutive_accepted():
assert passes_filters([5, 6, 7, 20, 35]) is True
# ── passes_filters — sum range ────────────────────────────────────────────────
def test_sum_in_range_accepted():
assert passes_filters([1, 14, 28, 42, 60], sum_range=(100, 200)) is True
def test_sum_below_range_rejected():
assert passes_filters([1, 2, 3, 4, 10], sum_range=(100, 200)) is False
def test_sum_above_range_rejected():
assert passes_filters([60, 62, 64, 66, 69], sum_range=(100, 200)) is False
def test_sum_range_none_skips_check():
# All-even but sum would be out of range if range were set — only even check applies
assert passes_filters([2, 4, 6, 8, 10], sum_range=None) is False # all-even fails
def test_sum_at_boundary_accepted():
assert passes_filters([1, 14, 28, 42, 15], sum_range=(100, 100)) is True # sum=100
# ── sum_range_percentiles ─────────────────────────────────────────────────────
def test_sum_range_none_with_few_draws(tmp_db):
pb = get_game_by_name("Powerball")
for i in range(5):
insert_draw(pb["id"], f"2024-01-{i+1:02d}", [1, 2, 3, 4, i+5], bonus=1)
result = sum_range_percentiles(pb["id"])
assert result is None # < 10 draws
def test_sum_range_returns_tuple_with_enough_draws(tmp_db):
pb = get_game_by_name("Powerball")
for i in range(20):
insert_draw(pb["id"], f"2024-02-{i+1:02d}", [1+i, 2+i, 3+i, 4+i, 5+i], bonus=1)
result = sum_range_percentiles(pb["id"])
assert result is not None
lo, hi = result
assert lo < hi
assert isinstance(lo, int)
assert isinstance(hi, int)
def test_sum_range_bounds_reasonable(tmp_db):
pb = get_game_by_name("Powerball")
# Insert draws with sums ranging 1525
for i in range(15, 26):
insert_draw(pb["id"], f"2024-03-{i-14:02d}", [1, 2, 3, 4, i-10], bonus=1)
lo, hi = sum_range_percentiles(pb["id"])
assert lo >= 10 # 10th pct of sums around 15
assert hi <= 25 # 90th pct of sums around 25
# ── _swap_filter ──────────────────────────────────────────────────────────────
def test_swap_filter_passes_already():
numbers = [1, 14, 28, 42, 60]
assert _swap_filter(numbers, [2, 3, 5], None) == numbers
def test_swap_filter_fixes_all_even():
# all-even → swap last (weakest-ranked) for an odd
numbers = [2, 14, 28, 42, 60]
pool = [1, 3, 5, 7, 9] # odd alternates
result = _swap_filter(numbers, pool, None)
assert passes_filters(result), f"Expected filtered result, got {result}"
assert len(result) == 5
assert len(set(result)) == 5
def test_swap_filter_fixes_four_consecutive():
numbers = [10, 11, 12, 13, 25]
pool = [1, 5, 8, 20, 30, 35]
result = _swap_filter(numbers, pool, None)
assert passes_filters(result), f"Expected filtered result, got {result}"
def test_swap_filter_graceful_fallback():
# If no swap fixes the ticket, return original
numbers = [2, 4, 6, 8, 10] # all-even
pool = [12, 14, 16] # all-even alternates — can't fix
result = _swap_filter(numbers, pool, None)
assert result == numbers # graceful fallback
# ── Filter integration in strategies ─────────────────────────────────────────
def _large_draw_set(game_id):
"""Insert 30 varied Powerball draws so sum_range_percentiles returns a range."""
import random as rng
rng.seed(42)
from db.models import get_game_by_id
game = get_game_by_id(game_id)
for i in range(30):
nums = sorted(rng.sample(range(1, game["main_max"] + 1), game["main_count"]))
bonus = rng.randint(1, game["bonus_max"]) if game["bonus_count"] > 0 else None
insert_draw(game_id, f"2020-{(i//30)+1:02d}-{(i%28)+1:02d}", nums, bonus=bonus)
def _assert_filtered(result, game):
nums = result["numbers"]
assert len(nums) == game["main_count"]
assert nums == sorted(nums)
assert len(set(nums)) == len(nums)
assert all(1 <= n <= game["main_max"] for n in nums)
assert not (len(nums) >= 4 and all(n % 2 == 0 for n in nums)), "all-even"
assert not (len(nums) >= 4 and all(n % 2 != 0 for n in nums)), "all-odd"
assert not _has_consecutive_run(nums, 4), "4+ consecutive"
def test_hot_numbers_filter_integration(tmp_db):
pb = get_game_by_name("Powerball")
_large_draw_set(pb["id"])
for _ in range(10):
_assert_filtered(hot_numbers(pb["id"]), pb)
def test_due_numbers_filter_integration(tmp_db):
pb = get_game_by_name("Powerball")
_large_draw_set(pb["id"])
for _ in range(10):
_assert_filtered(due_numbers(pb["id"]), pb)
def test_weighted_random_filter_integration(tmp_db):
pb = get_game_by_name("Powerball")
_large_draw_set(pb["id"])
for _ in range(20):
_assert_filtered(weighted_random(pb["id"]), pb)
def test_monte_carlo_filter_integration(tmp_db):
pb = get_game_by_name("Powerball")
_large_draw_set(pb["id"])
for _ in range(5):
_assert_filtered(monte_carlo(pb["id"], simulations=500), pb)
def test_quick_pick_filter_integration(tmp_db):
pb = get_game_by_name("Powerball")
_large_draw_set(pb["id"])
for _ in range(20):
_assert_filtered(quick_pick(pb["id"]), pb)
def test_positional_pick_filter_integration(tmp_db):
pb = get_game_by_name("Powerball")
_large_draw_set(pb["id"])
for _ in range(10):
_assert_filtered(positional_pick(pb["id"]), pb)
+228
View File
@@ -0,0 +1,228 @@
"""
tests/test_importer.py
----------------------
Tests for core/importer.py CSV draw import.
Powerball config: 5 main from 169, 1 bonus from 126.
"""
import os
import pytest
from db.models import get_game_by_name, add_game, get_draws_with_game
from core.importer import import_draws_csv, _parse_date
# ── _parse_date ───────────────────────────────────────────────────────────────
def test_parse_date_iso():
assert _parse_date("2024-01-15") == "2024-01-15"
def test_parse_date_us_slash():
assert _parse_date("01/15/2024") == "2024-01-15"
def test_parse_date_us_dash():
assert _parse_date("01-15-2024") == "2024-01-15"
def test_parse_date_invalid():
assert _parse_date("not-a-date") is None
def test_parse_date_header_text():
assert _parse_date("Date") is None
assert _parse_date("draw_date") is None
# ── Wide format (Date, N1, N2, ...) ──────────────────────────────────────────
def test_wide_no_header(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text(
"2024-01-01,1,13,36,61,69,7\n"
"2024-01-03,2,7,22,45,61,15\n"
)
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 2
assert result["skipped"] == 0
assert result["errors"] == []
def test_wide_with_header(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text(
"Date,Ball1,Ball2,Ball3,Ball4,Ball5,Bonus\n"
"2024-01-01,1,13,36,61,69,7\n"
"2024-01-03,2,7,22,45,61,15\n"
)
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 2
assert result["errors"] == []
def test_wide_no_bonus_column(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text("2024-01-01,1,13,36,61,69\n")
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 1
draws = get_draws_with_game(game_id=game["id"])
assert draws[0]["bonus"] is None
def test_wide_us_date_format(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text("01/15/2024,5,14,22,36,69,7\n")
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 1
draws = get_draws_with_game(game_id=game["id"])
assert draws[0]["draw_date"] == "2024-01-15"
# ── Packed format (Date, "N1,N2,...", Bonus) ──────────────────────────────────
def test_packed_format(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text('2024-01-01,"1,13,36,61,69",7\n')
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 1
assert result["errors"] == []
def test_packed_with_header(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text(
'Date,Numbers,Bonus\n'
'2024-01-01,"1,13,36,61,69",7\n'
'2024-01-03,"2,7,22,45,61",15\n'
)
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 2
# ── LottoSight export format ──────────────────────────────────────────────────
def test_lottosight_export_format(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "export.csv"
csv_file.write_text(
"Game,Date,Numbers,Bonus,Multiplier,Source\n"
'Powerball,2024-01-01,"1,13,36,61,69",7,2x,powerball_ny\n'
'Powerball,2024-01-03,"2,7,22,45,61",15,3x,powerball_ny\n'
)
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 2
assert result["errors"] == []
# ── Duplicate detection ───────────────────────────────────────────────────────
def test_duplicate_skipped(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text(
"2024-01-01,1,13,36,61,69,7\n"
"2024-01-01,1,13,36,61,69,7\n" # same date → duplicate
)
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 1
assert result["skipped"] == 1
def test_reimport_all_skipped(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text("2024-01-01,1,13,36,61,69,7\n")
import_draws_csv(game["id"], str(csv_file))
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 0
assert result["skipped"] == 1
# ── Validation errors ─────────────────────────────────────────────────────────
def test_wrong_number_count(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text("2024-01-01,1,13,36,61\n") # only 4 numbers
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 0
assert len(result["errors"]) == 1 # rejected — too few numbers to parse
def test_number_out_of_range(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text("2024-01-01,1,13,36,61,99,7\n") # 99 > 69
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 0
assert len(result["errors"]) == 1
def test_mixed_valid_invalid(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text(
"2024-01-01,1,13,36,61,69,7\n"
"bad_date,1,2,3,4,5,6\n" # unparseable
"2024-01-03,2,7,22,45,61,15\n"
)
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 2
assert len(result["errors"]) == 1
# ── Edge cases ────────────────────────────────────────────────────────────────
def test_file_not_found(tmp_db):
game = get_game_by_name("Powerball")
result = import_draws_csv(game["id"], "/nonexistent/path/file.csv")
assert result["added"] == 0
assert len(result["errors"]) == 1
assert "not found" in result["errors"][0].lower()
def test_empty_file(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "empty.csv"
csv_file.write_text("")
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 0
assert result["errors"] == ["File is empty"]
def test_blank_lines_ignored(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text(
"2024-01-01,1,13,36,61,69,7\n"
"\n"
"2024-01-03,2,7,22,45,61,15\n"
"\n"
)
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 2
assert result["errors"] == []
def test_unknown_game_id(tmp_db, tmp_path):
csv_file = tmp_path / "draws.csv"
csv_file.write_text("2024-01-01,1,2,3,4,5\n")
result = import_draws_csv(99999, str(csv_file))
assert result["added"] == 0
assert len(result["errors"]) == 1
def test_custom_game_import(tmp_db, tmp_path):
gid = add_game("My Lotto", 6, 49, bonus_count=0, bonus_max=0)
csv_file = tmp_path / "draws.csv"
csv_file.write_text(
"2024-01-01,5,14,22,33,41,48\n"
"2024-01-08,3,17,28,35,44,49\n"
)
result = import_draws_csv(gid, str(csv_file))
assert result["added"] == 2
assert result["errors"] == []
def test_data_persisted_correctly(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text("2024-03-15,5,14,22,36,69,7\n")
import_draws_csv(game["id"], str(csv_file))
draws = get_draws_with_game(game_id=game["id"])
assert len(draws) == 1
assert draws[0]["draw_date"] == "2024-03-15"
nums = [int(n) for n in draws[0]["numbers"].split(",")]
assert sorted(nums) == [5, 14, 22, 36, 69]
assert str(draws[0]["bonus"]) == "7"
+4 -4
View File
@@ -42,15 +42,15 @@ def _mm_id(tmp_db):
# GAMES
# ══════════════════════════════════════════════════════════════════════════════
def test_get_all_games_returns_two(tmp_db):
def test_get_all_games_returns_five(tmp_db):
games = get_all_games()
assert len(games) == 2
assert len(games) == 5
def test_get_all_games_active_only(tmp_db):
"""active_only=True should return 2 by default (both active)."""
"""active_only=True should return all 5 seeded games (all active by default)."""
games = get_all_games(active_only=True)
assert len(games) == 2
assert len(games) == 5
def test_get_game_by_name_powerball(tmp_db):
+208
View File
@@ -0,0 +1,208 @@
"""
tests/test_phase22.py
----------------------
Tests for Phase 22 features:
- Incremental VA fetch (break on already-stored dates)
- top_prize column in games table
- Dashboard game filter
- Auto-check predictions after fetch (match detection logic)
"""
from unittest.mock import MagicMock, patch
import pytest
from db.database import get_db_stats, init_db
from db.models import (
get_all_games,
get_game_by_name,
get_last_draw,
insert_draw,
insert_prediction,
get_predictions,
)
from core.fetcher import fetch_cash5_va, fetch_bankamillion_va
# ── Helpers ────────────────────────────────────────────────────────────────────
def _text_resp(text, status=200):
m = MagicMock()
m.status_code = status
m.raise_for_status = MagicMock()
m.text = text
return m
CASH5_VA_TEXT_OLD = (
"5/22/2026; 15,29,30,34,36\n"
"5/21/2026; 1,2,5,38,44\n"
)
CASH5_VA_TEXT_NEWER = (
"5/23/2026; 10,11,12,13,14\n" # new record
"5/22/2026; 15,29,30,34,36\n" # already in DB
"5/21/2026; 1,2,5,38,44\n" # already in DB
)
# ── top_prize column ───────────────────────────────────────────────────────────
def test_top_prize_seeded_powerball(tmp_db):
row = get_game_by_name("Powerball")
assert row["top_prize"] == "Jackpot (variable)"
def test_top_prize_seeded_mega_millions(tmp_db):
row = get_game_by_name("Mega Millions")
assert row["top_prize"] == "Jackpot (variable)"
def test_top_prize_seeded_cash5(tmp_db):
row = get_game_by_name("Cash 5")
assert row["top_prize"] == "Jackpot from $200K"
def test_top_prize_seeded_millionaire_for_life(tmp_db):
row = get_game_by_name("Millionaire for Life")
assert row["top_prize"] == "$1M/yr for life"
def test_top_prize_seeded_bank_a_million(tmp_db):
row = get_game_by_name("Bank a Million")
assert row["top_prize"] == "$1M after taxes"
def test_top_prize_all_games_non_empty(tmp_db):
games = get_all_games()
for g in games:
# Builtin games must have a top_prize; custom games may be empty
assert "top_prize" in g.keys()
# ── Incremental VA fetch ───────────────────────────────────────────────────────
def test_incremental_fetch_only_new_records_added(tmp_db):
"""Second fetch with a new leading record only inserts that one record."""
with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT_OLD)):
r1 = fetch_cash5_va()
assert r1["added"] == 2
with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT_NEWER)):
r2 = fetch_cash5_va()
assert r2["added"] == 1
assert r2["skipped"] == 0 # stopped before re-attempting stored dates
def test_incremental_fetch_no_new_data(tmp_db):
"""Second fetch of identical data adds nothing and doesn't error."""
with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT_OLD)):
fetch_cash5_va()
with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT_OLD)):
r = fetch_cash5_va()
assert r["status"] == "success"
assert r["added"] == 0
def test_incremental_fetch_db_count_correct(tmp_db):
"""Total draws in DB after incremental fetch equals unique dates only."""
with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT_OLD)):
fetch_cash5_va()
with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT_NEWER)):
fetch_cash5_va()
game = get_game_by_name("Cash 5")
stats = get_db_stats()
assert stats["Cash 5"] == 3 # 2026-05-21, 05-22, 05-23
def test_incremental_fetch_bank_a_million(tmp_db):
"""Bank a Million incremental fetch behaves the same as Cash 5."""
bam_old = (
"Results for Bank a Million\n"
"5/20/2026; 14,20,21,24,33,35; Bonus Ball: 18\n"
"5/16/2026; 6,8,17,20,29,39; Bonus Ball: 38\n"
)
bam_newer = (
"Results for Bank a Million\n"
"5/23/2026; 1,5,10,20,35,38; Bonus Ball: 7\n"
"5/20/2026; 14,20,21,24,33,35; Bonus Ball: 18\n"
"5/16/2026; 6,8,17,20,29,39; Bonus Ball: 38\n"
)
with patch("core.fetcher.requests.get", return_value=_text_resp(bam_old)):
r1 = fetch_bankamillion_va()
assert r1["added"] == 2
with patch("core.fetcher.requests.get", return_value=_text_resp(bam_newer)):
r2 = fetch_bankamillion_va()
assert r2["added"] == 1
assert r2["skipped"] == 0
# ── Match detection logic (unit-level) ────────────────────────────────────────
def _count_matches(pred_numbers: str, draw_numbers: str) -> int:
pred = {int(n) for n in pred_numbers.split(",") if n.strip().isdigit()}
draw = {int(n) for n in draw_numbers.split(",") if n.strip().isdigit()}
return len(pred & draw)
def test_match_detection_exact(tmp_db):
pb = get_game_by_name("Powerball")
insert_draw(pb["id"], "2026-05-22", [1, 13, 36, 61, 69], bonus=7)
last = get_last_draw(pb["id"])
pred_id = insert_prediction(pb["id"], "Hot Numbers", [1, 13, 36, 61, 69], bonus=7)
preds = get_predictions(game_id=pb["id"])
pred = preds[0]
matches = _count_matches(pred["numbers"], last["numbers"])
assert matches == 5
def test_match_detection_partial(tmp_db):
pb = get_game_by_name("Powerball")
insert_draw(pb["id"], "2026-05-22", [1, 13, 36, 61, 69], bonus=7)
last = get_last_draw(pb["id"])
insert_prediction(pb["id"], "Hot Numbers", [1, 13, 5, 6, 7], bonus=99)
preds = get_predictions(game_id=pb["id"])
matches = _count_matches(preds[0]["numbers"], last["numbers"])
assert matches == 2
def test_match_detection_no_match(tmp_db):
pb = get_game_by_name("Powerball")
insert_draw(pb["id"], "2026-05-22", [1, 13, 36, 61, 69], bonus=7)
last = get_last_draw(pb["id"])
insert_prediction(pb["id"], "Due Numbers", [2, 4, 6, 8, 10], bonus=99)
preds = get_predictions(game_id=pb["id"])
matches = _count_matches(preds[0]["numbers"], last["numbers"])
assert matches == 0
def test_match_alert_threshold_is_two(tmp_db):
"""Only predictions with ≥2 main matches (or bonus hit) count toward alert."""
pb = get_game_by_name("Powerball")
insert_draw(pb["id"], "2026-05-22", [1, 13, 36, 61, 69], bonus=7)
last = get_last_draw(pb["id"])
preds_data = [
([1, 2, 3, 4, 5], 99), # 1 match — below threshold
([1, 13, 2, 3, 4], 99), # 2 matches — at threshold
([1, 13, 36, 2, 3], 99), # 3 matches — above threshold
]
for nums, bonus in preds_data:
insert_prediction(pb["id"], "Test", nums, bonus=bonus)
preds = get_predictions(game_id=pb["id"])
draw_nums = {int(n) for n in last["numbers"].split(",") if n.strip().isdigit()}
qualifying = [
p for p in preds
if len({int(n) for n in p["numbers"].split(",") if n.strip().isdigit()} & draw_nums) >= 2
]
assert len(qualifying) == 2
+135
View File
@@ -0,0 +1,135 @@
"""
tests/test_quick_pick.py
------------------------
Tests for the quick_pick strategy and the exclude parameter
added to all predictor strategies in Phase 17.
"""
import pytest
from db.models import get_game_by_name, add_game, insert_draw
from core.predictor import (
quick_pick, hot_numbers, due_numbers,
weighted_random, monte_carlo, positional_pick,
)
@pytest.fixture
def pb(tmp_db):
game = get_game_by_name("Powerball")
insert_draw(game["id"], "2024-01-01", [1, 13, 36, 61, 69], bonus=7)
insert_draw(game["id"], "2024-01-03", [1, 7, 13, 45, 61], bonus=15)
insert_draw(game["id"], "2024-01-05", [2, 13, 22, 36, 55], bonus=3)
return game
# ── quick_pick — basic validity ───────────────────────────────────────────────
def test_quick_pick_count(pb):
assert len(quick_pick(pb["id"])["numbers"]) == pb["main_count"]
def test_quick_pick_range(pb):
result = quick_pick(pb["id"])
assert all(1 <= n <= pb["main_max"] for n in result["numbers"])
def test_quick_pick_no_duplicates(pb):
nums = quick_pick(pb["id"])["numbers"]
assert len(nums) == len(set(nums))
def test_quick_pick_sorted(pb):
nums = quick_pick(pb["id"])["numbers"]
assert nums == sorted(nums)
def test_quick_pick_bonus_in_range(pb):
bonus = quick_pick(pb["id"])["bonus"]
assert bonus is not None
assert 1 <= bonus <= pb["bonus_max"]
def test_quick_pick_empty_db(tmp_db):
game = get_game_by_name("Powerball")
result = quick_pick(game["id"])
assert len(result["numbers"]) == 5
assert all(1 <= n <= 69 for n in result["numbers"])
def test_quick_pick_no_bonus_game(tmp_db):
gid = add_game("NoBonusLotto", 6, 49, bonus_count=0, bonus_max=0)
result = quick_pick(gid)
assert result["bonus"] is None
assert len(result["numbers"]) == 6
assert all(1 <= n <= 49 for n in result["numbers"])
# ── quick_pick — exclude parameter ───────────────────────────────────────────
def test_quick_pick_exclude_numbers(pb):
excl = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
result = quick_pick(pb["id"], exclude=excl)
assert not any(n in excl for n in result["numbers"])
def test_quick_pick_exclude_tight(pb):
# Exclude all but exactly main_count numbers — should still pick valid ticket
excl = set(range(1, 65)) # leaves 6569 (exactly 5 for Powerball)
result = quick_pick(pb["id"], exclude=excl)
assert len(result["numbers"]) == 5
assert all(n >= 65 for n in result["numbers"])
def test_quick_pick_exclude_too_many_falls_back(pb):
# Exclude so many that not enough remain — silently falls back
excl = set(range(1, 69)) # only [69] left, need 5
result = quick_pick(pb["id"], exclude=excl)
assert len(result["numbers"]) == 5 # must always return a full ticket
def test_quick_pick_exclude_empty_set(pb):
result = quick_pick(pb["id"], exclude=set())
assert len(result["numbers"]) == 5
def test_quick_pick_exclude_none(pb):
result = quick_pick(pb["id"], exclude=None)
assert len(result["numbers"]) == 5
# ── exclude parameter — all existing strategies ───────────────────────────────
def test_hot_numbers_exclude(pb):
excl = {1, 13, 61} # the most frequent numbers in our fixture
result = hot_numbers(pb["id"], exclude=excl)
assert not any(n in excl for n in result["numbers"])
assert len(result["numbers"]) == 5
def test_due_numbers_exclude(pb):
excl = {36, 69}
result = due_numbers(pb["id"], exclude=excl)
assert not any(n in excl for n in result["numbers"])
assert len(result["numbers"]) == 5
def test_weighted_random_exclude(pb):
excl = {1, 7, 13, 22, 36, 45, 55, 61, 69}
result = weighted_random(pb["id"], exclude=excl)
assert not any(n in excl for n in result["numbers"])
assert len(result["numbers"]) == 5
def test_monte_carlo_exclude(pb):
excl = {1, 13}
result = monte_carlo(pb["id"], simulations=200, exclude=excl)
assert not any(n in excl for n in result["numbers"])
assert len(result["numbers"]) == 5
def test_positional_pick_exclude(pb):
excl = {1, 2, 3, 4, 5}
result = positional_pick(pb["id"], exclude=excl)
assert not any(n in excl for n in result["numbers"])
assert len(result["numbers"]) == 5
def test_exclude_none_unchanged(pb):
# All strategies accept exclude=None without breaking
for fn in (hot_numbers, due_numbers, weighted_random, positional_pick):
result = fn(pb["id"], exclude=None)
assert len(result["numbers"]) == 5
def test_exclude_empty_set_unchanged(pb):
for fn in (hot_numbers, due_numbers, weighted_random, positional_pick):
result = fn(pb["id"], exclude=set())
assert len(result["numbers"]) == 5
def test_monte_carlo_exclude_none(pb):
result = monte_carlo(pb["id"], simulations=100, exclude=None)
assert len(result["numbers"]) == 5
+228
View File
@@ -0,0 +1,228 @@
"""
tests/test_recency_ensemble.py
-------------------------------
Tests for:
- Recency-weighted frequency_analysis (decay parameter)
- Ensemble prediction strategy
"""
import pytest
from db.models import get_game_by_name, insert_draw
from core.analyzer import frequency_analysis
from core.predictor import (
hot_numbers, weighted_random, monte_carlo, ensemble,
)
# ── Helpers ────────────────────────────────────────────────────────────────────
def _insert_draws(game_id, draws):
"""Insert list of (date, numbers, bonus) tuples."""
for date, nums, bonus in draws:
insert_draw(game_id, date, nums, bonus=bonus)
# ── frequency_analysis with decay ─────────────────────────────────────────────
def test_decay_zero_matches_no_decay(tmp_db):
"""decay=0.0 must return identical results to the default (no decay)."""
pb = get_game_by_name("Powerball")
_insert_draws(pb["id"], [
("2024-01-01", [1, 13, 36, 61, 69], 7),
("2024-01-03", [1, 2, 13, 45, 69], 15),
("2024-01-05", [2, 13, 22, 36, 55], 3),
])
no_decay = frequency_analysis(pb["id"])
with_zero = frequency_analysis(pb["id"], decay=0.0)
assert no_decay == with_zero
def test_decay_returns_floats(tmp_db):
pb = get_game_by_name("Powerball")
_insert_draws(pb["id"], [
("2024-01-01", [1, 13, 36, 61, 69], 7),
("2024-01-03", [5, 10, 20, 30, 40], 15),
])
freq = frequency_analysis(pb["id"], decay=0.01)
assert all(isinstance(v, float) for v in freq.values())
def test_decay_recent_number_ranked_higher(tmp_db):
"""
A number appearing only in the most recent draw should rank higher than
a number that appeared only in an older draw when decay is applied.
"""
pb = get_game_by_name("Powerball")
# Number 99→use 69 appears only in the oldest draw
# Number 1 appears only in the newest draw
_insert_draws(pb["id"], [
("2024-01-01", [69, 13, 36, 61, 55], 7), # oldest — 69 appears here
("2024-01-03", [5, 10, 20, 30, 40], 15),
("2024-01-05", [5, 10, 20, 30, 40], 3),
("2024-01-07", [5, 10, 20, 30, 40], 8),
("2024-01-09", [5, 10, 20, 30, 40], 11),
("2024-01-11", [1, 13, 22, 45, 50], 4), # newest — 1 appears here
])
freq = frequency_analysis(pb["id"], decay=0.1) # high decay for clear separation
assert freq[1] > freq[69], (
f"Recent number (1) should outrank older number (69): {freq[1]:.3f} vs {freq[69]:.3f}"
)
def test_decay_with_last_n(tmp_db):
"""last_n windows the draws before decay is applied."""
pb = get_game_by_name("Powerball")
_insert_draws(pb["id"], [
("2024-01-01", [1, 13, 36, 61, 69], 7),
("2024-01-03", [2, 4, 13, 45, 69], 15),
("2024-01-05", [5, 10, 20, 30, 40], 3),
])
freq_all = frequency_analysis(pb["id"], decay=0.01)
freq_last = frequency_analysis(pb["id"], last_n=1, decay=0.01)
# last_n=1 only sees the third draw
assert set(freq_last.keys()) == {5, 10, 20, 30, 40}
assert set(freq_all.keys()) == {1, 2, 4, 5, 10, 13, 20, 30, 36, 40, 45, 61, 69}
def test_decay_sum_of_weights_reasonable(tmp_db):
"""
Total weight with decay should be less than raw count (some weight lost to decay),
but each number's weight should be positive.
"""
pb = get_game_by_name("Powerball")
_insert_draws(pb["id"], [
("2024-01-01", [1, 2, 3, 4, 5], 7),
("2024-01-02", [1, 2, 3, 4, 5], 7),
("2024-01-03", [1, 2, 3, 4, 5], 7),
])
freq = frequency_analysis(pb["id"], decay=0.05)
# All 5 numbers appeared 3 times each; with decay the total weight < 3 per number
for n in [1, 2, 3, 4, 5]:
assert 0 < freq[n] < 3.0
def test_decay_empty_db_returns_empty(tmp_db):
pb = get_game_by_name("Powerball")
assert frequency_analysis(pb["id"], decay=0.01) == {}
# ── hot_numbers / weighted_random / monte_carlo with decay ────────────────────
def _pb_with_draws(tmp_db):
pb = get_game_by_name("Powerball")
draws = [
("2024-01-01", [1, 13, 36, 61, 69], 7),
("2024-01-03", [1, 2, 13, 45, 69], 15),
("2024-01-05", [2, 13, 22, 36, 55], 3),
("2024-01-08", [5, 18, 33, 50, 65], 22),
("2024-01-10", [7, 14, 28, 42, 60], 11),
]
_insert_draws(pb["id"], draws)
return pb
def test_hot_numbers_with_decay_valid(tmp_db):
pb = _pb_with_draws(tmp_db)
result = hot_numbers(pb["id"])
nums = result["numbers"]
assert len(nums) == pb["main_count"]
assert nums == sorted(nums)
assert len(set(nums)) == len(nums)
assert all(1 <= n <= pb["main_max"] for n in nums)
def test_weighted_random_with_decay_valid(tmp_db):
pb = _pb_with_draws(tmp_db)
for _ in range(5):
result = weighted_random(pb["id"])
assert len(result["numbers"]) == pb["main_count"]
assert all(1 <= n <= pb["main_max"] for n in result["numbers"])
def test_monte_carlo_with_decay_valid(tmp_db):
pb = _pb_with_draws(tmp_db)
result = monte_carlo(pb["id"], simulations=200)
assert len(result["numbers"]) == pb["main_count"]
assert all(1 <= n <= pb["main_max"] for n in result["numbers"])
# ── Ensemble strategy ─────────────────────────────────────────────────────────
def test_ensemble_valid_structure(tmp_db):
pb = _pb_with_draws(tmp_db)
result = ensemble(pb["id"])
nums = result["numbers"]
bonus = result["bonus"]
assert len(nums) == pb["main_count"]
assert nums == sorted(nums)
assert len(set(nums)) == len(nums)
assert all(1 <= n <= pb["main_max"] for n in nums)
assert bonus is not None
assert 1 <= bonus <= pb["bonus_max"]
def test_ensemble_empty_db_fallback(tmp_db):
pb = get_game_by_name("Powerball")
result = ensemble(pb["id"])
assert len(result["numbers"]) == pb["main_count"]
def test_ensemble_no_duplicates_across_strategies(tmp_db):
pb = _pb_with_draws(tmp_db)
result = ensemble(pb["id"])
assert len(set(result["numbers"])) == pb["main_count"]
def test_ensemble_with_exclude(tmp_db):
pb = _pb_with_draws(tmp_db)
exclude = {1, 2, 3, 4, 5}
result = ensemble(pb["id"], exclude=exclude)
assert not any(n in exclude for n in result["numbers"])
def test_ensemble_picks_consensus_number(tmp_db):
"""
13 appears in 3 of 5 draws in the fixture and should be picked by multiple
strategies; the ensemble should include it.
"""
pb = _pb_with_draws(tmp_db)
# Run several times — consensus picks should be stable
hits = sum(1 for _ in range(10) if 13 in ensemble(pb["id"])["numbers"])
assert hits >= 7, f"13 should appear in most ensemble tickets, got {hits}/10"
def test_ensemble_bonus_is_valid(tmp_db):
pb = _pb_with_draws(tmp_db)
for _ in range(5):
result = ensemble(pb["id"])
assert result["bonus"] is not None
assert 1 <= result["bonus"] <= pb["bonus_max"]
def test_ensemble_valid_for_no_bonus_game(tmp_db):
cash5 = get_game_by_name("Cash 5")
_insert_draws(cash5["id"], [
("2024-01-01", [1, 5, 10, 20, 30], None),
("2024-01-02", [2, 6, 11, 21, 31], None),
("2024-01-03", [3, 7, 12, 22, 32], None),
])
result = ensemble(cash5["id"])
assert len(result["numbers"]) == cash5["main_count"]
assert result["bonus"] is None
def test_ensemble_passes_combination_filters(tmp_db):
"""Ensemble should respect combination filters on its output."""
import random as rng
from core.filters import _has_consecutive_run
rng.seed(99)
pb = get_game_by_name("Powerball")
for i in range(30):
nums = sorted(rng.sample(range(1, pb["main_max"] + 1), pb["main_count"]))
insert_draw(pb["id"], f"2020-{(i//28)+1:02d}-{(i%28)+1:02d}", nums, bonus=rng.randint(1, 26))
for _ in range(10):
result = ensemble(pb["id"])
nums = result["numbers"]
assert not (len(nums) >= 4 and all(n % 2 == 0 for n in nums)), "all-even"
assert not (len(nums) >= 4 and all(n % 2 != 0 for n in nums)), "all-odd"
assert not _has_consecutive_run(nums, 4), "4+ consecutive"
+98
View File
@@ -0,0 +1,98 @@
"""
tests/test_wheeling.py
-----------------------
Tests for core/wheeling.py full-cover wheel generation.
"""
import pytest
from core.wheeling import wheel_full, wheel_count, MAX_TICKETS
# ── wheel_count ───────────────────────────────────────────────────────────────
def test_wheel_count_basic():
assert wheel_count([1, 2, 3, 4, 5, 6], 5) == 6 # C(6,5)
def test_wheel_count_exact_pick():
assert wheel_count([1, 2, 3, 4, 5], 5) == 1 # C(5,5)
def test_wheel_count_larger():
assert wheel_count(list(range(1, 10)), 5) == 126 # C(9,5)
def test_wheel_count_k_zero_returns_zero():
assert wheel_count([1, 2, 3, 4, 5], 0) == 0
def test_wheel_count_k_exceeds_n_returns_zero():
assert wheel_count([1, 2, 3], 5) == 0
def test_wheel_count_deduplicates_input():
assert wheel_count([1, 1, 2, 3, 4, 5], 5) == 1 # C(5,5) after dedup
# ── wheel_full — valid cases ──────────────────────────────────────────────────
def test_wheel_full_ticket_count():
tickets = wheel_full([1, 2, 3, 4, 5, 6], 5)
assert len(tickets) == 6
def test_wheel_full_single_ticket():
tickets = wheel_full([5, 14, 22, 36, 69], 5)
assert len(tickets) == 1
assert tickets[0] == [5, 14, 22, 36, 69]
def test_wheel_full_each_ticket_sorted():
tickets = wheel_full([10, 3, 7, 1, 5, 2], 4)
for t in tickets:
assert t == sorted(t)
def test_wheel_full_no_duplicate_tickets():
tickets = wheel_full(list(range(1, 9)), 5) # C(8,5) = 56
as_tuples = [tuple(t) for t in tickets]
assert len(as_tuples) == len(set(as_tuples))
def test_wheel_full_all_numbers_in_pool():
pool = [5, 14, 22, 36, 55, 69]
tickets = wheel_full(pool, 4)
for t in tickets:
for n in t:
assert n in pool
def test_wheel_full_deduplicates_input():
# [1,1,2,3,4,5] → pool [1,2,3,4,5] → C(5,5) = 1
tickets = wheel_full([1, 1, 2, 3, 4, 5], 5)
assert len(tickets) == 1
def test_wheel_full_at_cap(monkeypatch):
import core.wheeling as wm
monkeypatch.setattr(wm, "MAX_TICKETS", 56)
# C(8,5) = 56 exactly at cap — should pass
tickets = wm.wheel_full(list(range(1, 9)), 5)
assert len(tickets) == 56
def test_wheel_full_returns_list_of_lists():
tickets = wheel_full([1, 2, 3, 4, 5, 6], 5)
assert isinstance(tickets, list)
for t in tickets:
assert isinstance(t, list)
# ── wheel_full — error cases ──────────────────────────────────────────────────
def test_wheel_full_k_zero_raises():
with pytest.raises(ValueError, match="at least 1"):
wheel_full([1, 2, 3, 4, 5], 0)
def test_wheel_full_k_exceeds_n_raises():
with pytest.raises(ValueError, match="exceeds"):
wheel_full([1, 2, 3], 5)
def test_wheel_full_exceeds_cap_raises():
# C(10,5) = 252 > MAX_TICKETS (200)
with pytest.raises(ValueError, match="252"):
wheel_full(list(range(1, 11)), 5)
def test_wheel_full_error_message_includes_count():
with pytest.raises(ValueError) as exc_info:
wheel_full(list(range(1, 11)), 5)
assert "252" in str(exc_info.value)
assert str(MAX_TICKETS) in str(exc_info.value)
+124
View File
@@ -0,0 +1,124 @@
"""
tests/test_widgets.py
---------------------
Tests for ui/widgets.py ball_color() is pure and testable without a display.
BallsBar creation tests require Tkinter and are skipped when unavailable.
"""
import pytest
from ui.widgets import ball_color, BallsBar
def _has_display():
try:
import tkinter as tk
r = tk.Tk(); r.withdraw(); r.destroy()
return True
except Exception:
return False
# ── ball_color — pure function, no display needed ─────────────────────────────
def test_ball_color_returns_two_hex_strings():
bg, fg = ball_color(7)
assert bg.startswith("#") and len(bg) == 7
assert fg.startswith("#") and len(fg) == 7
def test_ball_color_bonus_is_red():
bg, _ = ball_color(7, is_bonus=True)
assert bg == "#e74c3c"
def test_ball_color_bonus_same_regardless_of_number():
assert ball_color(1, is_bonus=True) == ball_color(26, is_bonus=True)
assert ball_color(99, is_bonus=True) == ball_color(5, is_bonus=True)
def test_ball_color_different_ranges_have_different_colors():
bgs = {ball_color(n)[0] for n in [1, 15, 25, 35, 45, 55, 65, 75]}
assert len(bgs) >= 5 # at least 5 distinct background colors
def test_ball_color_boundary_values():
# Each range boundary should resolve without error
for n in [1, 9, 10, 19, 20, 29, 30, 39, 40, 49, 50, 59, 60, 69, 70, 99]:
bg, fg = ball_color(n)
assert bg.startswith("#")
assert fg.startswith("#")
def test_ball_color_out_of_defined_range():
# Number > 99 falls back gracefully
bg, fg = ball_color(200)
assert bg.startswith("#")
assert fg.startswith("#")
def test_ball_color_is_deterministic():
for n in [1, 7, 14, 22, 35, 49, 69]:
assert ball_color(n) == ball_color(n)
def test_ball_color_non_bonus_not_red():
# Main balls in defined ranges should not be the bonus red
for n in range(1, 70):
bg, _ = ball_color(n, is_bonus=False)
assert bg != "#e74c3c" or n >= 60 # only 60-69 range is red
# ── BallsBar — needs display ──────────────────────────────────────────────────
@pytest.mark.skipif(not _has_display(), reason="no display available")
def test_ballsbar_creates_widget():
import tkinter as tk
try:
root = tk.Tk(); root.withdraw()
except Exception:
pytest.skip("Tkinter init failed")
try:
bar = BallsBar(root, numbers=[1, 7, 14, 22, 35], bonus=3)
assert bar.winfo_exists()
finally:
root.destroy()
@pytest.mark.skipif(not _has_display(), reason="no display available")
def test_ballsbar_no_bonus():
import tkinter as tk
try:
root = tk.Tk(); root.withdraw()
except Exception:
pytest.skip("Tkinter init failed")
try:
bar = BallsBar(root, numbers=[5, 12, 30, 44, 66])
assert bar.winfo_exists()
assert int(bar.cget("width")) > 0
finally:
root.destroy()
@pytest.mark.skipif(not _has_display(), reason="no display available")
def test_ballsbar_with_highlights():
import tkinter as tk
try:
root = tk.Tk(); root.withdraw()
except Exception:
pytest.skip("Tkinter init failed")
try:
hi = {7: ("#27ae60", "#ffffff"), 14: ("#cccccc", "#888888")}
bar = BallsBar(root, numbers=[1, 7, 14, 22, 35], highlights=hi)
assert bar.winfo_exists()
finally:
root.destroy()
@pytest.mark.skipif(not _has_display(), reason="no display available")
def test_ballsbar_larger_radius():
import tkinter as tk
try:
root = tk.Tk(); root.withdraw()
except Exception:
pytest.skip("Tkinter init failed")
try:
bar_small = BallsBar(root, numbers=[1, 2, 3], radius=11)
bar_normal = BallsBar(root, numbers=[1, 2, 3], radius=14)
w_small = int(bar_small.cget("width"))
w_normal = int(bar_normal.cget("width"))
assert w_normal > w_small
finally:
root.destroy()
+81 -18
View File
@@ -15,7 +15,8 @@ from datetime import date, timedelta
from db.database import get_db_stats
from db.models import get_all_games, get_last_draw, get_draw_count, get_predictions
from core.analyzer import frequency_analysis
from core.analyzer import frequency_analysis, gap_analysis
from ui.widgets import BallsBar
# Weekday indices: Monday=0 … Sunday=6
_DRAW_DAYS: dict[str, list[int]] = {
@@ -53,6 +54,7 @@ class DashboardScreen(ttk.Frame):
def __init__(self, parent, on_fetch=None, **kwargs):
super().__init__(parent, **kwargs)
self._on_fetch = on_fetch
self._filter_var = tk.StringVar(value="All Games")
self._build_ui()
# ── Layout ────────────────────────────────────────────────────────────────
@@ -65,7 +67,7 @@ class DashboardScreen(ttk.Frame):
vsb.pack(side="right", fill="y")
canvas.pack(side="left", fill="both", expand=True)
inner = ttk.Frame(canvas, padding=(28, 20, 28, 20))
inner = ttk.Frame(canvas, padding=(28, 16, 28, 20))
win = canvas.create_window((0, 0), window=inner, anchor="nw")
def _resize(event=None):
@@ -81,6 +83,18 @@ class DashboardScreen(ttk.Frame):
lambda e: canvas.unbind_all("<MouseWheel>"))
self._inner = inner
# Game filter bar
filter_bar = ttk.Frame(inner)
filter_bar.pack(fill="x", pady=(0, 12))
ttk.Label(filter_bar, text="Show game:").pack(side="left", padx=(0, 6))
self._game_filter = ttk.Combobox(
filter_bar, textvariable=self._filter_var,
state="readonly", width=22,
)
self._game_filter.pack(side="left")
self._game_filter.bind("<<ComboboxSelected>>", lambda _e: self.refresh())
self._draw_sections()
def _section_header(self, title: str):
@@ -96,25 +110,41 @@ class DashboardScreen(ttk.Frame):
self._last_draw_body = self._section_header("Last Draw Results")
self._db_body = self._section_header("Database Summary")
self._hot_body = self._section_header("Hot Numbers (last 100 draws)")
self._overdue_body = self._section_header("Most Overdue Numbers")
# ── Refresh ───────────────────────────────────────────────────────────────
def _update_filter_options(self):
games = get_all_games(active_only=True)
names = ["All Games"] + [g["name"] for g in games]
self._game_filter["values"] = names
if self._filter_var.get() not in names:
self._filter_var.set("All Games")
def _filtered_games(self):
games = get_all_games(active_only=True)
sel = self._filter_var.get()
if not sel or sel == "All Games":
return games
return [g for g in games if g["name"] == sel]
def refresh(self):
self._update_filter_options()
self._refresh_last_draws()
self._refresh_db_summary()
self._refresh_hot_numbers()
self._refresh_overdue()
def _refresh_last_draws(self):
for w in self._last_draw_body.winfo_children():
w.destroy()
games = get_all_games(active_only=True)
games = self._filtered_games()
if not games:
ttk.Label(self._last_draw_body, text="No active games.",
foreground="#aaaaaa").pack(anchor="w")
return
# Lay cards out in a horizontal row that wraps via grid
row_frame = ttk.Frame(self._last_draw_body)
row_frame.pack(fill="x")
@@ -125,19 +155,21 @@ class DashboardScreen(ttk.Frame):
)
card.grid(row=0, column=col_idx, padx=(0, 16), sticky="nw")
top_prize = game["top_prize"] if "top_prize" in game.keys() else ""
if top_prize:
ttk.Label(card, text=f"Top prize: {top_prize}",
foreground="#8e44ad", font=_CARD_FONT).pack(anchor="w")
if draw is None:
ttk.Label(card, text="No draws yet.", foreground="#aaaaaa",
font=_CARD_FONT).pack(anchor="w")
else:
ttk.Label(card, text=draw["draw_date"],
foreground="#555555", font=_CARD_FONT).pack(anchor="w")
ttk.Label(card,
text=_fmt_numbers(draw["numbers"]),
font=_NUMBER_FONT, foreground="#1a1a2e").pack(anchor="w", pady=(6, 2))
if game["bonus_count"] > 0 and draw["bonus"]:
bonus_label = "Bonus" if game["name"] != "Powerball" else "Powerball"
ttk.Label(card, text=f"{bonus_label}: {draw['bonus']}",
foreground="#2471a3", font=_CARD_FONT).pack(anchor="w")
nums = [int(n) for n in draw["numbers"].split(",") if n.strip().isdigit()]
bonus = (int(draw["bonus"]) if draw["bonus"] and str(draw["bonus"]).isdigit()
and game["bonus_count"] > 0 else None)
BallsBar(card, numbers=nums, bonus=bonus).pack(anchor="w", pady=(6, 2))
if draw["multiplier"]:
ttk.Label(card, text=f"Multiplier: {draw['multiplier']}",
foreground="#777777", font=_CARD_FONT).pack(anchor="w")
@@ -172,7 +204,7 @@ class DashboardScreen(ttk.Frame):
for w in self._hot_body.winfo_children():
w.destroy()
games = get_all_games(active_only=True)
games = self._filtered_games()
if not games:
ttk.Label(self._hot_body, text="No active games.",
foreground="#aaaaaa").pack(anchor="w")
@@ -191,10 +223,41 @@ class DashboardScreen(ttk.Frame):
font=_CARD_FONT).pack(side="left")
continue
top5 = sorted(freq, key=freq.get, reverse=True)[:5]
nums_str = " ".join(f"{n:02d}" for n in sorted(top5))
ttk.Label(row, text=nums_str, font=_CARD_FONT,
foreground="#1a5276").pack(side="left")
counts_str = " ".join(f"({freq[n]}×)" for n in sorted(top5))
ttk.Label(row, text=f" {counts_str}", foreground="#aaaaaa",
top5 = sorted(sorted(freq, key=freq.get, reverse=True)[:5])
BallsBar(row, numbers=top5, radius=11).pack(side="left", padx=(0, 6))
counts_str = " ".join(f"({freq[n]}×)" for n in top5)
ttk.Label(row, text=counts_str, foreground="#aaaaaa",
font=_CARD_FONT).pack(side="left")
def _refresh_overdue(self):
for w in self._overdue_body.winfo_children():
w.destroy()
games = self._filtered_games()
if not games:
ttk.Label(self._overdue_body, text="No active games.",
foreground="#aaaaaa").pack(anchor="w")
return
_ORANGE = ("#e67e22", "#ffffff")
for game in games:
gaps = gap_analysis(game["id"]) # {number: gap}
row = ttk.Frame(self._overdue_body)
row.pack(fill="x", pady=3)
ttk.Label(row, text=f"{game['name']}:", width=18, anchor="w",
font=_CARD_FONT).pack(side="left")
if not gaps:
ttk.Label(row, text="No data yet.", foreground="#aaaaaa",
font=_CARD_FONT).pack(side="left")
continue
top5 = sorted(sorted(gaps, key=gaps.get, reverse=True)[:5])
highlights = {n: _ORANGE for n in top5}
BallsBar(row, numbers=top5, radius=11,
highlights=highlights).pack(side="left", padx=(0, 6))
gaps_str = " ".join(f"({gaps[n]} ago)" for n in top5)
ttk.Label(row, text=gaps_str, foreground="#aaaaaa",
font=_CARD_FONT).pack(side="left")
+43
View File
@@ -13,6 +13,7 @@ from db.models import get_all_games, get_game_by_name, get_draws_with_game
from core.exporter import (
export_draws_excel, export_draws_csv, default_path, ensure_exports_dir,
)
from ui.widgets import BallsBar
_COLUMNS = ("game", "date", "numbers", "bonus", "multiplier", "source")
_LABELS = {
@@ -115,6 +116,15 @@ class HistoryScreen(ttk.Frame):
tree_frame.rowconfigure(0, weight=1)
tree_frame.columnconfigure(0, weight=1)
# ── Ball detail strip ─────────────────────────────────────────────────
ttk.Separator(self, orient="horizontal").pack(fill="x", padx=6)
self._detail_frame = ttk.Frame(self, padding=(8, 3, 8, 3))
self._detail_frame.pack(fill="x")
ttk.Label(self._detail_frame, text="Select a row to preview.",
foreground="#aaaaaa", font=("TkDefaultFont", 8)).pack(anchor="w")
self._tree.bind("<<TreeviewSelect>>", self._on_row_select)
# ── Row count ─────────────────────────────────────────────────────────
self._count_var = tk.StringVar(value="0 rows")
ttk.Label(self, textvariable=self._count_var, anchor="e",
@@ -173,6 +183,7 @@ class HistoryScreen(ttk.Frame):
def _populate(self, rows):
self._tree.delete(*self._tree.get_children())
self._clear_detail()
for row in rows:
self._tree.insert("", "end", values=(
row["game_name"],
@@ -185,6 +196,38 @@ class HistoryScreen(ttk.Frame):
count = len(rows)
self._count_var.set(f"{count} row{'s' if count != 1 else ''}")
def _clear_detail(self):
for w in self._detail_frame.winfo_children():
w.destroy()
ttk.Label(self._detail_frame, text="Select a row to preview.",
foreground="#aaaaaa", font=("TkDefaultFont", 8)).pack(anchor="w")
def _on_row_select(self, _event=None):
sel = self._tree.selection()
if not sel:
self._clear_detail()
return
vals = self._tree.item(sel[0], "values")
# vals: (game, date, numbers_fmt, bonus, multiplier, source)
nums_text = vals[2] # e.g. "1 13 36 61 69"
bonus_text = str(vals[3])
try:
nums = [int(x) for x in nums_text.split() if x.isdigit()]
bonus = int(bonus_text) if bonus_text.isdigit() else None
except Exception:
self._clear_detail()
return
if not nums:
self._clear_detail()
return
for w in self._detail_frame.winfo_children():
w.destroy()
row_frame = ttk.Frame(self._detail_frame)
row_frame.pack(anchor="w")
ttk.Label(row_frame, text=vals[1], foreground="#555555",
font=("TkDefaultFont", 8), width=11).pack(side="left")
BallsBar(row_frame, numbers=nums, bonus=bonus).pack(side="left")
# ── Sorting ───────────────────────────────────────────────────────────────
def _sort_by(self, col):
+395 -9
View File
@@ -1,11 +1,12 @@
"""
ui/predictor_ui.py
------------------
Prediction generator screen two tabs inside a ttk.Notebook.
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 real
draw, delete individual rows or clear all
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
@@ -19,32 +20,50 @@ from db.models import (
delete_prediction, delete_all_predictions,
)
from core.predictor import (
hot_numbers, due_numbers, weighted_random, monte_carlo, positional_pick,
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 = {
"Hot Numbers": "Top 5 most frequent numbers from the last 100 draws.",
"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 each number's historical frequency.",
"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:
@@ -64,6 +83,7 @@ class PredictorScreen(ttk.Frame):
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 ───────────────────────────────────────────────────────
@@ -75,13 +95,16 @@ class PredictorScreen(ttk.Frame):
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 ──────────────────────────────────────────────────────────
@@ -114,8 +137,12 @@ class PredictorScreen(ttk.Frame):
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=(14, 0))
self._gen_btn.pack(side="left", padx=(12, 0))
# Results treeview
tree_frame = ttk.Frame(parent)
@@ -137,6 +164,13 @@ class PredictorScreen(ttk.Frame):
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")
@@ -156,6 +190,11 @@ class PredictorScreen(ttk.Frame):
)
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
@@ -229,6 +268,7 @@ class PredictorScreen(ttk.Frame):
def refresh(self):
self._load_games()
self._load_check_games()
self._load_wheel_games()
self._refresh_saved()
def _load_games(self):
@@ -317,10 +357,12 @@ class PredictorScreen(ttk.Frame):
self.update_idletasks()
try:
tickets = [strategy_fn(self._game_id) for _ in range(count)]
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:
@@ -352,10 +394,58 @@ class PredictorScreen(ttk.Frame):
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):
@@ -491,6 +581,13 @@ class PredictorScreen(ttk.Frame):
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,
@@ -569,13 +666,302 @@ class PredictorScreen(ttk.Frame):
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]
+75 -7
View File
@@ -7,24 +7,31 @@ on_fetch: callable injected by main.py to trigger the shared fetch thread.
"""
import tkinter as tk
from tkinter import ttk, messagebox
from tkinter import ttk, messagebox, filedialog
import logging
from db.database import get_db_stats
from db.database import get_db_stats, backup_db, restore_db
from db.models import (
get_all_games, get_draw_count, set_game_active,
get_last_fetch_per_source, get_predictions,
add_game, delete_game, _BUILTIN_GAMES,
)
from core.importer import import_draws_csv
logger = logging.getLogger(__name__)
_SOURCE_NAMES = {
"powerball_ny": "Powerball (NY)",
"megamillions_ny": "Mega Millions (NY)",
"megamillions_tx": "Mega Millions (TX)",
"powerball_ny": "Powerball (NY)",
"megamillions_ny": "Mega Millions (NY)",
"megamillions_tx": "Mega Millions (TX)",
"cash5_va": "Cash 5 (VA)",
"millionaireforlife_va": "Millionaire for Life (VA)",
"bankamillion_va": "Bank a Million (VA)",
}
_ALL_SOURCES = ["powerball_ny", "megamillions_ny", "megamillions_tx"]
_ALL_SOURCES = [
"powerball_ny", "megamillions_ny", "megamillions_tx",
"cash5_va", "millionaireforlife_va", "bankamillion_va",
]
_HEADER_FONT = ("TkDefaultFont", 10, "bold")
@@ -80,6 +87,7 @@ class SettingsScreen(ttk.Frame):
self._sources_body = self._section("Data Sources")
self._fetch_body = self._build_fetch_section()
self._db_body = self._section("Database")
self._build_db_actions()
def _build_fetch_section(self):
body = self._section("Fetch Schedule")
@@ -132,11 +140,16 @@ class SettingsScreen(ttk.Frame):
foreground="#777777",
).pack(side="left", padx=(16, 0))
ttk.Button(
row, text="Import CSV",
command=lambda gid=game["id"], gname=game["name"]: self._import_csv(gid, gname),
).pack(side="left", padx=(12, 0))
if game["name"] not in _BUILTIN_GAMES:
ttk.Button(
row, text="Delete",
command=lambda gid=game["id"], gname=game["name"]: self._delete_game(gid, gname),
).pack(side="left", padx=(12, 0))
).pack(side="left", padx=(6, 0))
ttk.Button(
self._games_body, text="+ Add Custom Game",
@@ -187,6 +200,13 @@ class SettingsScreen(ttk.Frame):
ttk.Label(row, text="Predictions:", width=18, anchor="w").pack(side="left")
ttk.Label(row, text=str(pred_count), foreground="#555555").pack(side="left")
def _build_db_actions(self):
body = self._section("Database Actions")
row = ttk.Frame(body)
row.pack(fill="x")
ttk.Button(row, text="Backup DB", command=self._backup_db ).pack(side="left", padx=(0, 8))
ttk.Button(row, text="Restore DB", command=self._restore_db).pack(side="left")
# ── Actions ───────────────────────────────────────────────────────────────
def _toggle_game(self, game_id: int, var: tk.BooleanVar):
@@ -203,6 +223,54 @@ class SettingsScreen(ttk.Frame):
else:
self._fetch_msg_var.set("Fetch not available.")
def _import_csv(self, game_id: int, game_name: str):
fp = filedialog.askopenfilename(
title=f"Import draws for {game_name}",
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
)
if not fp:
return
result = import_draws_csv(game_id, fp)
added = result["added"]
skipped = result["skipped"]
errors = result["errors"]
msg = f"Added {added:,} draw{'s' if added != 1 else ''}."
if skipped:
msg += f"\nSkipped {skipped:,} duplicate{'s' if skipped != 1 else ''}."
if errors:
preview = "\n".join(errors[:5])
suffix = f"\n… and {len(errors) - 5} more." if len(errors) > 5 else ""
msg += f"\n\n{len(errors)} row error(s):\n{preview}{suffix}"
messagebox.showinfo("Import Complete", msg)
self._refresh_games()
def _backup_db(self):
try:
dest = backup_db()
messagebox.showinfo("Backup Complete", f"Database backed up to:\n{dest}")
except Exception as e:
messagebox.showerror("Backup Failed", str(e))
def _restore_db(self):
fp = filedialog.askopenfilename(
title="Select backup file to restore",
filetypes=[("SQLite DB", "*.db"), ("All files", "*.*")],
)
if not fp:
return
if not messagebox.askyesno(
"Confirm Restore",
"Restoring will overwrite the current database.\n"
"This cannot be undone. Continue?",
):
return
try:
restore_db(fp)
messagebox.showinfo("Restore Complete",
"Database restored. Please restart the app.")
except Exception as e:
messagebox.showerror("Restore Failed", str(e))
def _open_add_game_dialog(self):
_AddGameDialog(self, on_save=self._refresh_games)
+13 -5
View File
@@ -10,9 +10,12 @@ from tkinter import ttk
from datetime import datetime
_SOURCE_NAMES = {
"powerball_ny": "Powerball",
"megamillions_ny": "Mega Millions (NY)",
"megamillions_tx": "Mega Millions (TX)",
"powerball_ny": "Powerball",
"megamillions_ny": "Mega Millions (NY)",
"megamillions_tx": "Mega Millions (TX)",
"cash5_va": "Cash 5 (VA)",
"millionaireforlife_va": "Millionaire for Life (VA)",
"bankamillion_va": "Bank a Million (VA)",
}
@@ -50,5 +53,10 @@ class StatusBar(ttk.Frame):
parts.append(f"{name} — ERROR: {r['message']}")
else:
parts.append(f"{name}{r['added']} added, {r['skipped']} skipped")
text = "Last fetch: " + " | ".join(parts) + f" | {now}"
self.set_text(text)
self._last_fetch_text = "Last fetch: " + " | ".join(parts) + f" | {now}"
self.set_text(self._last_fetch_text)
def append_match_alert(self, msg: str):
"""Append a prediction-match alert to the current status text."""
base = getattr(self, "_last_fetch_text", self._label.cget("text"))
self.set_text(f"{base}{msg}")
+109
View File
@@ -0,0 +1,109 @@
"""
ui/widgets.py
-------------
Shared reusable widgets.
ball_color(number, is_bonus) -> (bg_hex, fg_hex)
BallsBar tk.Canvas that draws a horizontal row of numbered lottery balls
"""
import tkinter as tk
# (low, high, bg_hex, fg_hex) — range-based colour scheme
_RANGES = [
( 1, 9, "#f0f0f0", "#333333"),
(10, 19, "#ffd700", "#333333"),
(20, 29, "#ff7f50", "#ffffff"),
(30, 39, "#4a9edd", "#ffffff"),
(40, 49, "#3cb371", "#ffffff"),
(50, 59, "#9b59b6", "#ffffff"),
(60, 69, "#e74c3c", "#ffffff"),
(70, 99, "#555555", "#ffffff"),
]
_BONUS_BG = "#e74c3c"
_BONUS_FG = "#ffffff"
_FALLBACK = ("#aaaaaa", "#333333")
def ball_color(number: int, is_bonus: bool = False) -> tuple[str, str]:
"""Return (bg_hex, fg_hex) for a lottery ball. Pure function, no Tk needed."""
if is_bonus:
return _BONUS_BG, _BONUS_FG
for lo, hi, bg, fg in _RANGES:
if lo <= number <= hi:
return bg, fg
return _FALLBACK
class BallsBar(tk.Canvas):
"""
Horizontal row of numbered lottery balls drawn on a tk.Canvas.
numbers list of main ball integers
bonus optional single bonus ball integer (drawn after a gap, in red)
radius ball radius in pixels (default 14 28 px diameter)
highlights {number: (bg_hex, fg_hex)} overrides for specific balls
(e.g. green for matched, grey for unmatched in ticket checker)
"""
_GAP = 3 # px between adjacent balls
_SEP = 10 # extra px gap before the bonus ball
def __init__(
self,
parent,
numbers: list[int],
bonus: int | None = None,
radius: int = 14,
highlights: dict | None = None,
**kwargs,
):
self._radius = radius
self._highlights = highlights or {}
diam = radius * 2
n_main = len(numbers)
n_bonus = 1 if bonus is not None else 0
width = (
n_main * (diam + self._GAP)
+ (self._SEP + n_bonus * (diam + self._GAP) if n_bonus else 0)
+ 4
)
height = diam + 4
# Inherit parent background so the canvas blends in seamlessly
if "bg" not in kwargs and "background" not in kwargs:
try:
kwargs["background"] = parent.cget("background")
except Exception:
pass
kwargs.setdefault("highlightthickness", 0)
kwargs.setdefault("bd", 0)
super().__init__(parent, width=width, height=height, **kwargs)
self._draw(numbers, bonus, diam)
def _draw(self, numbers: list[int], bonus: int | None, diam: int):
r = self._radius
x = 2 + r
y = r + 2
for num in numbers:
bg, fg = self._highlights.get(num) or ball_color(num, False)
self._ball(x, y, num, bg, fg)
x += diam + self._GAP
if bonus is not None:
x += self._SEP
bg, fg = self._highlights.get(bonus) or ball_color(bonus, True)
self._ball(x, y, bonus, bg, fg)
def _ball(self, cx: int, cy: int, number: int, bg: str, fg: str):
r = self._radius - 1
self.create_oval(cx - r, cy - r, cx + r, cy + r,
fill=bg, outline="#aaaaaa", width=1)
size = 7 if number >= 10 else 8
self.create_text(cx, cy, text=str(number),
font=("TkDefaultFont", size, "bold"), fill=fg)