From c05867a642cefb05b12a04370c955a4bd1424a40 Mon Sep 17 00:00:00 2001 From: Nguyen HP Laptop Date: Sat, 23 May 2026 18:16:56 -0400 Subject: [PATCH] 05/23 update codes, add build scripts --- .claude/settings.local.json | 11 +- README.md | 221 +++++++++++++++++++++++++++++++- build.bat | 16 +++ build.ps1 | 120 +++++++++++++++++ core/analyzer.py | 30 +++-- core/predictor.py | 64 +++++++-- data/lottosight.db | Bin 2002944 -> 2002944 bytes lottosight.spec | 4 +- tests/test_recency_ensemble.py | 228 +++++++++++++++++++++++++++++++++ ui/predictor_ui.py | 9 +- 10 files changed, 674 insertions(+), 29 deletions(-) create mode 100644 build.bat create mode 100644 build.ps1 create mode 100644 tests/test_recency_ensemble.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7dd9eae..3793834 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -22,7 +22,16 @@ "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_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)" ] } } diff --git a/README.md b/README.md index d0319fc..e46b3ab 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,220 @@ -# lottosight +# LottoSight -Lottery prediction app \ No newline at end of file +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. diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..cecf408 --- /dev/null +++ b/build.bat @@ -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 diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..989a9ca --- /dev/null +++ b/build.ps1 @@ -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 "" diff --git a/core/analyzer.py b/core/analyzer.py index 8d191ed..357d55d 100644 --- a/core/analyzer.py +++ b/core/analyzer.py @@ -6,27 +6,41 @@ Each function takes game_id and returns structured Python data (dicts / lists) — no UI concerns here. """ +import math from collections import Counter from itertools import combinations from db.models import get_all_draws_numbers, get_game_by_id -def frequency_analysis(game_id, last_n=None): +def frequency_analysis(game_id, last_n=None, decay: float = 0.0): """ - Count appearances of each main ball. + Count (or weight) appearances of each main ball. last_n: restrict to the most recent N draws (None = all). - Returns {number: count} sorted high → low. + decay: exponential recency weight per draw step (0 = uniform / off). + With decay=0.01 the draw 69 steps back carries ~50% of the + latest draw's weight; draws >300 steps back are near-zero. + Returns {number: count_or_weight} sorted high → low. """ - draws = get_all_draws_numbers(game_id) # ASC order + draws = get_all_draws_numbers(game_id) # ASC order, oldest first if last_n and last_n > 0: draws = draws[-last_n:] - counter = Counter() - for draw in draws: - counter.update(draw["numbers"]) + if not decay: + counter = Counter() + for draw in draws: + counter.update(draw["numbers"]) + return dict(sorted(counter.items(), key=lambda kv: kv[1], reverse=True)) - return dict(sorted(counter.items(), key=lambda kv: kv[1], reverse=True)) + # Decayed path: newest draw (i = n-1) gets weight 1.0; older draws decay + n = len(draws) + freq: dict[int, float] = {} + for i, draw in enumerate(draws): + w = math.exp(-decay * (n - 1 - i)) + for num in draw["numbers"]: + freq[num] = freq.get(num, 0.0) + w + + return dict(sorted(freq.items(), key=lambda kv: kv[1], reverse=True)) def gap_analysis(game_id): diff --git a/core/predictor.py b/core/predictor.py index a4f8112..43c6c46 100644 --- a/core/predictor.py +++ b/core/predictor.py @@ -23,6 +23,9 @@ 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 ────────────────────────────────────────────────────────── @@ -113,7 +116,8 @@ def _fill_to_count(chosen: list, game: dict, exclude: set | None = None) -> list 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. @@ -124,19 +128,14 @@ def hot_numbers(game_id: int, last_n: int = 100, exclude: set | None = None) -> if not draws: return _random_ticket(game, excl) - recent = draws[-last_n:] if last_n and last_n > 0 else draws - - counter = Counter() - for draw in recent: - counter.update(draw["numbers"]) - - ranked = [n for n, _ in sorted(counter.items(), key=lambda kv: (-kv[1], kv[0])) + 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) - sum_range = sum_range_percentiles(game_id) + 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) + numbers = _swap_filter(numbers, ranked_pool, sum_range) bonus = _hot_bonus(draws, game) return {"numbers": numbers, "bonus": bonus} @@ -179,7 +178,7 @@ def weighted_random(game_id: int, exclude: set | None = None) -> dict: """ 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 = _safe_pool(game, excl) weights = np.array([freq.get(n, 1) for n in pool], dtype=float) @@ -207,7 +206,7 @@ def monte_carlo(game_id: int, simulations: int = 10_000, """ 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 = _safe_pool(game, excl) weights = np.array([freq.get(n, 1) for n in pool], dtype=float) @@ -281,7 +280,46 @@ def positional_pick(game_id: int, exclude: set | None = None) -> dict: return {"numbers": numbers, "bonus": bonus} -# ── Strategy 6: Quick Pick ──────────────────────────────────────────────────── +# ── 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: """ diff --git a/data/lottosight.db b/data/lottosight.db index bc9336a9af484e8935755fe5913229baca82e27b..c867edc5c1301c547dfd8a4cf96fdc7ea111b593 100644 GIT binary patch delta 325 zcmZozP}Q)YYJxOl;6xc`#=wmU9rh9tTujX3OesZ)<;C{bIT@M78PgMUQ;Xf39NHWh z+Z>qM9GKf2SlS#|+Z@>19N60&INBUI+Z?#s9Jt#Yc-kCzw>j{!6fnzjvQKB6o zLDraqLz-ba;|>lnuzGW-dc)Ak1|3R*Mut`(9cEBb$p&o- zLJ*b4?9*W;1c6=Ap0JA#i1~q70Eh*FSO|!Pfmj5HMS)lhh{b_e0*EDnSPF=xfmmjH J!Y^I!=o2UuO@ibQI7!2)u_jj^p$H1>IilC; zeDLLEmY3PpVgAkCF#i!=JUftFXudr}$JG0oOlY=`;$6Bv0Rltd2m--Cs3Fu58VF4U z6G0-h5ZVZx7bqhUTCR%5T`itgA|zi1Mf_3ob66?br9EoVJ{`~@9nmq>)TR?Ur4Du9 IVKpnie@4AL%K!iX diff --git a/lottosight.spec b/lottosight.spec index 84f3493..6af2eb4 100644 --- a/lottosight.spec +++ b/lottosight.spec @@ -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', diff --git a/tests/test_recency_ensemble.py b/tests/test_recency_ensemble.py new file mode 100644 index 0000000..da10969 --- /dev/null +++ b/tests/test_recency_ensemble.py @@ -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" diff --git a/ui/predictor_ui.py b/ui/predictor_ui.py index 3220ad0..1b23ad6 100644 --- a/ui/predictor_ui.py +++ b/ui/predictor_ui.py @@ -20,7 +20,8 @@ from db.models import ( delete_prediction, delete_all_predictions, ) from core.predictor import ( - hot_numbers, due_numbers, weighted_random, monte_carlo, positional_pick, quick_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 @@ -30,6 +31,7 @@ from ui.widgets import BallsBar logger = logging.getLogger(__name__) _STRATEGIES = { + "Ensemble": ensemble, "Hot Numbers": hot_numbers, "Due Numbers": due_numbers, "Weighted Random": weighted_random, @@ -39,9 +41,10 @@ _STRATEGIES = { } _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.",