Compare commits
10
Commits
4edc527458
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c05867a642 | ||
|
|
c61c09db94 | ||
|
|
59789f3cfe | ||
|
|
edd2ed5660 | ||
|
|
c792d9878e | ||
|
|
b5e03fbf10 | ||
|
|
ea30c7d838 | ||
|
|
2553406254 | ||
|
|
7506e94d61 | ||
|
|
57c777dc82 |
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/ \
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 | 1–69 | 1 (1–26) | NY Open Data API |
|
||||
| Mega Millions | 5 | 1–70 | 1 (1–25) | NY Open Data API + Texas Lottery CSV |
|
||||
| Cash 5 (VA) | 5 | 1–45 | — | Virginia Lottery API |
|
||||
| Millionaire for Life (VA) | 5 | 1–58 | 1 (1–5) | Virginia Lottery API |
|
||||
| Bank a Million (VA) | 6 | 1–40 | 1 (1–40) | 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 10th–90th 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.
|
||||
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
+19
-5
@@ -6,28 +6,42 @@ 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:]
|
||||
|
||||
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))
|
||||
|
||||
# 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
@@ -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:
|
||||
|
||||
@@ -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 10th–90th 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
|
||||
@@ -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}
|
||||
+208
-41
@@ -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)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
# 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)
|
||||
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()
|
||||
|
||||
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)
|
||||
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
|
||||
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 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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -4,3 +4,4 @@ numpy>=1.26.0
|
||||
matplotlib>=3.8.0
|
||||
openpyxl>=3.1.0
|
||||
APScheduler>=3.10.0
|
||||
truststore>=0.9.0
|
||||
|
||||
@@ -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
|
||||
@@ -138,7 +138,10 @@ def test_dashboard_instantiates(tmp_db):
|
||||
import tkinter as tk
|
||||
from ui.dashboard import DashboardScreen
|
||||
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
root = tk.Tk(); root.withdraw()
|
||||
except Exception:
|
||||
pytest.skip("Tkinter init failed")
|
||||
try:
|
||||
screen = DashboardScreen(root)
|
||||
screen.refresh()
|
||||
|
||||
@@ -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
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 15–25
|
||||
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)
|
||||
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
tests/test_importer.py
|
||||
----------------------
|
||||
Tests for core/importer.py — CSV draw import.
|
||||
|
||||
Powerball config: 5 main from 1–69, 1 bonus from 1–26.
|
||||
"""
|
||||
|
||||
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"
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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 65–69 (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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
+394
-8
@@ -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
|
||||
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 (1–5).",
|
||||
"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]
|
||||
|
||||
+72
-4
@@ -7,15 +7,16 @@ 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__)
|
||||
|
||||
@@ -23,8 +24,14 @@ _SOURCE_NAMES = {
|
||||
"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)
|
||||
|
||||
|
||||
+10
-2
@@ -13,6 +13,9 @@ _SOURCE_NAMES = {
|
||||
"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
@@ -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)
|
||||
Reference in New Issue
Block a user