mirror of
https://github.com/mblanke/Lottery-Tracker.git
synced 2026-03-01 14:10:22 -05:00
Compare commits
2 Commits
main
...
6d3475efe9
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d3475efe9 | |||
| fdba869a8d |
@@ -6,6 +6,7 @@ __pycache__/
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
.venv/
|
||||
ENV/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
@@ -13,6 +14,7 @@ ENV/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
35
.env.example
Normal file
35
.env.example
Normal file
@@ -0,0 +1,35 @@
|
||||
# ============================================================
|
||||
# Lottery Tracker — Environment Configuration
|
||||
# ============================================================
|
||||
# Copy this file to .env and adjust values as needed.
|
||||
# cp .env.example .env
|
||||
# ============================================================
|
||||
|
||||
# --- Flask ---
|
||||
FLASK_DEBUG=false
|
||||
FLASK_HOST=0.0.0.0
|
||||
FLASK_PORT=5000
|
||||
|
||||
# CORS: comma-separated origins, or * for all
|
||||
ALLOWED_ORIGINS=*
|
||||
|
||||
# --- Tax rates ---
|
||||
LUMP_SUM_RATE=0.52
|
||||
FEDERAL_TAX_RATE=0.37
|
||||
DEFAULT_STATE_TAX_RATE=0.055
|
||||
USD_CAD_RATE=1.35
|
||||
INVESTMENT_INCOME_TAX_RATE=0.5353
|
||||
PERSONAL_WITHDRAWAL_PCT=0.10
|
||||
|
||||
# --- Investment defaults ---
|
||||
DEFAULT_INVEST_PCT=0.90
|
||||
DEFAULT_ANNUAL_RETURN=0.045
|
||||
DEFAULT_CYCLES=8
|
||||
|
||||
# --- Scraper URLs (override only if sites change) ---
|
||||
# SCRAPER_URL_POWERBALL=https://www.lotto.net/powerball
|
||||
# SCRAPER_URL_MEGA_MILLIONS=https://www.lotto.net/mega-millions
|
||||
# SCRAPER_URL_OLG=https://www.olg.ca/
|
||||
|
||||
# --- Cache TTL (seconds, default 6 hours) ---
|
||||
CACHE_TTL_SECONDS=21600
|
||||
20
.gitignore
vendored
20
.gitignore
vendored
@@ -23,3 +23,23 @@ Thumbs.db
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Data files
|
||||
*.xlsx
|
||||
*.xls
|
||||
*.csv
|
||||
|
||||
# Debug HTML
|
||||
*.html
|
||||
|
||||
# Docker
|
||||
ssl/*.pem
|
||||
ssl/*.key
|
||||
ssl/*.crt
|
||||
|
||||
# Coverage
|
||||
htmlcov/
|
||||
.coverage
|
||||
coverage.xml
|
||||
|
||||
14
AGENTS.md
14
AGENTS.md
@@ -64,13 +64,13 @@ Model: Auto (override to the strongest available for high-stakes diffs).
|
||||
- Prefer deterministic, reproducible steps.
|
||||
- Cite sources when generating documents from a knowledge base.
|
||||
|
||||
## Repo facts (fill these in)
|
||||
- Primary stack:
|
||||
- Package manager:
|
||||
- Test command:
|
||||
- Lint/format command:
|
||||
- Build command (if any):
|
||||
- Deployment (if any):
|
||||
## Repo facts
|
||||
- Primary stack: Python 3.13 (Flask 3.1) + TypeScript (Next.js 15, MUI 6)
|
||||
- Package manager: pip (backend) + npm (frontend)
|
||||
- Test command: `pytest -q`
|
||||
- Lint/format command: `ruff check . --fix`
|
||||
- Build command (if any): `docker compose build`
|
||||
- Deployment (if any): `docker compose -f docker-compose.prod.yml up -d`
|
||||
|
||||
|
||||
## Claude Code Agents (optional)
|
||||
|
||||
@@ -1,255 +1,92 @@
|
||||
# 🐋 Docker Setup Complete!
|
||||
# Docker Quick Start
|
||||
|
||||
## What's Been Created
|
||||
## What's Included
|
||||
|
||||
### Docker Files
|
||||
- ✅ `Dockerfile.backend` - Flask API with Playwright
|
||||
- ✅ `Dockerfile.frontend` - Next.js app optimized for production
|
||||
- ✅ `Dockerfile.email` - Email scheduler service
|
||||
- ✅ `docker-compose.yml` - Development setup
|
||||
- ✅ `docker-compose.prod.yml` - Production setup with nginx
|
||||
- ✅ `.dockerignore` - Optimized build context
|
||||
- ✅ `requirements.txt` - Python dependencies
|
||||
|
||||
### Configuration
|
||||
- ✅ Updated `next.config.ts` for standalone output
|
||||
- ✅ Created startup scripts (Windows & Linux)
|
||||
- ✅ Complete documentation in `DOCKER_README.md`
|
||||
- `Dockerfile.backend` — Flask API with Playwright & Chromium
|
||||
- `Dockerfile.frontend` — Next.js standalone production build
|
||||
- `docker-compose.yml` — Development setup (backend + frontend)
|
||||
- `docker-compose.prod.yml` — Production setup (+ nginx reverse proxy)
|
||||
- `nginx.conf` — Reverse proxy with rate limiting & caching
|
||||
- `.env.example` — All available environment variables
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Windows Script (Easiest)
|
||||
### 1. Configure
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 2. Start
|
||||
```bash
|
||||
# Windows
|
||||
docker-start.bat
|
||||
|
||||
# Linux/macOS
|
||||
./docker-start.sh
|
||||
|
||||
# Or directly
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Option 2: Docker Compose
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Option 3: Manual Build
|
||||
```bash
|
||||
# Build
|
||||
docker-compose build
|
||||
|
||||
# Start
|
||||
docker-compose up -d
|
||||
|
||||
# Check status
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 What Gets Deployed
|
||||
|
||||
### Backend Container
|
||||
- Python 3.13
|
||||
- Flask API on port 5000
|
||||
- Playwright with Chromium browser
|
||||
- Lottery scrapers for all 4 lotteries
|
||||
- Investment calculator
|
||||
- Health check endpoint
|
||||
|
||||
### Frontend Container
|
||||
- Node.js 20
|
||||
- Next.js standalone build
|
||||
- Optimized production bundle
|
||||
- Connects to backend API
|
||||
|
||||
### Email Container (Optional)
|
||||
- Runs daily at 7:00 AM
|
||||
- Sends lottery jackpot emails
|
||||
- Uses same scraping logic
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Access Points
|
||||
|
||||
After running `docker-compose up -d`:
|
||||
|
||||
### 3. Open
|
||||
- **Frontend**: http://localhost:3000
|
||||
- **Backend API**: http://localhost:5000
|
||||
- **Health Check**: http://localhost:5000/api/health
|
||||
|
||||
---
|
||||
|
||||
## 📊 Container Management
|
||||
## What Gets Deployed
|
||||
|
||||
### Backend Container
|
||||
- Python 3.13 + Flask + Gunicorn (2 workers)
|
||||
- Playwright with Chromium (for Canadian lottery scraping)
|
||||
- Unified scraper for Powerball, Mega Millions, Lotto Max, Lotto 6/49
|
||||
- Investment calculator with tax, annuity, group play, break-even
|
||||
- TTL-cached jackpot data (6 hours default)
|
||||
|
||||
### Frontend Container
|
||||
- Node.js 20 + Next.js 15 standalone build
|
||||
- Material-UI dark theme
|
||||
- 6 interactive pages (Calculator, Compare, Break-Even, Annuity, Group Play, Odds)
|
||||
|
||||
### Nginx (Production Only)
|
||||
- Reverse proxy for API and frontend
|
||||
- Rate limiting (10 req/s burst 20)
|
||||
- Static asset caching (30 days)
|
||||
- HTTPS ready (see `ssl/README.md`)
|
||||
|
||||
---
|
||||
|
||||
## Common Commands
|
||||
|
||||
### View Logs
|
||||
```bash
|
||||
# All services
|
||||
docker-compose logs -f
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Specific service
|
||||
docker-compose logs -f backend
|
||||
docker-compose logs -f frontend
|
||||
```
|
||||
# Restart
|
||||
docker compose restart
|
||||
|
||||
### Restart Services
|
||||
```bash
|
||||
docker-compose restart
|
||||
```
|
||||
# Rebuild after code changes
|
||||
docker compose up -d --build
|
||||
|
||||
### Stop Everything
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
# Stop
|
||||
docker compose down
|
||||
|
||||
### Rebuild After Changes
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
# Production
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
## Troubleshooting
|
||||
|
||||
### Port Already in Use
|
||||
If ports 3000 or 5000 are busy:
|
||||
1. **Check Docker Desktop is running**
|
||||
2. **Ensure ports 3000 and 5000 are available**
|
||||
3. **Check logs**: `docker compose logs`
|
||||
4. **Rebuild**: `docker compose up -d --build`
|
||||
5. **Reset**: `docker compose down && docker compose up -d`
|
||||
|
||||
**Option A**: Stop other services
|
||||
```bash
|
||||
# Windows
|
||||
netstat -ano | findstr :3000
|
||||
taskkill /PID <PID> /F
|
||||
```
|
||||
|
||||
**Option B**: Change ports in `docker-compose.yml`
|
||||
```yaml
|
||||
ports:
|
||||
- "8080:3000" # Use port 8080 instead
|
||||
```
|
||||
|
||||
### Backend Won't Start
|
||||
```bash
|
||||
# Check logs
|
||||
docker logs lottery-backend
|
||||
|
||||
# Rebuild without cache
|
||||
docker-compose build --no-cache backend
|
||||
```
|
||||
|
||||
### Frontend Can't Connect
|
||||
Update `docker-compose.yml` frontend environment:
|
||||
```yaml
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://localhost:5000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Include Email Scheduler
|
||||
|
||||
To run the email scheduler:
|
||||
|
||||
```bash
|
||||
docker-compose --profile email up -d
|
||||
```
|
||||
|
||||
Or remove the `profiles` section from `docker-compose.yml` to always include it.
|
||||
|
||||
---
|
||||
|
||||
## 📈 Production Deployment
|
||||
|
||||
### Use Production Compose
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Deploy to Server
|
||||
```bash
|
||||
# On your server
|
||||
git clone <your-repo>
|
||||
cd Lottery
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Push to Docker Hub
|
||||
```bash
|
||||
# Login
|
||||
docker login
|
||||
|
||||
# Tag images
|
||||
docker tag lottery-backend yourusername/lottery-backend:latest
|
||||
docker tag lottery-frontend yourusername/lottery-frontend:latest
|
||||
|
||||
# Push
|
||||
docker push yourusername/lottery-backend:latest
|
||||
docker push yourusername/lottery-frontend:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security for Production
|
||||
|
||||
1. **Use environment variables** - Don't hardcode credentials
|
||||
2. **Enable HTTPS** - Use nginx with SSL certificates
|
||||
3. **Update base images** regularly
|
||||
4. **Scan for vulnerabilities**:
|
||||
```bash
|
||||
docker scan lottery-backend
|
||||
```
|
||||
5. **Use Docker secrets** for sensitive data
|
||||
|
||||
---
|
||||
|
||||
## 💾 Data Persistence
|
||||
|
||||
Currently, containers are stateless. To add persistence:
|
||||
|
||||
Add volumes in `docker-compose.yml`:
|
||||
```yaml
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎛️ Resource Limits
|
||||
|
||||
Current limits (production):
|
||||
- Backend: 2GB RAM, 1 CPU
|
||||
- Frontend: 512MB RAM, 0.5 CPU
|
||||
- Email: 1GB RAM, 0.5 CPU
|
||||
|
||||
Adjust in `docker-compose.prod.yml` if needed.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Benefits of Docker
|
||||
|
||||
1. ✅ **Consistent environment** - Works the same everywhere
|
||||
2. ✅ **Easy deployment** - One command to start everything
|
||||
3. ✅ **Isolation** - Each service in its own container
|
||||
4. ✅ **Scalability** - Easy to scale services
|
||||
5. ✅ **Version control** - Docker images are versioned
|
||||
6. ✅ **Portability** - Deploy anywhere Docker runs
|
||||
|
||||
---
|
||||
|
||||
## 📝 Next Steps
|
||||
|
||||
1. ✅ Test locally: `docker-compose up -d`
|
||||
2. ✅ Check logs: `docker-compose logs -f`
|
||||
3. ✅ Access app: http://localhost:3000
|
||||
4. ✅ Configure email scheduler if needed
|
||||
5. ✅ Deploy to production server
|
||||
6. ✅ Set up CI/CD pipeline (optional)
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Need Help?
|
||||
|
||||
See detailed documentation in:
|
||||
- `DOCKER_README.md` - Full Docker guide
|
||||
- `EMAIL_SETUP.md` - Email configuration
|
||||
- Docker logs: `docker-compose logs -f`
|
||||
|
||||
---
|
||||
|
||||
Enjoy your Dockerized Lottery Investment Calculator! 🎰🐋
|
||||
See `DOCKER_README.md` for detailed troubleshooting.
|
||||
|
||||
348
DOCKER_README.md
348
DOCKER_README.md
@@ -1,54 +1,56 @@
|
||||
# Lottery Investment Calculator - Docker Setup
|
||||
|
||||
## 🐋 Docker Deployment Guide
|
||||
|
||||
### Prerequisites
|
||||
## Prerequisites
|
||||
- Docker Desktop installed (https://www.docker.com/products/docker-desktop)
|
||||
- Docker Compose (included with Docker Desktop)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
## Quick Start
|
||||
|
||||
### 1. Build and Run Everything
|
||||
### 1. Configure Environment
|
||||
```bash
|
||||
docker-compose up -d
|
||||
cp .env.example .env
|
||||
# Edit .env with your preferred settings
|
||||
```
|
||||
|
||||
This will start:
|
||||
### 2. Build and Run
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This starts:
|
||||
- **Backend API** on http://localhost:5000
|
||||
- **Frontend Web App** on http://localhost:3000
|
||||
|
||||
### 2. Check Status
|
||||
### 3. Check Status
|
||||
```bash
|
||||
docker-compose ps
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### 3. View Logs
|
||||
### 4. View Logs
|
||||
```bash
|
||||
# All services
|
||||
docker-compose logs -f
|
||||
docker compose logs -f
|
||||
|
||||
# Just backend
|
||||
docker-compose logs -f backend
|
||||
|
||||
# Just frontend
|
||||
docker-compose logs -f frontend
|
||||
# Specific service
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend
|
||||
```
|
||||
|
||||
### 4. Stop Everything
|
||||
### 5. Stop
|
||||
```bash
|
||||
docker-compose down
|
||||
docker compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Individual Services
|
||||
## Individual Services
|
||||
|
||||
### Backend Only
|
||||
```bash
|
||||
docker build -f Dockerfile.backend -t lottery-backend .
|
||||
docker run -p 5000:5000 lottery-backend
|
||||
docker run -p 5000:5000 --env-file .env lottery-backend
|
||||
```
|
||||
|
||||
### Frontend Only
|
||||
@@ -57,291 +59,139 @@ docker build -f Dockerfile.frontend -t lottery-frontend .
|
||||
docker run -p 3000:3000 lottery-frontend
|
||||
```
|
||||
|
||||
### Email Scheduler (Optional)
|
||||
```bash
|
||||
docker-compose --profile email up -d
|
||||
```
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is done via environment variables. See `.env.example` for available options.
|
||||
|
||||
Key variables:
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `FLASK_DEBUG` | `false` | Enable Flask debug mode |
|
||||
| `FLASK_CORS_ORIGINS` | `*` | Allowed CORS origins |
|
||||
| `FEDERAL_TAX_RATE` | `0.37` | US federal tax rate |
|
||||
| `DEFAULT_STATE_TAX_RATE` | `0.055` | Default state tax rate |
|
||||
| `USD_TO_CAD` | `1.44` | USD→CAD exchange rate |
|
||||
| `CACHE_TTL_HOURS` | `6` | Jackpot cache duration |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuration
|
||||
## Network
|
||||
|
||||
### Update Next.js to use standalone output
|
||||
|
||||
Add to `frontend/next.config.ts`:
|
||||
```typescript
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
};
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create `.env` file:
|
||||
```bash
|
||||
# Backend
|
||||
FLASK_ENV=production
|
||||
|
||||
# Frontend
|
||||
NEXT_PUBLIC_API_URL=http://localhost:5000
|
||||
|
||||
# Email (optional)
|
||||
EMAIL_SENDER=mblanke@gmail.com
|
||||
EMAIL_RECIPIENT=mblanke@gmail.com
|
||||
EMAIL_PASSWORD=vyapvyjjfrqpqnax
|
||||
```
|
||||
|
||||
Then update `docker-compose.yml` to use env_file:
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
env_file: .env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Build Process
|
||||
|
||||
### First Time Setup
|
||||
```bash
|
||||
# Build all images
|
||||
docker-compose build
|
||||
|
||||
# Or build individually
|
||||
docker-compose build backend
|
||||
docker-compose build frontend
|
||||
docker-compose build email-scheduler
|
||||
```
|
||||
|
||||
### Rebuild After Code Changes
|
||||
```bash
|
||||
# Rebuild and restart
|
||||
docker-compose up -d --build
|
||||
|
||||
# Rebuild specific service
|
||||
docker-compose up -d --build backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Network Configuration
|
||||
|
||||
All services communicate via the `lottery-network` bridge network.
|
||||
|
||||
### Internal URLs (container to container):
|
||||
### Internal URLs (container to container)
|
||||
- Backend: `http://backend:5000`
|
||||
- Frontend: `http://frontend:3000`
|
||||
|
||||
### External URLs (host to container):
|
||||
### External URLs (host machine)
|
||||
- Backend: `http://localhost:5000`
|
||||
- Frontend: `http://localhost:3000`
|
||||
|
||||
---
|
||||
|
||||
## 📊 Health Checks
|
||||
## Health Checks
|
||||
|
||||
The backend includes a health check endpoint:
|
||||
```bash
|
||||
curl http://localhost:5000/api/health
|
||||
```
|
||||
|
||||
Check in Docker:
|
||||
```bash
|
||||
docker inspect lottery-backend | grep -A 10 Health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Production Deployment
|
||||
## Production Deployment
|
||||
|
||||
### Docker Hub
|
||||
### Using Production Compose
|
||||
```bash
|
||||
# Tag images
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
This adds nginx reverse proxy with:
|
||||
- Rate limiting (10 req/s)
|
||||
- Static asset caching
|
||||
- HTTPS support (configure certs in `ssl/`)
|
||||
- Resource limits per container
|
||||
|
||||
See `ssl/README.md` for certificate setup.
|
||||
|
||||
### Deploy to Server
|
||||
```bash
|
||||
git clone <your-repo>
|
||||
cd Lottery-Tracker
|
||||
cp .env.example .env
|
||||
# Edit .env for production
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Push to Docker Hub
|
||||
```bash
|
||||
docker login
|
||||
docker tag lottery-backend yourusername/lottery-backend:latest
|
||||
docker tag lottery-frontend yourusername/lottery-frontend:latest
|
||||
|
||||
# Push to Docker Hub
|
||||
docker push yourusername/lottery-backend:latest
|
||||
docker push yourusername/lottery-frontend:latest
|
||||
```
|
||||
|
||||
### Deploy to Server
|
||||
```bash
|
||||
# Pull images on server
|
||||
docker pull yourusername/lottery-backend:latest
|
||||
docker pull yourusername/lottery-frontend:latest
|
||||
|
||||
# Run with compose
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
## Troubleshooting
|
||||
|
||||
### Backend won't start
|
||||
### Port Already in Use
|
||||
```bash
|
||||
# Check logs
|
||||
docker logs lottery-backend
|
||||
# Windows
|
||||
netstat -ano | findstr :5000
|
||||
taskkill /PID <PID> /F
|
||||
|
||||
# Common issues:
|
||||
# - Port 5000 already in use
|
||||
# - Playwright installation failed
|
||||
# - Missing dependencies
|
||||
# Or change ports in docker-compose.yml
|
||||
```
|
||||
|
||||
### Frontend can't connect to backend
|
||||
### Backend Won't Start
|
||||
```bash
|
||||
# Check if backend is running
|
||||
docker-compose ps
|
||||
docker logs lottery-backend
|
||||
docker compose build --no-cache backend
|
||||
```
|
||||
|
||||
# Test backend directly
|
||||
### Frontend Can't Connect to Backend
|
||||
```bash
|
||||
docker compose ps
|
||||
curl http://localhost:5000/api/health
|
||||
|
||||
# Check frontend environment
|
||||
docker exec lottery-frontend env | grep API_URL
|
||||
```
|
||||
|
||||
### Playwright browser issues
|
||||
### Playwright Browser Issues
|
||||
```bash
|
||||
# Rebuild with no cache
|
||||
docker-compose build --no-cache backend
|
||||
|
||||
# Check Playwright installation
|
||||
docker compose build --no-cache backend
|
||||
docker exec lottery-backend playwright --version
|
||||
```
|
||||
|
||||
### Container keeps restarting
|
||||
### Access Container Shell
|
||||
```bash
|
||||
# View logs
|
||||
docker logs lottery-backend --tail 100
|
||||
docker exec -it lottery-backend /bin/bash
|
||||
docker exec -it lottery-frontend /bin/sh
|
||||
```
|
||||
|
||||
# Check health status
|
||||
docker inspect lottery-backend | grep -A 5 Health
|
||||
### Clean Everything
|
||||
```bash
|
||||
docker compose down -v --rmi all
|
||||
docker system prune -a
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Useful Commands
|
||||
## Resource Usage
|
||||
|
||||
### Access Container Shell
|
||||
```bash
|
||||
# Backend
|
||||
docker exec -it lottery-backend /bin/bash
|
||||
Production limits (set in `docker-compose.prod.yml`):
|
||||
- Backend: 2 GB RAM, 1 CPU
|
||||
- Frontend: 512 MB RAM, 0.5 CPU
|
||||
- Nginx: 256 MB RAM, 0.25 CPU
|
||||
|
||||
# Frontend
|
||||
docker exec -it lottery-frontend /bin/sh
|
||||
```
|
||||
|
||||
### Remove Everything
|
||||
```bash
|
||||
# Stop and remove containers, networks
|
||||
docker-compose down
|
||||
|
||||
# Also remove volumes
|
||||
docker-compose down -v
|
||||
|
||||
# Remove images
|
||||
docker-compose down --rmi all
|
||||
```
|
||||
|
||||
### Prune Unused Resources
|
||||
```bash
|
||||
docker system prune -a
|
||||
```
|
||||
|
||||
### View Resource Usage
|
||||
### Monitor
|
||||
```bash
|
||||
docker stats
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚢 Alternative: Docker without Compose
|
||||
## Image Sizes (Approximate)
|
||||
|
||||
### Create Network
|
||||
```bash
|
||||
docker network create lottery-network
|
||||
```
|
||||
|
||||
### Run Backend
|
||||
```bash
|
||||
docker run -d \
|
||||
--name lottery-backend \
|
||||
--network lottery-network \
|
||||
-p 5000:5000 \
|
||||
lottery-backend
|
||||
```
|
||||
|
||||
### Run Frontend
|
||||
```bash
|
||||
docker run -d \
|
||||
--name lottery-frontend \
|
||||
--network lottery-network \
|
||||
-p 3000:3000 \
|
||||
-e NEXT_PUBLIC_API_URL=http://localhost:5000 \
|
||||
lottery-frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Email Scheduler with Docker
|
||||
|
||||
To include the email scheduler:
|
||||
|
||||
1. **Start with email service:**
|
||||
```bash
|
||||
docker-compose --profile email up -d
|
||||
```
|
||||
|
||||
2. **Or add to default profile** (edit docker-compose.yml):
|
||||
Remove `profiles: - email` from email-scheduler service
|
||||
|
||||
3. **Check email logs:**
|
||||
```bash
|
||||
docker logs lottery-email -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security Notes
|
||||
|
||||
⚠️ **Important:**
|
||||
- Never commit `.env` files with real credentials
|
||||
- Use Docker secrets in production
|
||||
- Set proper firewall rules
|
||||
- Use HTTPS in production
|
||||
- Regularly update base images
|
||||
|
||||
---
|
||||
|
||||
## 📈 Scaling
|
||||
|
||||
### Run multiple backend instances
|
||||
```bash
|
||||
docker-compose up -d --scale backend=3
|
||||
```
|
||||
|
||||
### Add load balancer (nginx)
|
||||
See `docker-compose.prod.yml` for nginx configuration
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Support
|
||||
|
||||
If containers won't start:
|
||||
1. Check Docker Desktop is running
|
||||
2. Ensure ports 3000 and 5000 are available
|
||||
3. Check logs: `docker-compose logs`
|
||||
4. Rebuild: `docker-compose up -d --build`
|
||||
5. Reset: `docker-compose down && docker-compose up -d`
|
||||
|
||||
---
|
||||
|
||||
## 📦 Image Sizes (Approximate)
|
||||
|
||||
- Backend: ~1.5 GB (includes Chromium browser)
|
||||
- Frontend: ~200 MB
|
||||
- Email Scheduler: ~1.5 GB (includes Chromium browser)
|
||||
|
||||
To reduce size, consider multi-stage builds or Alpine Linux variants.
|
||||
- Backend: ~1.5 GB (includes Chromium for Playwright)
|
||||
- Frontend: ~200 MB (Next.js standalone)
|
||||
- Nginx: ~30 MB
|
||||
|
||||
@@ -36,14 +36,14 @@ COPY requirements.txt .
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Install Playwright browsers
|
||||
# Install Playwright Chromium (deps already installed above)
|
||||
RUN playwright install chromium
|
||||
RUN playwright install-deps chromium
|
||||
|
||||
# Copy application files
|
||||
COPY app.py .
|
||||
COPY lottery_calculator.py .
|
||||
COPY ["import requests.py", "."]
|
||||
COPY config.py .
|
||||
COPY scrapers.py .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5000
|
||||
@@ -51,7 +51,8 @@ EXPOSE 5000
|
||||
# Set environment variables
|
||||
ENV FLASK_APP=app.py
|
||||
ENV FLASK_ENV=production
|
||||
ENV FLASK_DEBUG=false
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "app.py"]
|
||||
# Run with gunicorn for production
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "120", "app:app"]
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# Email Scheduler Dockerfile
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies for Playwright
|
||||
RUN apt-get update && apt-get install -y \
|
||||
wget \
|
||||
gnupg \
|
||||
ca-certificates \
|
||||
fonts-liberation \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
libatspi2.0-0 \
|
||||
libcups2 \
|
||||
libdbus-1-3 \
|
||||
libdrm2 \
|
||||
libgbm1 \
|
||||
libgtk-3-0 \
|
||||
libnspr4 \
|
||||
libnss3 \
|
||||
libwayland-client0 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxkbcommon0 \
|
||||
libxrandr2 \
|
||||
xdg-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Install Playwright
|
||||
RUN playwright install chromium
|
||||
RUN playwright install-deps chromium
|
||||
|
||||
# Copy email sender script
|
||||
COPY email_sender.py .
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
CMD ["python", "email_sender.py"]
|
||||
208
EMAIL_SETUP.md
208
EMAIL_SETUP.md
@@ -1,208 +0,0 @@
|
||||
# Email Configuration for Lottery Jackpot Alerts
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Install Required Package
|
||||
```bash
|
||||
pip install schedule
|
||||
```
|
||||
|
||||
### 2. Configure Email Settings
|
||||
|
||||
Edit `email_sender.py` and update the `EMAIL_CONFIG` section:
|
||||
|
||||
```python
|
||||
EMAIL_CONFIG = {
|
||||
'smtp_server': 'smtp.gmail.com', # Your email provider's SMTP server
|
||||
'smtp_port': 587,
|
||||
'sender_email': 'your-email@gmail.com', # Your email address
|
||||
'sender_password': 'your-app-password', # Your app-specific password
|
||||
'recipient_email': 'recipient@example.com', # Where to send the report
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Email Provider Settings
|
||||
|
||||
#### For Gmail:
|
||||
1. **Enable 2-Factor Authentication** on your Google account
|
||||
2. **Generate App Password**:
|
||||
- Go to: https://myaccount.google.com/apppasswords
|
||||
- Select "Mail" and "Windows Computer"
|
||||
- Copy the 16-character password
|
||||
- Use this password in `sender_password` (NOT your regular Gmail password)
|
||||
3. SMTP Settings:
|
||||
- Server: `smtp.gmail.com`
|
||||
- Port: `587`
|
||||
|
||||
#### For Outlook/Hotmail:
|
||||
- Server: `smtp-mail.outlook.com`
|
||||
- Port: `587`
|
||||
- Use your regular email and password
|
||||
|
||||
#### For Yahoo:
|
||||
- Server: `smtp.mail.yahoo.com`
|
||||
- Port: `587`
|
||||
- Generate app password at: https://login.yahoo.com/account/security
|
||||
|
||||
#### For Other Providers:
|
||||
Search for "[Your Provider] SMTP settings" to find the correct server and port.
|
||||
|
||||
### 4. Test the Email
|
||||
|
||||
Uncomment this line in the `main()` function to send a test email immediately:
|
||||
```python
|
||||
send_daily_jackpots()
|
||||
```
|
||||
|
||||
Then run:
|
||||
```bash
|
||||
python email_sender.py
|
||||
```
|
||||
|
||||
### 5. Schedule Daily Emails
|
||||
|
||||
The script is configured to send emails at **7:00 AM** every day.
|
||||
|
||||
To run it continuously:
|
||||
```bash
|
||||
python email_sender.py
|
||||
```
|
||||
|
||||
Keep the terminal window open. The script will:
|
||||
- Wait until 7:00 AM
|
||||
- Fetch current jackpots
|
||||
- Send formatted email
|
||||
- Repeat daily
|
||||
|
||||
### 6. Run as Background Service (Optional)
|
||||
|
||||
#### Windows - Task Scheduler:
|
||||
1. Open Task Scheduler
|
||||
2. Create Basic Task
|
||||
3. Name: "Lottery Jackpot Email"
|
||||
4. Trigger: Daily at 7:00 AM
|
||||
5. Action: Start a program
|
||||
- Program: `python`
|
||||
- Arguments: `d:\Projects\Dev\Lottery\email_sender.py`
|
||||
6. Finish
|
||||
|
||||
#### Windows - NSSM (Non-Sucking Service Manager):
|
||||
```bash
|
||||
# Install NSSM
|
||||
choco install nssm
|
||||
|
||||
# Create service
|
||||
nssm install LotteryEmail python d:\Projects\Dev\Lottery\email_sender.py
|
||||
|
||||
# Start service
|
||||
nssm start LotteryEmail
|
||||
```
|
||||
|
||||
#### Linux - Cron Job:
|
||||
```bash
|
||||
# Edit crontab
|
||||
crontab -e
|
||||
|
||||
# Add this line (runs at 7:00 AM daily)
|
||||
0 7 * * * /usr/bin/python3 /path/to/email_sender.py
|
||||
```
|
||||
|
||||
#### Linux - systemd service:
|
||||
Create `/etc/systemd/system/lottery-email.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Lottery Jackpot Email Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=yourusername
|
||||
WorkingDirectory=/path/to/Lottery
|
||||
ExecStart=/usr/bin/python3 /path/to/email_sender.py
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Then:
|
||||
```bash
|
||||
sudo systemctl enable lottery-email
|
||||
sudo systemctl start lottery-email
|
||||
```
|
||||
|
||||
## Email Features
|
||||
|
||||
The automated email includes:
|
||||
- 🎰 **Powerball** jackpot (US)
|
||||
- 🎰 **Mega Millions** jackpot (US)
|
||||
- 🎰 **Lotto Max** jackpot (Canada - TAX FREE!)
|
||||
- 🎰 **Lotto 6/49** jackpot (Canada - TAX FREE!)
|
||||
- 📅 **Timestamp** of when data was fetched
|
||||
- 💡 **Reminder** about Canadian tax-free winnings
|
||||
- 🎨 **Beautiful HTML formatting** with colors and styling
|
||||
|
||||
## Customization
|
||||
|
||||
### Change Send Time:
|
||||
Edit this line in `email_sender.py`:
|
||||
```python
|
||||
schedule.every().day.at("07:00").do(send_daily_jackpots)
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `"09:30"` - 9:30 AM
|
||||
- `"18:00"` - 6:00 PM
|
||||
- `"00:00"` - Midnight
|
||||
|
||||
### Send to Multiple Recipients:
|
||||
Change the `send_email()` function:
|
||||
```python
|
||||
msg['To'] = "email1@example.com, email2@example.com, email3@example.com"
|
||||
```
|
||||
|
||||
### Send Multiple Times Per Day:
|
||||
Add multiple schedule lines:
|
||||
```python
|
||||
schedule.every().day.at("07:00").do(send_daily_jackpots)
|
||||
schedule.every().day.at("19:00").do(send_daily_jackpots)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Authentication failed":
|
||||
- Make sure you're using an **app password**, not your regular password (for Gmail)
|
||||
- Check that 2FA is enabled on your account
|
||||
- Verify SMTP server and port are correct
|
||||
|
||||
### "Connection refused":
|
||||
- Check your firewall settings
|
||||
- Verify SMTP port is correct (usually 587 or 465)
|
||||
- Try port 465 with `SMTP_SSL` instead of `SMTP` with `starttls()`
|
||||
|
||||
### Script stops running:
|
||||
- Check if your computer went to sleep
|
||||
- Use Task Scheduler or systemd to auto-restart
|
||||
- Check logs for error messages
|
||||
|
||||
### Jackpots not updating:
|
||||
- Websites may have changed their HTML structure
|
||||
- Check if Playwright browser is installed: `playwright install chromium`
|
||||
- Test the scraper functions individually
|
||||
|
||||
## Security Notes
|
||||
|
||||
⚠️ **IMPORTANT**:
|
||||
- Never commit `email_sender.py` with your real credentials to Git
|
||||
- Use environment variables for sensitive data in production
|
||||
- Keep your app password secure
|
||||
- Don't share your app password with anyone
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues:
|
||||
1. Run the test email first to verify configuration
|
||||
2. Check error messages in the console
|
||||
3. Verify internet connection
|
||||
4. Confirm email provider settings
|
||||
5. Test scraping functions individually
|
||||
Submodule Lottery-Tracker deleted from 68c6a20624
@@ -1,50 +0,0 @@
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.load_workbook('Max.xlsx', data_only=True)
|
||||
ws = wb.active
|
||||
|
||||
print("LOTTERY INVESTMENT CALCULATOR ANALYSIS")
|
||||
print("="*70)
|
||||
print("\nINPUTS:")
|
||||
print("-"*70)
|
||||
print(f"Lottery Amount: ${ws['D3'].value:,.0f}")
|
||||
print(f"Cash Sum (52%): ${ws['D4'].value:,.2f}")
|
||||
print(f"Federal Taxes (37%): ${ws['D5'].value:,.2f}")
|
||||
print(f"State Taxes (5.5%): ${ws['D6'].value:,.2f}")
|
||||
print(f"Net Amount: ${ws['D7'].value:,.2f}")
|
||||
print(f"Canadian Conversion (1.35x): ${ws['D8'].value:,.2f}")
|
||||
print(f"\nInvest 90%: ${ws['D10'].value:,.2f}")
|
||||
print(f"Fun Money (10%): ${ws['G7'].value:,.2f}")
|
||||
print(f"Net Daily Income: ${ws['G8'].value:,.2f}")
|
||||
|
||||
print("\n\nINVESTMENT CYCLES (90-day periods at 4.5% annual return):")
|
||||
print("-"*70)
|
||||
print(f"{'Cycle':<10} {'Principal Start':<18} {'Interest':<15} {'Taxes':<15} {'Withdrawal':<15} {'Total Out':<15} {'Reinvest':<15} {'Principal End':<18}")
|
||||
print("-"*70)
|
||||
|
||||
for row in range(13, 21): # Cycles 1-8
|
||||
cycle = ws[f'C{row}'].value
|
||||
principal_start = ws[f'D{row}'].value
|
||||
interest = ws[f'E{row}'].value
|
||||
taxes = ws[f'F{row}'].value
|
||||
withdrawal = ws[f'G{row}'].value
|
||||
total_out = ws[f'H{row}'].value
|
||||
reinvest = ws[f'I{row}'].value
|
||||
principal_end = ws[f'J{row}'].value
|
||||
|
||||
print(f"{cycle:<10} ${principal_start:>15,.0f} ${interest:>13,.0f} ${taxes:>13,.0f} ${withdrawal:>13,.0f} ${total_out:>13,.0f} ${reinvest:>13,.0f} ${principal_end:>15,.0f}")
|
||||
|
||||
print("\n\nKEY FORMULAS:")
|
||||
print("-"*70)
|
||||
print("• Interest per cycle: Principal × 4.5% × (90/365)")
|
||||
print("• Taxes on interest: Interest × 53.53%")
|
||||
print("• Personal withdrawal: Interest × 10%")
|
||||
print("• Total withdrawal: Taxes + Personal withdrawal")
|
||||
print("• Reinvestment: Interest - Total withdrawal")
|
||||
print("• Next cycle principal: Previous principal + Reinvestment")
|
||||
|
||||
total_withdrawn = ws['G7'].value
|
||||
print(f"\n\nTOTAL PERSONAL WITHDRAWALS (8 cycles): ${total_withdrawn:,.2f}")
|
||||
print(f"Average per cycle: ${total_withdrawn/8:,.2f}")
|
||||
print(f"Daily income: ${ws['G8'].value:,.2f}")
|
||||
print(f"Annual income: ${ws['G8'].value * 365:,.2f}")
|
||||
571
app.py
571
app.py
@@ -1,188 +1,443 @@
|
||||
"""
|
||||
Flask Backend for Lottery Investment Calculator
|
||||
Provides API endpoints for jackpots and investment calculations
|
||||
Flask Backend for Lottery Investment Calculator.
|
||||
|
||||
API endpoints for jackpots, investment calculations, comparisons,
|
||||
break-even analysis, annuity projections, and state tax information.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
from flask_cors import CORS
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
import json
|
||||
import urllib3
|
||||
from playwright.sync_api import sync_playwright
|
||||
import re
|
||||
from lottery_calculator import calculate_us_lottery, calculate_canadian_lottery
|
||||
|
||||
# Suppress SSL warnings
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
from config import (
|
||||
ANNUITY_ANNUAL_INCREASE,
|
||||
ANNUITY_YEARS,
|
||||
LOTTERY_ODDS,
|
||||
STATE_TAX_RATES,
|
||||
load_config,
|
||||
)
|
||||
from lottery_calculator import (
|
||||
calculate_annuity,
|
||||
calculate_break_even,
|
||||
calculate_canadian_lottery,
|
||||
calculate_group_split,
|
||||
calculate_us_lottery,
|
||||
)
|
||||
from scrapers import clear_cache, get_all_jackpots
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging
|
||||
# ---------------------------------------------------------------------------
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
"""Application factory — creates and configures the Flask app."""
|
||||
cfg = load_config()
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app) # Enable CORS for Next.js frontend
|
||||
|
||||
# Common headers to mimic a browser request
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Accept-Encoding": "gzip, deflate",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Cache-Control": "max-age=0",
|
||||
}
|
||||
|
||||
|
||||
def get_us_lotteries():
|
||||
"""Fetch Powerball and Mega Millions jackpots from official sources"""
|
||||
results = {"Powerball": None, "Mega Millions": None}
|
||||
|
||||
# Powerball — scrape powerball.com (static HTML)
|
||||
try:
|
||||
resp = requests.get("https://www.powerball.com/", timeout=15, headers=HEADERS)
|
||||
resp.raise_for_status()
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
all_text = soup.get_text()
|
||||
lines = all_text.split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if 'Estimated Jackpot' in line.strip():
|
||||
# Dollar amount may be a few lines below (skip blanks)
|
||||
for j in range(i + 1, min(i + 5, len(lines))):
|
||||
next_line = lines[j].strip()
|
||||
if not next_line:
|
||||
continue
|
||||
if '$' in next_line:
|
||||
match = re.search(
|
||||
r'\$([\d,.]+)\s*(Billion|Million)',
|
||||
next_line,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if match:
|
||||
value = float(match.group(1).replace(',', ''))
|
||||
unit = match.group(2).lower()
|
||||
if unit == 'billion':
|
||||
results["Powerball"] = value * 1_000_000_000
|
||||
# CORS — restrict origins via env or allow all in dev
|
||||
if cfg.allowed_origins == "*":
|
||||
CORS(app)
|
||||
else:
|
||||
results["Powerball"] = value * 1_000_000
|
||||
break
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Error fetching Powerball: {e}")
|
||||
CORS(app, origins=[o.strip() for o in cfg.allowed_origins.split(",")])
|
||||
|
||||
# Mega Millions — official JSON API
|
||||
# ------------------------------------------------------------------
|
||||
# Validation helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _require_json() -> dict | None:
|
||||
"""Parse JSON body or return None."""
|
||||
return request.get_json(silent=True)
|
||||
|
||||
def _validate_number(
|
||||
value, name: str, *, minimum: float = 0, maximum: float | None = None
|
||||
) -> float | None:
|
||||
"""Coerce *value* to float and validate range. Returns None on bad input."""
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://www.megamillions.com/cmspages/utilservice.asmx/GetLatestDrawData",
|
||||
timeout=15,
|
||||
headers=HEADERS,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
# Response is XML-wrapped JSON
|
||||
match = re.search(r'\{.*\}', resp.text, re.DOTALL)
|
||||
if match:
|
||||
data = json.loads(match.group())
|
||||
jackpot = data.get("Jackpot", {})
|
||||
next_pool = jackpot.get("NextPrizePool")
|
||||
if next_pool is not None:
|
||||
results["Mega Millions"] = float(next_pool)
|
||||
else:
|
||||
current = jackpot.get("CurrentPrizePool")
|
||||
if current is not None:
|
||||
results["Mega Millions"] = float(current)
|
||||
except Exception as e:
|
||||
print(f"Error fetching Mega Millions: {e}")
|
||||
v = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if v < minimum:
|
||||
return None
|
||||
if maximum is not None and v > maximum:
|
||||
return None
|
||||
return v
|
||||
|
||||
return results
|
||||
# ------------------------------------------------------------------
|
||||
# Jackpot endpoints
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_canadian_lotteries():
|
||||
"""Fetch Lotto Max and Lotto 6/49 jackpots using Playwright"""
|
||||
results = {"Lotto Max": None, "Lotto 6/49": None}
|
||||
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
page.goto("https://www.olg.ca/", wait_until="networkidle", timeout=30000)
|
||||
page.wait_for_timeout(3000)
|
||||
content = page.content()
|
||||
browser.close()
|
||||
|
||||
# Lotto Max
|
||||
lotto_max_match = re.search(r'LOTTO\s*MAX(?:(?!LOTTO\s*6/49).)*?\$\s*([\d.,]+)\s*Million', content, re.IGNORECASE | re.DOTALL)
|
||||
if lotto_max_match:
|
||||
value = float(lotto_max_match.group(1).replace(',', ''))
|
||||
results["Lotto Max"] = value * 1_000_000
|
||||
|
||||
# Lotto 6/49
|
||||
lotto_649_match = re.search(r'LOTTO\s*6/49(?:(?!LOTTO\s*MAX).)*?\$\s*([\d.,]+)\s*Million', content, re.IGNORECASE | re.DOTALL)
|
||||
if lotto_649_match:
|
||||
value = float(lotto_649_match.group(1).replace(',', ''))
|
||||
results["Lotto 6/49"] = value * 1_000_000
|
||||
except Exception as e:
|
||||
print(f"Error fetching Canadian lotteries: {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@app.route('/api/jackpots', methods=['GET'])
|
||||
@app.route("/api/jackpots", methods=["GET"])
|
||||
def get_jackpots():
|
||||
"""API endpoint to get all lottery jackpots"""
|
||||
us_lotteries = get_us_lotteries()
|
||||
canadian_lotteries = get_canadian_lotteries()
|
||||
"""Return current jackpots for all four lotteries (cached)."""
|
||||
force = request.args.get("refresh", "").lower() in ("1", "true")
|
||||
data = get_all_jackpots(force_refresh=force)
|
||||
return jsonify(data)
|
||||
|
||||
return jsonify({
|
||||
"us": {
|
||||
"powerball": us_lotteries["Powerball"],
|
||||
"megaMillions": us_lotteries["Mega Millions"]
|
||||
},
|
||||
"canadian": {
|
||||
"lottoMax": canadian_lotteries["Lotto Max"],
|
||||
"lotto649": canadian_lotteries["Lotto 6/49"]
|
||||
}
|
||||
})
|
||||
@app.route("/api/jackpots/refresh", methods=["POST"])
|
||||
def refresh_jackpots():
|
||||
"""Force-refresh the jackpot cache and return new values."""
|
||||
clear_cache()
|
||||
data = get_all_jackpots(force_refresh=True)
|
||||
return jsonify(data)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Calculator endpoints
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.route('/api/calculate', methods=['POST'])
|
||||
@app.route("/api/calculate", methods=["POST"])
|
||||
def calculate():
|
||||
"""API endpoint to calculate investment returns"""
|
||||
data = request.json
|
||||
"""Calculate investment returns for a given jackpot."""
|
||||
data = _require_json()
|
||||
if data is None:
|
||||
return jsonify({"error": "Request body must be JSON"}), 400
|
||||
|
||||
jackpot = data.get('jackpot')
|
||||
lottery_type = data.get('type', 'us') # 'us' or 'canadian'
|
||||
invest_percentage = data.get('investPercentage', 0.90)
|
||||
annual_return = data.get('annualReturn', 0.045)
|
||||
cycles = data.get('cycles', 8)
|
||||
jackpot = _validate_number(data.get("jackpot"), "jackpot", minimum=1)
|
||||
if jackpot is None:
|
||||
return jsonify({"error": "jackpot must be a positive number"}), 400
|
||||
|
||||
if not jackpot:
|
||||
return jsonify({"error": "Jackpot amount is required"}), 400
|
||||
lottery_type = data.get("type", "us")
|
||||
if lottery_type not in ("us", "canadian"):
|
||||
return jsonify({"error": "type must be 'us' or 'canadian'"}), 400
|
||||
|
||||
invest_pct = _validate_number(
|
||||
data.get("investPercentage", cfg.investment.invest_percentage),
|
||||
"investPercentage",
|
||||
minimum=0,
|
||||
maximum=1,
|
||||
)
|
||||
annual_return = _validate_number(
|
||||
data.get("annualReturn", cfg.investment.annual_return),
|
||||
"annualReturn",
|
||||
minimum=0,
|
||||
maximum=1,
|
||||
)
|
||||
cycles = _validate_number(
|
||||
data.get("cycles", cfg.investment.cycles),
|
||||
"cycles",
|
||||
minimum=1,
|
||||
maximum=100,
|
||||
)
|
||||
|
||||
# State tax for US calculations
|
||||
state_code = data.get("state")
|
||||
state_tax = cfg.tax.default_state_tax_rate
|
||||
if lottery_type == "us" and state_code:
|
||||
state_info = STATE_TAX_RATES.get(state_code.upper())
|
||||
if state_info:
|
||||
state_tax = state_info["rate"]
|
||||
|
||||
if invest_pct is None or annual_return is None or cycles is None:
|
||||
return jsonify({"error": "Invalid parameter values"}), 400
|
||||
|
||||
try:
|
||||
if lottery_type == 'us':
|
||||
result = calculate_us_lottery(jackpot, invest_percentage, annual_return, cycles)
|
||||
if lottery_type == "us":
|
||||
result = calculate_us_lottery(
|
||||
jackpot,
|
||||
invest_percentage=invest_pct,
|
||||
annual_return=annual_return,
|
||||
cycles=int(cycles),
|
||||
state_tax_rate=state_tax,
|
||||
lump_sum_rate=cfg.tax.lump_sum_rate,
|
||||
federal_tax_rate=cfg.tax.federal_tax_rate,
|
||||
usd_cad_rate=cfg.tax.usd_cad_rate,
|
||||
investment_income_tax_rate=cfg.tax.investment_income_tax_rate,
|
||||
personal_withdrawal_pct=cfg.tax.personal_withdrawal_pct,
|
||||
)
|
||||
else:
|
||||
result = calculate_canadian_lottery(jackpot, invest_percentage, annual_return, cycles)
|
||||
|
||||
result = calculate_canadian_lottery(
|
||||
jackpot,
|
||||
invest_percentage=invest_pct,
|
||||
annual_return=annual_return,
|
||||
cycles=int(cycles),
|
||||
investment_income_tax_rate=cfg.tax.investment_income_tax_rate,
|
||||
personal_withdrawal_pct=cfg.tax.personal_withdrawal_pct,
|
||||
)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
except Exception:
|
||||
logger.exception("Calculation error")
|
||||
return jsonify({"error": "Internal calculation error"}), 500
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State tax endpoints
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.route('/api/health', methods=['GET'])
|
||||
@app.route("/api/states", methods=["GET"])
|
||||
def get_states():
|
||||
"""Return all US states with their lottery tax rates."""
|
||||
states = [
|
||||
{"code": code, "name": info["name"], "rate": info["rate"]}
|
||||
for code, info in sorted(STATE_TAX_RATES.items())
|
||||
]
|
||||
return jsonify(states)
|
||||
|
||||
@app.route("/api/states/<code>", methods=["GET"])
|
||||
def get_state(code: str):
|
||||
"""Return tax info for a specific state."""
|
||||
info = STATE_TAX_RATES.get(code.upper())
|
||||
if not info:
|
||||
return jsonify({"error": f"Unknown state code: {code}"}), 404
|
||||
return jsonify({"code": code.upper(), "name": info["name"], "rate": info["rate"]})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Comparison endpoint
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.route("/api/compare", methods=["GET"])
|
||||
def compare():
|
||||
"""Side-by-side comparison of all lotteries with current jackpots."""
|
||||
jackpots = get_all_jackpots()
|
||||
state_code = request.args.get("state")
|
||||
state_tax = cfg.tax.default_state_tax_rate
|
||||
if state_code:
|
||||
st = STATE_TAX_RATES.get(state_code.upper())
|
||||
if st:
|
||||
state_tax = st["rate"]
|
||||
|
||||
comparisons = []
|
||||
lottery_map = {
|
||||
"powerball": ("us", jackpots["us"].get("powerball")),
|
||||
"megaMillions": ("us", jackpots["us"].get("megaMillions")),
|
||||
"lottoMax": ("canadian", jackpots["canadian"].get("lottoMax")),
|
||||
"lotto649": ("canadian", jackpots["canadian"].get("lotto649")),
|
||||
}
|
||||
|
||||
for key, (country_type, amount) in lottery_map.items():
|
||||
odds_info = LOTTERY_ODDS.get(key, {})
|
||||
entry = {
|
||||
"key": key,
|
||||
"name": odds_info.get("name", key),
|
||||
"country": country_type,
|
||||
"jackpot": amount,
|
||||
"odds": odds_info.get("odds"),
|
||||
"ticketCost": odds_info.get("ticket_cost"),
|
||||
"oddsFormatted": f"1 in {odds_info.get('odds', 0):,}",
|
||||
"calculation": None,
|
||||
}
|
||||
|
||||
if amount and amount > 0:
|
||||
try:
|
||||
if country_type == "us":
|
||||
calc = calculate_us_lottery(
|
||||
amount,
|
||||
state_tax_rate=state_tax,
|
||||
lump_sum_rate=cfg.tax.lump_sum_rate,
|
||||
federal_tax_rate=cfg.tax.federal_tax_rate,
|
||||
usd_cad_rate=cfg.tax.usd_cad_rate,
|
||||
investment_income_tax_rate=cfg.tax.investment_income_tax_rate,
|
||||
personal_withdrawal_pct=cfg.tax.personal_withdrawal_pct,
|
||||
)
|
||||
else:
|
||||
calc = calculate_canadian_lottery(
|
||||
amount,
|
||||
investment_income_tax_rate=cfg.tax.investment_income_tax_rate,
|
||||
personal_withdrawal_pct=cfg.tax.personal_withdrawal_pct,
|
||||
)
|
||||
entry["calculation"] = calc
|
||||
except Exception:
|
||||
logger.exception("Comparison calc failed for %s", key)
|
||||
|
||||
comparisons.append(entry)
|
||||
|
||||
return jsonify(comparisons)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Break-even calculator
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.route("/api/calculate/breakeven", methods=["POST"])
|
||||
def break_even():
|
||||
"""Calculate the jackpot amount where expected value equals ticket cost."""
|
||||
data = _require_json()
|
||||
if data is None:
|
||||
return jsonify({"error": "Request body must be JSON"}), 400
|
||||
|
||||
lottery_key = data.get("lottery", "powerball")
|
||||
odds_info = LOTTERY_ODDS.get(lottery_key)
|
||||
if not odds_info:
|
||||
return jsonify({"error": f"Unknown lottery: {lottery_key}"}), 400
|
||||
|
||||
ticket_cost = _validate_number(
|
||||
data.get("ticketCost", odds_info["ticket_cost"]),
|
||||
"ticketCost",
|
||||
minimum=0.01,
|
||||
)
|
||||
state_code = data.get("state")
|
||||
state_tax = cfg.tax.default_state_tax_rate
|
||||
if state_code:
|
||||
st = STATE_TAX_RATES.get(state_code.upper())
|
||||
if st:
|
||||
state_tax = st["rate"]
|
||||
|
||||
result = calculate_break_even(
|
||||
odds=odds_info["odds"],
|
||||
ticket_cost=ticket_cost,
|
||||
country=odds_info["country"],
|
||||
lump_sum_rate=cfg.tax.lump_sum_rate,
|
||||
federal_tax_rate=cfg.tax.federal_tax_rate,
|
||||
state_tax_rate=state_tax,
|
||||
)
|
||||
result["lottery"] = odds_info["name"]
|
||||
result["oddsFormatted"] = f"1 in {odds_info['odds']:,}"
|
||||
return jsonify(result)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Annuity calculator
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.route("/api/calculate/annuity", methods=["POST"])
|
||||
def annuity():
|
||||
"""Calculate 30-year annuity payout schedule."""
|
||||
data = _require_json()
|
||||
if data is None:
|
||||
return jsonify({"error": "Request body must be JSON"}), 400
|
||||
|
||||
jackpot = _validate_number(data.get("jackpot"), "jackpot", minimum=1)
|
||||
if jackpot is None:
|
||||
return jsonify({"error": "jackpot must be a positive number"}), 400
|
||||
|
||||
lottery_type = data.get("type", "us")
|
||||
state_code = data.get("state")
|
||||
state_tax = cfg.tax.default_state_tax_rate
|
||||
if state_code:
|
||||
st = STATE_TAX_RATES.get(state_code.upper())
|
||||
if st:
|
||||
state_tax = st["rate"]
|
||||
|
||||
years = int(
|
||||
_validate_number(
|
||||
data.get("years", ANNUITY_YEARS), "years", minimum=1, maximum=40
|
||||
)
|
||||
or ANNUITY_YEARS
|
||||
)
|
||||
annual_increase = (
|
||||
_validate_number(
|
||||
data.get("annualIncrease", ANNUITY_ANNUAL_INCREASE),
|
||||
"annualIncrease",
|
||||
minimum=0,
|
||||
maximum=0.20,
|
||||
)
|
||||
or ANNUITY_ANNUAL_INCREASE
|
||||
)
|
||||
|
||||
result = calculate_annuity(
|
||||
jackpot=jackpot,
|
||||
country=lottery_type,
|
||||
years=years,
|
||||
annual_increase=annual_increase,
|
||||
federal_tax_rate=cfg.tax.federal_tax_rate,
|
||||
state_tax_rate=state_tax,
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Group play calculator
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.route("/api/calculate/group", methods=["POST"])
|
||||
def group_play():
|
||||
"""Split winnings among a group with optional custom shares."""
|
||||
data = _require_json()
|
||||
if data is None:
|
||||
return jsonify({"error": "Request body must be JSON"}), 400
|
||||
|
||||
jackpot = _validate_number(data.get("jackpot"), "jackpot", minimum=1)
|
||||
if jackpot is None:
|
||||
return jsonify({"error": "jackpot must be a positive number"}), 400
|
||||
|
||||
members_val = _validate_number(
|
||||
data.get("members", 2), "members", minimum=1, maximum=1000
|
||||
)
|
||||
members = int(members_val) if members_val else 2
|
||||
|
||||
shares = data.get("shares") # optional list of floats summing to 1.0
|
||||
lottery_type = data.get("type", "us")
|
||||
|
||||
state_code = data.get("state")
|
||||
state_tax = cfg.tax.default_state_tax_rate
|
||||
if state_code:
|
||||
st = STATE_TAX_RATES.get(state_code.upper())
|
||||
if st:
|
||||
state_tax = st["rate"]
|
||||
|
||||
result = calculate_group_split(
|
||||
jackpot=jackpot,
|
||||
members=members,
|
||||
shares=shares,
|
||||
country=lottery_type,
|
||||
lump_sum_rate=cfg.tax.lump_sum_rate,
|
||||
federal_tax_rate=cfg.tax.federal_tax_rate,
|
||||
state_tax_rate=state_tax,
|
||||
usd_cad_rate=cfg.tax.usd_cad_rate,
|
||||
investment_income_tax_rate=cfg.tax.investment_income_tax_rate,
|
||||
personal_withdrawal_pct=cfg.tax.personal_withdrawal_pct,
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Odds / probability info
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.route("/api/odds", methods=["GET"])
|
||||
def get_odds():
|
||||
"""Return odds and ticket cost for all supported lotteries."""
|
||||
result = []
|
||||
for key, info in LOTTERY_ODDS.items():
|
||||
result.append(
|
||||
{
|
||||
"key": key,
|
||||
"name": info["name"],
|
||||
"odds": info["odds"],
|
||||
"oddsFormatted": f"1 in {info['odds']:,}",
|
||||
"oddsPercentage": f"{(1 / info['odds']) * 100:.10f}%",
|
||||
"ticketCost": info["ticket_cost"],
|
||||
"country": info["country"],
|
||||
}
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Health check
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.route("/api/health", methods=["GET"])
|
||||
def health():
|
||||
"""Health check endpoint"""
|
||||
return jsonify({"status": "ok"})
|
||||
"""Health check endpoint."""
|
||||
return jsonify({"status": "ok", "version": "2.0.0"})
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("🎰 Lottery Investment Calculator API")
|
||||
print("=" * 50)
|
||||
print("Starting Flask server on http://localhost:5000")
|
||||
print("API Endpoints:")
|
||||
print(" - GET /api/jackpots - Get current jackpots")
|
||||
print(" - POST /api/calculate - Calculate investments")
|
||||
print(" - GET /api/health - Health check")
|
||||
print("=" * 50)
|
||||
# Bind to 0.0.0.0 so the Flask app is reachable from outside the container
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
cfg = load_config()
|
||||
logger.info("Lottery Investment Calculator API v2.0")
|
||||
logger.info("Endpoints:")
|
||||
logger.info(" GET /api/jackpots - Current jackpots (cached)")
|
||||
logger.info(" POST /api/jackpots/refresh - Force refresh")
|
||||
logger.info(" POST /api/calculate - Investment calculator")
|
||||
logger.info(" POST /api/calculate/breakeven - Break-even calculator")
|
||||
logger.info(" POST /api/calculate/annuity - Annuity calculator")
|
||||
logger.info(" POST /api/calculate/group - Group play calculator")
|
||||
logger.info(" GET /api/compare - Side-by-side comparison")
|
||||
logger.info(" GET /api/states - US state tax rates")
|
||||
logger.info(" GET /api/odds - Lottery odds info")
|
||||
logger.info(" GET /api/health - Health check")
|
||||
app.run(debug=cfg.debug, host=cfg.host, port=cfg.port)
|
||||
|
||||
212
config.py
Normal file
212
config.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Centralized configuration for the Lottery Investment Calculator.
|
||||
|
||||
All magic numbers, URLs, tax rates, and tunable parameters live here.
|
||||
Values are loaded from environment variables with sensible defaults.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# US State Tax Rates (2024-2025)
|
||||
# ---------------------------------------------------------------------------
|
||||
STATE_TAX_RATES: dict[str, dict] = {
|
||||
"AL": {"name": "Alabama", "rate": 0.05},
|
||||
"AK": {"name": "Alaska", "rate": 0.0},
|
||||
"AZ": {"name": "Arizona", "rate": 0.025},
|
||||
"AR": {"name": "Arkansas", "rate": 0.044},
|
||||
"CA": {"name": "California", "rate": 0.133},
|
||||
"CO": {"name": "Colorado", "rate": 0.044},
|
||||
"CT": {"name": "Connecticut", "rate": 0.0699},
|
||||
"DE": {"name": "Delaware", "rate": 0.066},
|
||||
"FL": {"name": "Florida", "rate": 0.0},
|
||||
"GA": {"name": "Georgia", "rate": 0.055},
|
||||
"HI": {"name": "Hawaii", "rate": 0.11},
|
||||
"ID": {"name": "Idaho", "rate": 0.058},
|
||||
"IL": {"name": "Illinois", "rate": 0.0495},
|
||||
"IN": {"name": "Indiana", "rate": 0.0305},
|
||||
"IA": {"name": "Iowa", "rate": 0.06},
|
||||
"KS": {"name": "Kansas", "rate": 0.057},
|
||||
"KY": {"name": "Kentucky", "rate": 0.04},
|
||||
"LA": {"name": "Louisiana", "rate": 0.0425},
|
||||
"ME": {"name": "Maine", "rate": 0.0715},
|
||||
"MD": {"name": "Maryland", "rate": 0.0575},
|
||||
"MA": {"name": "Massachusetts", "rate": 0.09},
|
||||
"MI": {"name": "Michigan", "rate": 0.0425},
|
||||
"MN": {"name": "Minnesota", "rate": 0.0985},
|
||||
"MS": {"name": "Mississippi", "rate": 0.05},
|
||||
"MO": {"name": "Missouri", "rate": 0.048},
|
||||
"MT": {"name": "Montana", "rate": 0.059},
|
||||
"NE": {"name": "Nebraska", "rate": 0.0564},
|
||||
"NV": {"name": "Nevada", "rate": 0.0},
|
||||
"NH": {"name": "New Hampshire", "rate": 0.0},
|
||||
"NJ": {"name": "New Jersey", "rate": 0.1075},
|
||||
"NM": {"name": "New Mexico", "rate": 0.059},
|
||||
"NY": {"name": "New York", "rate": 0.109},
|
||||
"NC": {"name": "North Carolina", "rate": 0.045},
|
||||
"ND": {"name": "North Dakota", "rate": 0.0195},
|
||||
"OH": {"name": "Ohio", "rate": 0.0357},
|
||||
"OK": {"name": "Oklahoma", "rate": 0.0475},
|
||||
"OR": {"name": "Oregon", "rate": 0.099},
|
||||
"PA": {"name": "Pennsylvania", "rate": 0.0307},
|
||||
"RI": {"name": "Rhode Island", "rate": 0.0599},
|
||||
"SC": {"name": "South Carolina", "rate": 0.064},
|
||||
"SD": {"name": "South Dakota", "rate": 0.0},
|
||||
"TN": {"name": "Tennessee", "rate": 0.0},
|
||||
"TX": {"name": "Texas", "rate": 0.0},
|
||||
"UT": {"name": "Utah", "rate": 0.0465},
|
||||
"VT": {"name": "Vermont", "rate": 0.0875},
|
||||
"VA": {"name": "Virginia", "rate": 0.0575},
|
||||
"WA": {"name": "Washington", "rate": 0.0},
|
||||
"WV": {"name": "West Virginia", "rate": 0.055},
|
||||
"WI": {"name": "Wisconsin", "rate": 0.0765},
|
||||
"WY": {"name": "Wyoming", "rate": 0.0},
|
||||
"DC": {"name": "District of Columbia", "rate": 0.105},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lottery Odds (1 in N for jackpot)
|
||||
# ---------------------------------------------------------------------------
|
||||
LOTTERY_ODDS: dict[str, dict] = {
|
||||
"powerball": {
|
||||
"name": "Powerball",
|
||||
"odds": 292_201_338,
|
||||
"ticket_cost": 2.0,
|
||||
"country": "us",
|
||||
},
|
||||
"megaMillions": {
|
||||
"name": "Mega Millions",
|
||||
"odds": 302_575_350,
|
||||
"ticket_cost": 2.0,
|
||||
"country": "us",
|
||||
},
|
||||
"lottoMax": {
|
||||
"name": "Lotto Max",
|
||||
"odds": 33_294_800,
|
||||
"ticket_cost": 5.0,
|
||||
"country": "canadian",
|
||||
},
|
||||
"lotto649": {
|
||||
"name": "Lotto 6/49",
|
||||
"odds": 13_983_816,
|
||||
"ticket_cost": 3.0,
|
||||
"country": "canadian",
|
||||
},
|
||||
}
|
||||
|
||||
# Annuity constants
|
||||
ANNUITY_YEARS = 30
|
||||
ANNUITY_ANNUAL_INCREASE = 0.05 # 5% annual increase (Powerball/MM standard)
|
||||
|
||||
|
||||
def _env_float(key: str, default: float) -> float:
|
||||
"""Read a float from an environment variable with a fallback."""
|
||||
raw = os.environ.get(key)
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _env_str(key: str, default: str) -> str:
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
def _env_int(key: str, default: int) -> int:
|
||||
raw = os.environ.get(key)
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _env_bool(key: str, default: bool) -> bool:
|
||||
raw = os.environ.get(key, "").lower()
|
||||
if raw in ("1", "true", "yes"):
|
||||
return True
|
||||
if raw in ("0", "false", "no"):
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScraperURLs:
|
||||
"""Target URLs for lottery scraping."""
|
||||
|
||||
powerball: str = "https://www.lotto.net/powerball"
|
||||
mega_millions: str = "https://www.lotto.net/mega-millions"
|
||||
olg: str = "https://www.olg.ca/"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaxConfig:
|
||||
"""Tax and financial parameters."""
|
||||
|
||||
lump_sum_rate: float = 0.52
|
||||
federal_tax_rate: float = 0.37
|
||||
default_state_tax_rate: float = 0.055
|
||||
usd_cad_rate: float = 1.35
|
||||
investment_income_tax_rate: float = 0.5353
|
||||
personal_withdrawal_pct: float = 0.10
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InvestmentDefaults:
|
||||
"""Default investment calculation parameters."""
|
||||
|
||||
invest_percentage: float = 0.90
|
||||
annual_return: float = 0.045
|
||||
cycles: int = 8
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppConfig:
|
||||
"""Top-level application configuration."""
|
||||
|
||||
# Flask
|
||||
debug: bool = False
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 5000
|
||||
allowed_origins: str = "*"
|
||||
|
||||
# Sub-configs
|
||||
urls: ScraperURLs = field(default_factory=ScraperURLs)
|
||||
tax: TaxConfig = field(default_factory=TaxConfig)
|
||||
investment: InvestmentDefaults = field(default_factory=InvestmentDefaults)
|
||||
|
||||
# Scraper cache TTL in seconds (default 6 hours)
|
||||
cache_ttl: int = 21600
|
||||
|
||||
|
||||
def load_config() -> AppConfig:
|
||||
"""Build an ``AppConfig`` from environment variables."""
|
||||
return AppConfig(
|
||||
debug=_env_bool("FLASK_DEBUG", False),
|
||||
host=_env_str("FLASK_HOST", "0.0.0.0"),
|
||||
port=_env_int("FLASK_PORT", 5000),
|
||||
allowed_origins=_env_str("ALLOWED_ORIGINS", "*"),
|
||||
urls=ScraperURLs(
|
||||
powerball=_env_str("SCRAPER_URL_POWERBALL", ScraperURLs.powerball),
|
||||
mega_millions=_env_str("SCRAPER_URL_MEGA_MILLIONS", ScraperURLs.mega_millions),
|
||||
olg=_env_str("SCRAPER_URL_OLG", ScraperURLs.olg),
|
||||
),
|
||||
tax=TaxConfig(
|
||||
lump_sum_rate=_env_float("LUMP_SUM_RATE", TaxConfig.lump_sum_rate),
|
||||
federal_tax_rate=_env_float("FEDERAL_TAX_RATE", TaxConfig.federal_tax_rate),
|
||||
default_state_tax_rate=_env_float("DEFAULT_STATE_TAX_RATE", TaxConfig.default_state_tax_rate),
|
||||
usd_cad_rate=_env_float("USD_CAD_RATE", TaxConfig.usd_cad_rate),
|
||||
investment_income_tax_rate=_env_float("INVESTMENT_INCOME_TAX_RATE", TaxConfig.investment_income_tax_rate),
|
||||
personal_withdrawal_pct=_env_float("PERSONAL_WITHDRAWAL_PCT", TaxConfig.personal_withdrawal_pct),
|
||||
),
|
||||
investment=InvestmentDefaults(
|
||||
invest_percentage=_env_float("DEFAULT_INVEST_PCT", InvestmentDefaults.invest_percentage),
|
||||
annual_return=_env_float("DEFAULT_ANNUAL_RETURN", InvestmentDefaults.annual_return),
|
||||
cycles=_env_int("DEFAULT_CYCLES", InvestmentDefaults.cycles),
|
||||
),
|
||||
cache_ttl=_env_int("CACHE_TTL_SECONDS", 21600),
|
||||
)
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Nginx Reverse Proxy
|
||||
nginx:
|
||||
@@ -26,14 +24,17 @@ services:
|
||||
container_name: lottery-backend
|
||||
expose:
|
||||
- "5000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
- FLASK_DEBUG=false
|
||||
- PYTHONUNBUFFERED=1
|
||||
restart: always
|
||||
networks:
|
||||
- lottery-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -41,10 +42,10 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
cpus: "1"
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
cpus: "0.5"
|
||||
memory: 1G
|
||||
|
||||
# Next.js Frontend
|
||||
@@ -52,11 +53,13 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: /api
|
||||
container_name: lottery-frontend
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://backend:5000
|
||||
- NEXT_PUBLIC_API_URL=/api
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
- backend
|
||||
@@ -66,29 +69,12 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
cpus: "0.5"
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
cpus: "0.25"
|
||||
memory: 256M
|
||||
|
||||
# Email Scheduler
|
||||
email-scheduler:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.email
|
||||
container_name: lottery-email
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
restart: always
|
||||
networks:
|
||||
- lottery-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 1G
|
||||
|
||||
networks:
|
||||
lottery-network:
|
||||
driver: bridge
|
||||
|
||||
@@ -9,14 +9,17 @@ services:
|
||||
container_name: lottery-backend
|
||||
ports:
|
||||
- "5000:5000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
- FLASK_DEBUG=false
|
||||
- PYTHONUNBUFFERED=1
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- lottery-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -40,20 +43,6 @@ services:
|
||||
networks:
|
||||
- lottery-network
|
||||
|
||||
# Email Scheduler (Optional - runs daily at 7 AM)
|
||||
email-scheduler:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.email
|
||||
container_name: lottery-email
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- lottery-network
|
||||
profiles:
|
||||
- email # Only start if explicitly requested
|
||||
|
||||
networks:
|
||||
lottery-network:
|
||||
driver: bridge
|
||||
|
||||
329
email_sender.py
329
email_sender.py
@@ -1,329 +0,0 @@
|
||||
import schedule
|
||||
import time
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
from playwright.async_api import async_playwright
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
import urllib3
|
||||
import re
|
||||
|
||||
# Disable SSL warnings
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
# Email configuration
|
||||
EMAIL_CONFIG = {
|
||||
'smtp_server': 'smtp.gmail.com', # Change this for your email provider
|
||||
'smtp_port': 587,
|
||||
'sender_email': 'mblanke@gmail.com', # Replace with your email
|
||||
'sender_password': 'vyapvyjjfrqpqnax', # App password (spaces removed)
|
||||
'recipient_email': 'mblanke@gmail.com', # Replace with recipient email
|
||||
}
|
||||
|
||||
# Common headers to mimic a browser request
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
|
||||
# Scraping functions
|
||||
def get_powerball():
|
||||
"""Get Powerball jackpot from lotto.net"""
|
||||
try:
|
||||
url = "https://www.lotto.net/powerball"
|
||||
response = requests.get(url, timeout=10, verify=False, headers=HEADERS)
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
|
||||
# Look for "Next Jackpot" text
|
||||
all_text = soup.get_text()
|
||||
lines = all_text.split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if 'Next Jackpot' in line and i + 1 < len(lines):
|
||||
next_line = lines[i + 1].strip()
|
||||
if '$' in next_line and 'Million' in next_line:
|
||||
# Parse the amount
|
||||
match = re.search(r'\$\s*([\d,]+(?:\.\d+)?)\s*Million', next_line)
|
||||
if match:
|
||||
amount_str = match.group(1).replace(',', '')
|
||||
return float(amount_str)
|
||||
except Exception as e:
|
||||
print(f"Error getting Powerball: {e}")
|
||||
return None
|
||||
|
||||
def get_mega_millions():
|
||||
"""Get Mega Millions jackpot from lotto.net"""
|
||||
try:
|
||||
url = "https://www.lotto.net/mega-millions"
|
||||
response = requests.get(url, timeout=10, verify=False, headers=HEADERS)
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
|
||||
# Look for "Next Jackpot" text
|
||||
all_text = soup.get_text()
|
||||
lines = all_text.split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if 'Next Jackpot' in line and i + 1 < len(lines):
|
||||
next_line = lines[i + 1].strip()
|
||||
if '$' in next_line and 'Million' in next_line:
|
||||
# Parse the amount
|
||||
match = re.search(r'\$\s*([\d,]+(?:\.\d+)?)\s*Million', next_line)
|
||||
if match:
|
||||
amount_str = match.group(1).replace(',', '')
|
||||
return float(amount_str)
|
||||
except Exception as e:
|
||||
print(f"Error getting Mega Millions: {e}")
|
||||
return None
|
||||
|
||||
async def get_canadian_lotteries():
|
||||
"""Get Lotto Max and Lotto 6/49 jackpots using Playwright"""
|
||||
lotto_max = None
|
||||
lotto_649 = None
|
||||
|
||||
try:
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
page = await browser.new_page()
|
||||
|
||||
await page.goto('https://www.olg.ca/', wait_until='networkidle')
|
||||
await page.wait_for_timeout(2000)
|
||||
|
||||
content = await page.content()
|
||||
|
||||
# Lotto Max pattern
|
||||
lotto_max_pattern = r'LOTTO\s*MAX(?:(?!LOTTO\s*6/49).)*?\$\s*([\d.,]+)\s*Million'
|
||||
match = re.search(lotto_max_pattern, content, re.IGNORECASE | re.DOTALL)
|
||||
if match:
|
||||
amount_str = match.group(1).replace(',', '')
|
||||
lotto_max = float(amount_str)
|
||||
|
||||
# Lotto 6/49 pattern
|
||||
lotto_649_pattern = r'LOTTO\s*6/49.*?\$\s*([\d.,]+)\s*Million'
|
||||
match = re.search(lotto_649_pattern, content, re.IGNORECASE | re.DOTALL)
|
||||
if match:
|
||||
amount_str = match.group(1).replace(',', '')
|
||||
lotto_649 = float(amount_str)
|
||||
|
||||
await browser.close()
|
||||
except Exception as e:
|
||||
print(f"Error getting Canadian lotteries: {e}")
|
||||
|
||||
return lotto_max, lotto_649
|
||||
|
||||
def format_currency(amount):
|
||||
"""Format amount as currency"""
|
||||
if amount is None:
|
||||
return "Not available"
|
||||
return f"${amount:,.0f}M"
|
||||
|
||||
def create_email_html(powerball, mega_millions, lotto_max, lotto_649):
|
||||
"""Create HTML email content"""
|
||||
html = f"""
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f4f4f4;
|
||||
padding: 20px;
|
||||
}}
|
||||
.container {{
|
||||
background-color: white;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}}
|
||||
h1 {{
|
||||
color: #2c3e50;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}}
|
||||
.lottery-section {{
|
||||
margin-bottom: 30px;
|
||||
}}
|
||||
.lottery-section h2 {{
|
||||
color: #34495e;
|
||||
border-bottom: 2px solid #3498db;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 15px;
|
||||
}}
|
||||
.lottery-item {{
|
||||
background-color: #ecf0f1;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}}
|
||||
.lottery-name {{
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
font-size: 16px;
|
||||
}}
|
||||
.lottery-amount {{
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #27ae60;
|
||||
}}
|
||||
.tax-free {{
|
||||
background-color: #2ecc71;
|
||||
color: white;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
margin-left: 10px;
|
||||
}}
|
||||
.footer {{
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #ecf0f1;
|
||||
color: #7f8c8d;
|
||||
font-size: 12px;
|
||||
}}
|
||||
.timestamp {{
|
||||
color: #95a5a6;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎰 Daily Lottery Jackpots</h1>
|
||||
|
||||
<div class="lottery-section">
|
||||
<h2>🇺🇸 US Lotteries</h2>
|
||||
<div class="lottery-item">
|
||||
<div>
|
||||
<span class="lottery-name">Powerball</span>
|
||||
</div>
|
||||
<span class="lottery-amount">{format_currency(powerball)}</span>
|
||||
</div>
|
||||
<div class="lottery-item">
|
||||
<div>
|
||||
<span class="lottery-name">Mega Millions</span>
|
||||
</div>
|
||||
<span class="lottery-amount">{format_currency(mega_millions)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lottery-section">
|
||||
<h2>🇨🇦 Canadian Lotteries</h2>
|
||||
<div class="lottery-item">
|
||||
<div>
|
||||
<span class="lottery-name">Lotto Max</span>
|
||||
<span class="tax-free">TAX FREE</span>
|
||||
</div>
|
||||
<span class="lottery-amount">{format_currency(lotto_max)}</span>
|
||||
</div>
|
||||
<div class="lottery-item">
|
||||
<div>
|
||||
<span class="lottery-name">Lotto 6/49</span>
|
||||
<span class="tax-free">TAX FREE</span>
|
||||
</div>
|
||||
<span class="lottery-amount">{format_currency(lotto_649)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>💡 Remember: Canadian lottery winnings are tax-free!</p>
|
||||
<p>📊 Visit your Lottery Investment Calculator for detailed analysis</p>
|
||||
</div>
|
||||
|
||||
<div class="timestamp">
|
||||
Generated on {datetime.now().strftime('%B %d, %Y at %I:%M %p')}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return html
|
||||
|
||||
def send_email(subject, html_content):
|
||||
"""Send email with jackpot information"""
|
||||
try:
|
||||
# Create message
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = EMAIL_CONFIG['sender_email']
|
||||
msg['To'] = EMAIL_CONFIG['recipient_email']
|
||||
|
||||
# Attach HTML content
|
||||
html_part = MIMEText(html_content, 'html')
|
||||
msg.attach(html_part)
|
||||
|
||||
# Send email
|
||||
with smtplib.SMTP(EMAIL_CONFIG['smtp_server'], EMAIL_CONFIG['smtp_port']) as server:
|
||||
server.starttls()
|
||||
server.login(EMAIL_CONFIG['sender_email'], EMAIL_CONFIG['sender_password'])
|
||||
server.send_message(msg)
|
||||
|
||||
print(f"✅ Email sent successfully at {datetime.now().strftime('%I:%M %p')}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ Error sending email: {e}")
|
||||
return False
|
||||
|
||||
def send_daily_jackpots():
|
||||
"""Fetch jackpots and send email"""
|
||||
print(f"\n{'='*50}")
|
||||
print(f"🎰 Fetching lottery jackpots at {datetime.now().strftime('%I:%M %p')}")
|
||||
print(f"{'='*50}")
|
||||
|
||||
# Get US lotteries
|
||||
print("📊 Fetching Powerball...")
|
||||
powerball = get_powerball()
|
||||
print(f" Powerball: {format_currency(powerball)}")
|
||||
|
||||
print("📊 Fetching Mega Millions...")
|
||||
mega_millions = get_mega_millions()
|
||||
print(f" Mega Millions: {format_currency(mega_millions)}")
|
||||
|
||||
# Get Canadian lotteries
|
||||
print("📊 Fetching Canadian lotteries...")
|
||||
lotto_max, lotto_649 = asyncio.run(get_canadian_lotteries())
|
||||
print(f" Lotto Max: {format_currency(lotto_max)}")
|
||||
print(f" Lotto 6/49: {format_currency(lotto_649)}")
|
||||
|
||||
# Create email content
|
||||
subject = f"🎰 Daily Lottery Report - {datetime.now().strftime('%B %d, %Y')}"
|
||||
html_content = create_email_html(powerball, mega_millions, lotto_max, lotto_649)
|
||||
|
||||
# Send email
|
||||
print("\n📧 Sending email...")
|
||||
send_email(subject, html_content)
|
||||
print(f"{'='*50}\n")
|
||||
|
||||
def main():
|
||||
"""Main function to schedule and run the email sender"""
|
||||
print("🚀 Lottery Jackpot Email Scheduler Started")
|
||||
print("=" * 50)
|
||||
print(f"📧 Emails will be sent to: {EMAIL_CONFIG['recipient_email']}")
|
||||
print(f"⏰ Scheduled time: 7:00 AM daily")
|
||||
print(f"🔄 Current time: {datetime.now().strftime('%I:%M %p')}")
|
||||
print("=" * 50)
|
||||
print("\nPress Ctrl+C to stop the scheduler\n")
|
||||
|
||||
# Schedule the job for 7:00 AM every day
|
||||
schedule.every().day.at("07:00").do(send_daily_jackpots)
|
||||
|
||||
# Optional: Uncomment to send immediately for testing
|
||||
# print("🧪 Sending test email now...")
|
||||
# send_daily_jackpots()
|
||||
|
||||
# Keep the script running
|
||||
while True:
|
||||
schedule.run_pending()
|
||||
time.sleep(60) # Check every minute
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2
frontend
2
frontend
Submodule frontend updated: cbf83de8c1...dcb2161ea4
@@ -1,186 +0,0 @@
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
import urllib3
|
||||
from playwright.sync_api import sync_playwright
|
||||
import re
|
||||
|
||||
# Suppress SSL warnings
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
# Common headers to mimic a browser request
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Cache-Control": "max-age=0",
|
||||
}
|
||||
|
||||
def get_powerball():
|
||||
url = "https://www.lotto.net/powerball"
|
||||
try:
|
||||
resp = requests.get(url, timeout=10, verify=False, headers=HEADERS)
|
||||
resp.raise_for_status()
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
# Look for divs containing "Next Jackpot" and "$XXX Million"
|
||||
all_text = soup.get_text()
|
||||
lines = all_text.split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if 'Next Jackpot' in line and i + 1 < len(lines):
|
||||
next_line = lines[i + 1].strip()
|
||||
if '$' in next_line and 'Million' in next_line:
|
||||
return next_line
|
||||
return "Not found"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def get_mega_millions():
|
||||
url = "https://www.lotto.net/mega-millions"
|
||||
try:
|
||||
resp = requests.get(url, timeout=10, verify=False, headers=HEADERS)
|
||||
resp.raise_for_status()
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
# Look for divs containing "Next Jackpot" and "$XXX Million"
|
||||
all_text = soup.get_text()
|
||||
lines = all_text.split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if 'Next Jackpot' in line and i + 1 < len(lines):
|
||||
next_line = lines[i + 1].strip()
|
||||
if '$' in next_line and 'Million' in next_line:
|
||||
return next_line
|
||||
return "Not found"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def get_lotto_max():
|
||||
url = "https://www.olg.ca/"
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
page.goto(url, wait_until="networkidle", timeout=30000)
|
||||
# Wait for lottery content to load
|
||||
page.wait_for_timeout(3000)
|
||||
content = page.content()
|
||||
browser.close()
|
||||
|
||||
# Search for Lotto Max jackpot - look for the pattern more carefully
|
||||
# Match "LOTTO MAX" followed by jackpot info, avoiding 649
|
||||
match = re.search(r'LOTTO\s*MAX(?:(?!LOTTO\s*6/49).)*?\$\s*([\d.,]+)\s*Million', content, re.IGNORECASE | re.DOTALL)
|
||||
if match:
|
||||
return f"${match.group(1)} Million"
|
||||
return "Not found"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def get_lotto_649():
|
||||
url = "https://www.olg.ca/"
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
page.goto(url, wait_until="networkidle", timeout=30000)
|
||||
# Wait for lottery content to load
|
||||
page.wait_for_timeout(3000)
|
||||
content = page.content()
|
||||
browser.close()
|
||||
|
||||
# Search for Lotto 6/49 jackpot - be more specific
|
||||
match = re.search(r'LOTTO\s*6/49(?:(?!LOTTO\s*MAX).)*?\$\s*([\d.,]+)\s*Million', content, re.IGNORECASE | re.DOTALL)
|
||||
if match:
|
||||
return f"${match.group(1)} Million"
|
||||
return "Not found"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def get_olg_lotteries():
|
||||
"""
|
||||
Fetches jackpot amounts for Lotto Max and Lotto 6/49 from OLG website using Playwright.
|
||||
Returns a dict with keys 'Lotto Max' and 'Lotto 6/49'.
|
||||
"""
|
||||
url = "https://www.olg.ca/"
|
||||
results = {"Lotto Max": "Not found", "Lotto 6/49": "Not found"}
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
page.goto(url, wait_until="networkidle", timeout=30000)
|
||||
# Wait for lottery content to load
|
||||
page.wait_for_timeout(3000)
|
||||
content = page.content()
|
||||
browser.close()
|
||||
|
||||
# Lotto Max - be more specific to avoid 649
|
||||
lotto_max_match = re.search(r'LOTTO\s*MAX(?:(?!LOTTO\s*6/49).)*?\$\s*([\d.,]+)\s*Million', content, re.IGNORECASE | re.DOTALL)
|
||||
if lotto_max_match:
|
||||
results["Lotto Max"] = f"${lotto_max_match.group(1)} Million"
|
||||
|
||||
# Lotto 6/49 - be more specific to avoid MAX
|
||||
lotto_649_match = re.search(r'LOTTO\s*6/49(?:(?!LOTTO\s*MAX).)*?\$\s*([\d.,]+)\s*Million', content, re.IGNORECASE | re.DOTALL)
|
||||
if lotto_649_match:
|
||||
results["Lotto 6/49"] = f"${lotto_649_match.group(1)} Million"
|
||||
except Exception as e:
|
||||
results = {"Lotto Max": f"Error: {e}", "Lotto 6/49": f"Error: {e}"}
|
||||
return results
|
||||
|
||||
def get_lottery_usa():
|
||||
"""
|
||||
Fetches jackpot amounts for Powerball and Mega Millions from lotto.net.
|
||||
Returns a dict with keys 'Powerball' and 'Mega Millions'.
|
||||
"""
|
||||
results = {"Powerball": "Not found", "Mega Millions": "Not found"}
|
||||
|
||||
# Get Powerball
|
||||
try:
|
||||
resp = requests.get("https://www.lotto.net/powerball", timeout=10, verify=False, headers=HEADERS)
|
||||
resp.raise_for_status()
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
all_text = soup.get_text()
|
||||
lines = all_text.split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if 'Next Jackpot' in line and i + 1 < len(lines):
|
||||
next_line = lines[i + 1].strip()
|
||||
if '$' in next_line and 'Million' in next_line:
|
||||
results["Powerball"] = next_line
|
||||
break
|
||||
except Exception as e:
|
||||
results["Powerball"] = f"Error: {e}"
|
||||
|
||||
# Get Mega Millions
|
||||
try:
|
||||
resp = requests.get("https://www.lotto.net/mega-millions", timeout=10, verify=False, headers=HEADERS)
|
||||
resp.raise_for_status()
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
all_text = soup.get_text()
|
||||
lines = all_text.split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if 'Next Jackpot' in line and i + 1 < len(lines):
|
||||
next_line = lines[i + 1].strip()
|
||||
if '$' in next_line and 'Million' in next_line:
|
||||
results["Mega Millions"] = next_line
|
||||
break
|
||||
except Exception as e:
|
||||
results["Mega Millions"] = f"Error: {e}"
|
||||
|
||||
return results
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🎰 Current Lottery Jackpots")
|
||||
print("------------------------------")
|
||||
print(f"Powerball: {get_powerball()}")
|
||||
print(f"Mega Millions: {get_mega_millions()}")
|
||||
print(f"Lotto Max: {get_lotto_max()}")
|
||||
print(f"Lotto 6/49: {get_lotto_649()}")
|
||||
# Add OLG results as fallback/alternative
|
||||
olg = get_olg_lotteries()
|
||||
print(f"OLG Lotto Max: {olg['Lotto Max']}")
|
||||
print(f"OLG Lotto 6/49: {olg['Lotto 6/49']}")
|
||||
# Add Lottery USA results
|
||||
lottery_usa = get_lottery_usa()
|
||||
print(f"Lottery USA Powerball: {lottery_usa['Powerball']}")
|
||||
print(f"Lottery USA Mega Millions: {lottery_usa['Mega Millions']}")
|
||||
@@ -1,195 +1,344 @@
|
||||
"""
|
||||
Lottery Investment Calculator
|
||||
Handles both US and Canadian lottery calculations
|
||||
Lottery Investment Calculator — pure calculation logic.
|
||||
|
||||
All functions are deterministic and side-effect free.
|
||||
Tax rates, exchange rates, and investment defaults are passed as explicit
|
||||
parameters (with sensible defaults) so that callers can override via config.
|
||||
"""
|
||||
|
||||
def calculate_us_lottery(jackpot, invest_percentage=0.90, annual_return=0.045, cycles=8):
|
||||
from __future__ import annotations
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core calculators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _run_investment_cycles(
|
||||
principal: float,
|
||||
annual_return: float,
|
||||
cycles: int,
|
||||
investment_income_tax_rate: float,
|
||||
personal_withdrawal_pct: float,
|
||||
) -> tuple[list[dict], float, float]:
|
||||
"""Simulate 90-day reinvestment cycles.
|
||||
|
||||
Returns:
|
||||
(cycle_results, total_personal_withdrawals, final_principal)
|
||||
"""
|
||||
Calculate investment returns for US lottery winnings
|
||||
cycle_results: list[dict] = []
|
||||
total_personal_withdrawals = 0.0
|
||||
|
||||
for cycle in range(1, cycles + 1):
|
||||
interest_earned = principal * annual_return * (90 / 365)
|
||||
taxes_owed = interest_earned * investment_income_tax_rate
|
||||
personal_withdrawal = interest_earned * personal_withdrawal_pct
|
||||
total_withdrawal = taxes_owed + personal_withdrawal
|
||||
reinvestment = interest_earned - total_withdrawal
|
||||
new_principal = principal + reinvestment
|
||||
|
||||
total_personal_withdrawals += personal_withdrawal
|
||||
|
||||
cycle_results.append(
|
||||
{
|
||||
"cycle": cycle,
|
||||
"principalStart": principal,
|
||||
"interestEarned": interest_earned,
|
||||
"taxesOwed": taxes_owed,
|
||||
"personalWithdrawal": personal_withdrawal,
|
||||
"totalWithdrawal": total_withdrawal,
|
||||
"reinvestment": reinvestment,
|
||||
"principalEnd": new_principal,
|
||||
}
|
||||
)
|
||||
principal = new_principal
|
||||
|
||||
return cycle_results, total_personal_withdrawals, principal
|
||||
|
||||
|
||||
def calculate_us_lottery(
|
||||
jackpot: float,
|
||||
invest_percentage: float = 0.90,
|
||||
annual_return: float = 0.045,
|
||||
cycles: int = 8,
|
||||
*,
|
||||
state_tax_rate: float = 0.055,
|
||||
lump_sum_rate: float = 0.52,
|
||||
federal_tax_rate: float = 0.37,
|
||||
usd_cad_rate: float = 1.35,
|
||||
investment_income_tax_rate: float = 0.5353,
|
||||
personal_withdrawal_pct: float = 0.10,
|
||||
) -> dict:
|
||||
"""Calculate investment returns for US lottery winnings.
|
||||
|
||||
Args:
|
||||
jackpot: Original jackpot amount (USD)
|
||||
invest_percentage: Percentage to invest (default 90%)
|
||||
annual_return: Annual return rate (default 4.5%)
|
||||
cycles: Number of 90-day cycles to calculate (default 8)
|
||||
jackpot: Advertised jackpot amount (USD).
|
||||
invest_percentage: Fraction to invest (0-1).
|
||||
annual_return: Expected annual return rate.
|
||||
cycles: Number of 90-day reinvestment cycles.
|
||||
state_tax_rate: State income-tax rate on winnings.
|
||||
lump_sum_rate: Fraction of advertised jackpot available as lump sum.
|
||||
federal_tax_rate: Federal income-tax rate on winnings.
|
||||
usd_cad_rate: USD to CAD exchange rate.
|
||||
investment_income_tax_rate: Marginal tax on investment income.
|
||||
personal_withdrawal_pct: Fraction of interest withdrawn each cycle.
|
||||
"""
|
||||
# US Lottery calculations
|
||||
cash_sum = jackpot * 0.52 # Lump sum is 52%
|
||||
federal_tax = cash_sum * 0.37
|
||||
state_tax = cash_sum * 0.055
|
||||
cash_sum = jackpot * lump_sum_rate
|
||||
federal_tax = cash_sum * federal_tax_rate
|
||||
state_tax = cash_sum * state_tax_rate
|
||||
net_amount = cash_sum - federal_tax - state_tax
|
||||
|
||||
# Convert to Canadian dollars
|
||||
canadian_amount = net_amount * 1.35
|
||||
canadian_amount = net_amount * usd_cad_rate
|
||||
|
||||
# Split into investment and fun money
|
||||
investment_principal = canadian_amount * invest_percentage
|
||||
fun_money = canadian_amount * (1 - invest_percentage)
|
||||
|
||||
# Calculate cycles
|
||||
cycle_results = []
|
||||
principal = investment_principal
|
||||
total_personal_withdrawals = 0
|
||||
cycle_results, total_withdrawals, final_principal = _run_investment_cycles(
|
||||
investment_principal,
|
||||
annual_return,
|
||||
cycles,
|
||||
investment_income_tax_rate,
|
||||
personal_withdrawal_pct,
|
||||
)
|
||||
|
||||
for cycle in range(1, cycles + 1):
|
||||
# Interest for 90 days
|
||||
interest_earned = principal * annual_return * (90/365)
|
||||
|
||||
# Taxes on investment income (53.53%)
|
||||
taxes_owed = interest_earned * 0.5353
|
||||
|
||||
# Personal withdrawal (10% of interest)
|
||||
personal_withdrawal = interest_earned * 0.10
|
||||
|
||||
# Total withdrawal
|
||||
total_withdrawal = taxes_owed + personal_withdrawal
|
||||
|
||||
# Reinvestment
|
||||
reinvestment = interest_earned - total_withdrawal
|
||||
|
||||
# New principal
|
||||
new_principal = principal + reinvestment
|
||||
|
||||
total_personal_withdrawals += personal_withdrawal
|
||||
|
||||
cycle_results.append({
|
||||
'cycle': cycle,
|
||||
'principal_start': principal,
|
||||
'interest_earned': interest_earned,
|
||||
'taxes_owed': taxes_owed,
|
||||
'personal_withdrawal': personal_withdrawal,
|
||||
'total_withdrawal': total_withdrawal,
|
||||
'reinvestment': reinvestment,
|
||||
'principal_end': new_principal
|
||||
})
|
||||
|
||||
principal = new_principal
|
||||
|
||||
# Calculate daily income
|
||||
net_daily_income = (investment_principal * annual_return * 0.5353) / 365
|
||||
net_daily_income = (investment_principal * annual_return * (1 - investment_income_tax_rate)) / 365
|
||||
|
||||
return {
|
||||
'country': 'US',
|
||||
'original_jackpot': jackpot,
|
||||
'cash_sum': cash_sum,
|
||||
'federal_tax': federal_tax,
|
||||
'state_tax': state_tax,
|
||||
'net_amount_usd': net_amount,
|
||||
'net_amount_cad': canadian_amount,
|
||||
'investment_principal': investment_principal,
|
||||
'fun_money': fun_money,
|
||||
'net_daily_income': net_daily_income,
|
||||
'annual_income': net_daily_income * 365,
|
||||
'total_personal_withdrawals': total_personal_withdrawals,
|
||||
'final_principal': principal,
|
||||
'cycles': cycle_results
|
||||
"country": "US",
|
||||
"originalJackpot": jackpot,
|
||||
"cashSum": cash_sum,
|
||||
"federalTax": federal_tax,
|
||||
"stateTax": state_tax,
|
||||
"stateTaxRate": state_tax_rate,
|
||||
"netAmountUsd": net_amount,
|
||||
"netAmountCad": canadian_amount,
|
||||
"investmentPrincipal": investment_principal,
|
||||
"funMoney": fun_money,
|
||||
"netDailyIncome": net_daily_income,
|
||||
"annualIncome": net_daily_income * 365,
|
||||
"totalPersonalWithdrawals": total_withdrawals,
|
||||
"finalPrincipal": final_principal,
|
||||
"cycles": cycle_results,
|
||||
}
|
||||
|
||||
|
||||
def calculate_canadian_lottery(jackpot, invest_percentage=0.90, annual_return=0.045, cycles=8):
|
||||
"""
|
||||
Calculate investment returns for Canadian lottery winnings
|
||||
def calculate_canadian_lottery(
|
||||
jackpot: float,
|
||||
invest_percentage: float = 0.90,
|
||||
annual_return: float = 0.045,
|
||||
cycles: int = 8,
|
||||
*,
|
||||
investment_income_tax_rate: float = 0.5353,
|
||||
personal_withdrawal_pct: float = 0.10,
|
||||
) -> dict:
|
||||
"""Calculate investment returns for Canadian lottery winnings (tax-free).
|
||||
|
||||
Args:
|
||||
jackpot: Original jackpot amount (CAD) - TAX FREE!
|
||||
invest_percentage: Percentage to invest (default 90%)
|
||||
annual_return: Annual return rate (default 4.5%)
|
||||
cycles: Number of 90-day cycles to calculate (default 8)
|
||||
jackpot: Jackpot amount (CAD) - no tax deducted on winnings.
|
||||
invest_percentage: Fraction to invest (0-1).
|
||||
annual_return: Expected annual return rate.
|
||||
cycles: Number of 90-day reinvestment cycles.
|
||||
investment_income_tax_rate: Marginal tax on investment income.
|
||||
personal_withdrawal_pct: Fraction of interest withdrawn each cycle.
|
||||
"""
|
||||
# Canadian lotteries - NO TAX on winnings!
|
||||
net_amount = jackpot
|
||||
net_amount = jackpot # Tax-free!
|
||||
|
||||
# Split into investment and fun money
|
||||
investment_principal = net_amount * invest_percentage
|
||||
fun_money = net_amount * (1 - invest_percentage)
|
||||
|
||||
# Calculate cycles
|
||||
cycle_results = []
|
||||
principal = investment_principal
|
||||
total_personal_withdrawals = 0
|
||||
cycle_results, total_withdrawals, final_principal = _run_investment_cycles(
|
||||
investment_principal,
|
||||
annual_return,
|
||||
cycles,
|
||||
investment_income_tax_rate,
|
||||
personal_withdrawal_pct,
|
||||
)
|
||||
|
||||
for cycle in range(1, cycles + 1):
|
||||
# Interest for 90 days
|
||||
interest_earned = principal * annual_return * (90/365)
|
||||
|
||||
# Taxes on investment income (53.53%)
|
||||
taxes_owed = interest_earned * 0.5353
|
||||
|
||||
# Personal withdrawal (10% of interest)
|
||||
personal_withdrawal = interest_earned * 0.10
|
||||
|
||||
# Total withdrawal
|
||||
total_withdrawal = taxes_owed + personal_withdrawal
|
||||
|
||||
# Reinvestment
|
||||
reinvestment = interest_earned - total_withdrawal
|
||||
|
||||
# New principal
|
||||
new_principal = principal + reinvestment
|
||||
|
||||
total_personal_withdrawals += personal_withdrawal
|
||||
|
||||
cycle_results.append({
|
||||
'cycle': cycle,
|
||||
'principal_start': principal,
|
||||
'interest_earned': interest_earned,
|
||||
'taxes_owed': taxes_owed,
|
||||
'personal_withdrawal': personal_withdrawal,
|
||||
'total_withdrawal': total_withdrawal,
|
||||
'reinvestment': reinvestment,
|
||||
'principal_end': new_principal
|
||||
})
|
||||
|
||||
principal = new_principal
|
||||
|
||||
# Calculate daily income
|
||||
net_daily_income = (investment_principal * annual_return * 0.5353) / 365
|
||||
net_daily_income = (investment_principal * annual_return * (1 - investment_income_tax_rate)) / 365
|
||||
|
||||
return {
|
||||
'country': 'Canada',
|
||||
'original_jackpot': jackpot,
|
||||
'net_amount_cad': net_amount,
|
||||
'investment_principal': investment_principal,
|
||||
'fun_money': fun_money,
|
||||
'net_daily_income': net_daily_income,
|
||||
'annual_income': net_daily_income * 365,
|
||||
'total_personal_withdrawals': total_personal_withdrawals,
|
||||
'final_principal': principal,
|
||||
'cycles': cycle_results
|
||||
"country": "Canada",
|
||||
"originalJackpot": jackpot,
|
||||
"netAmountCad": net_amount,
|
||||
"investmentPrincipal": investment_principal,
|
||||
"funMoney": fun_money,
|
||||
"netDailyIncome": net_daily_income,
|
||||
"annualIncome": net_daily_income * 365,
|
||||
"totalPersonalWithdrawals": total_withdrawals,
|
||||
"finalPrincipal": final_principal,
|
||||
"cycles": cycle_results,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test with current jackpots
|
||||
print("=" * 80)
|
||||
print("US LOTTERY - MEGA MILLIONS ($547M)")
|
||||
print("=" * 80)
|
||||
us_result = calculate_us_lottery(547_000_000)
|
||||
print(f"Original Jackpot: ${us_result['original_jackpot']:,.0f}")
|
||||
print(f"Cash Sum (52%): ${us_result['cash_sum']:,.0f}")
|
||||
print(f"After Taxes (USD): ${us_result['net_amount_usd']:,.0f}")
|
||||
print(f"After Taxes (CAD): ${us_result['net_amount_cad']:,.0f}")
|
||||
print(f"Investment (90%): ${us_result['investment_principal']:,.0f}")
|
||||
print(f"Fun Money (10%): ${us_result['fun_money']:,.0f}")
|
||||
print(f"Daily Income: ${us_result['net_daily_income']:,.2f}")
|
||||
print(f"Annual Income: ${us_result['annual_income']:,.2f}")
|
||||
print(f"Final Principal (after 8 cycles): ${us_result['final_principal']:,.0f}")
|
||||
# ---------------------------------------------------------------------------
|
||||
# Break-even calculator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("CANADIAN LOTTERY - LOTTO 6/49 ($32M CAD)")
|
||||
print("=" * 80)
|
||||
can_result = calculate_canadian_lottery(32_000_000)
|
||||
print(f"Original Jackpot (TAX FREE!): ${can_result['original_jackpot']:,.0f}")
|
||||
print(f"Investment (90%): ${can_result['investment_principal']:,.0f}")
|
||||
print(f"Fun Money (10%): ${can_result['fun_money']:,.0f}")
|
||||
print(f"Daily Income: ${can_result['net_daily_income']:,.2f}")
|
||||
print(f"Annual Income: ${can_result['annual_income']:,.2f}")
|
||||
print(f"Final Principal (after 8 cycles): ${can_result['final_principal']:,.0f}")
|
||||
def calculate_break_even(
|
||||
odds: int,
|
||||
ticket_cost: float,
|
||||
country: str = "us",
|
||||
*,
|
||||
lump_sum_rate: float = 0.52,
|
||||
federal_tax_rate: float = 0.37,
|
||||
state_tax_rate: float = 0.055,
|
||||
) -> dict:
|
||||
"""Calculate the jackpot where expected value >= ticket cost.
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("COMPARISON")
|
||||
print("=" * 80)
|
||||
print(f"US ($547M) - You keep: ${us_result['net_amount_cad']:,.0f} CAD after taxes")
|
||||
print(f"Canadian ($32M) - You keep: ${can_result['net_amount_cad']:,.0f} CAD (NO TAXES!)")
|
||||
print(f"\nUS Daily Income: ${us_result['net_daily_income']:,.2f}")
|
||||
print(f"Canadian Daily Income: ${can_result['net_daily_income']:,.2f}")
|
||||
For US lotteries the take-home fraction is::
|
||||
|
||||
lump_sum_rate * (1 - federal_tax_rate - state_tax_rate)
|
||||
|
||||
For Canadian lotteries the full jackpot is kept (tax-free).
|
||||
|
||||
Returns a dict with the break-even jackpot and supporting details.
|
||||
"""
|
||||
if country == "us":
|
||||
take_home_fraction = lump_sum_rate * (1 - federal_tax_rate - state_tax_rate)
|
||||
else:
|
||||
take_home_fraction = 1.0
|
||||
|
||||
# EV = (jackpot * take_home_fraction) / odds >= ticket_cost
|
||||
# => jackpot >= ticket_cost * odds / take_home_fraction
|
||||
break_even_jackpot = (ticket_cost * odds) / take_home_fraction
|
||||
probability = 1 / odds
|
||||
|
||||
return {
|
||||
"breakEvenJackpot": break_even_jackpot,
|
||||
"takeHomeFraction": take_home_fraction,
|
||||
"odds": odds,
|
||||
"probability": probability,
|
||||
"ticketCost": ticket_cost,
|
||||
"expectedValueAtBreakEven": probability * break_even_jackpot * take_home_fraction,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Annuity calculator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def calculate_annuity(
|
||||
jackpot: float,
|
||||
country: str = "us",
|
||||
years: int = 30,
|
||||
annual_increase: float = 0.05,
|
||||
*,
|
||||
federal_tax_rate: float = 0.37,
|
||||
state_tax_rate: float = 0.055,
|
||||
) -> dict:
|
||||
"""Calculate a multi-year annuity payout schedule.
|
||||
|
||||
Powerball / Mega Millions annuities pay an initial amount then increase
|
||||
each year by *annual_increase* (typically 5%).
|
||||
|
||||
Returns yearly pre-tax and after-tax amounts plus totals.
|
||||
"""
|
||||
# Calculate initial annual payment using geometric series:
|
||||
# jackpot = payment * sum((1 + r)^k) for k=0..years-1
|
||||
# = payment * ((1+r)^years - 1) / r
|
||||
if annual_increase > 0:
|
||||
geo_sum = ((1 + annual_increase) ** years - 1) / annual_increase
|
||||
else:
|
||||
geo_sum = float(years)
|
||||
|
||||
initial_payment = jackpot / geo_sum
|
||||
|
||||
schedule: list[dict] = []
|
||||
total_pre_tax = 0.0
|
||||
total_after_tax = 0.0
|
||||
|
||||
for year in range(1, years + 1):
|
||||
pre_tax = initial_payment * (1 + annual_increase) ** (year - 1)
|
||||
tax = pre_tax * (federal_tax_rate + state_tax_rate) if country == "us" else 0.0
|
||||
after_tax = pre_tax - tax
|
||||
total_pre_tax += pre_tax
|
||||
total_after_tax += after_tax
|
||||
|
||||
schedule.append(
|
||||
{
|
||||
"year": year,
|
||||
"preTax": pre_tax,
|
||||
"tax": tax,
|
||||
"afterTax": after_tax,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"jackpot": jackpot,
|
||||
"country": country,
|
||||
"years": years,
|
||||
"annualIncrease": annual_increase,
|
||||
"initialPayment": initial_payment,
|
||||
"totalPreTax": total_pre_tax,
|
||||
"totalAfterTax": total_after_tax,
|
||||
"schedule": schedule,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Group play calculator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def calculate_group_split(
|
||||
jackpot: float,
|
||||
members: int = 2,
|
||||
shares: list[float] | None = None,
|
||||
country: str = "us",
|
||||
*,
|
||||
lump_sum_rate: float = 0.52,
|
||||
federal_tax_rate: float = 0.37,
|
||||
state_tax_rate: float = 0.055,
|
||||
usd_cad_rate: float = 1.35,
|
||||
investment_income_tax_rate: float = 0.5353,
|
||||
personal_withdrawal_pct: float = 0.10,
|
||||
) -> dict:
|
||||
"""Split lottery winnings among *members* with optional custom shares.
|
||||
|
||||
If *shares* is None every member gets an equal share. Otherwise *shares*
|
||||
must be a list of floats summing to ~1.0 with length == *members*.
|
||||
"""
|
||||
# Normalise shares
|
||||
if shares is None:
|
||||
share_list = [1.0 / members] * members
|
||||
else:
|
||||
if len(shares) != members:
|
||||
share_list = [1.0 / members] * members
|
||||
else:
|
||||
total = sum(shares)
|
||||
share_list = [s / total for s in shares] if total > 0 else [1.0 / members] * members
|
||||
|
||||
member_results: list[dict] = []
|
||||
|
||||
for i, share in enumerate(share_list):
|
||||
member_jackpot = jackpot * share
|
||||
if country == "us":
|
||||
calc = calculate_us_lottery(
|
||||
member_jackpot,
|
||||
lump_sum_rate=lump_sum_rate,
|
||||
federal_tax_rate=federal_tax_rate,
|
||||
state_tax_rate=state_tax_rate,
|
||||
usd_cad_rate=usd_cad_rate,
|
||||
investment_income_tax_rate=investment_income_tax_rate,
|
||||
personal_withdrawal_pct=personal_withdrawal_pct,
|
||||
)
|
||||
else:
|
||||
calc = calculate_canadian_lottery(
|
||||
member_jackpot,
|
||||
investment_income_tax_rate=investment_income_tax_rate,
|
||||
personal_withdrawal_pct=personal_withdrawal_pct,
|
||||
)
|
||||
|
||||
member_results.append(
|
||||
{
|
||||
"member": i + 1,
|
||||
"share": share,
|
||||
"jackpotShare": member_jackpot,
|
||||
"calculation": calc,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"originalJackpot": jackpot,
|
||||
"members": members,
|
||||
"shares": share_list,
|
||||
"country": country,
|
||||
"memberResults": member_results,
|
||||
}
|
||||
|
||||
Binary file not shown.
77
nginx.conf
Normal file
77
nginx.conf
Normal file
@@ -0,0 +1,77 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
upstream backend {
|
||||
server backend:5000;
|
||||
}
|
||||
|
||||
upstream frontend {
|
||||
server frontend:3000;
|
||||
}
|
||||
|
||||
# Rate limiting zone
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Redirect HTTP to HTTPS (uncomment when SSL is configured)
|
||||
# return 301 https://$host$request_uri;
|
||||
|
||||
# API reverse proxy
|
||||
location /api/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# Frontend
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location /_next/static/ {
|
||||
proxy_pass http://frontend;
|
||||
proxy_cache_valid 200 365d;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server (uncomment when SSL certificates are in ./ssl/)
|
||||
# server {
|
||||
# listen 443 ssl;
|
||||
# server_name your-domain.com;
|
||||
#
|
||||
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
#
|
||||
# location /api/ {
|
||||
# limit_req zone=api burst=20 nodelay;
|
||||
# proxy_pass http://backend;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# }
|
||||
#
|
||||
# location / {
|
||||
# proxy_pass http://frontend;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# }
|
||||
# }
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
37
pyproject.toml
Normal file
37
pyproject.toml
Normal file
@@ -0,0 +1,37 @@
|
||||
[project]
|
||||
name = "lottery-tracker"
|
||||
version = "2.0.0"
|
||||
description = "Lottery Investment Calculator — jackpots, tax analysis, and investment projections"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"UP", # pyupgrade
|
||||
"B", # bugbear
|
||||
"SIM", # simplify
|
||||
"RUF", # ruff-specific
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line too long (handled by formatter)
|
||||
"B008", # do not perform function calls in argument defaults
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["config", "scrapers", "lottery_calculator"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = "-q --tb=short"
|
||||
@@ -1,31 +0,0 @@
|
||||
import openpyxl
|
||||
import pandas as pd
|
||||
|
||||
# Load the workbook
|
||||
wb = openpyxl.load_workbook('Max.xlsx')
|
||||
print(f"Sheets: {wb.sheetnames}\n")
|
||||
|
||||
# Read each sheet
|
||||
for sheet_name in wb.sheetnames:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"SHEET: {sheet_name}")
|
||||
print('='*60)
|
||||
ws = wb[sheet_name]
|
||||
|
||||
# Print first 30 rows
|
||||
for i, row in enumerate(ws.iter_rows(values_only=True), 1):
|
||||
if any(cell is not None for cell in row): # Skip completely empty rows
|
||||
print(f"Row {i}: {row}")
|
||||
if i >= 30:
|
||||
break
|
||||
|
||||
print("\n\nNow using pandas for better formatting:")
|
||||
print("="*60)
|
||||
|
||||
# Try reading with pandas
|
||||
for sheet_name in wb.sheetnames:
|
||||
print(f"\n\nSheet: {sheet_name}")
|
||||
print("-"*60)
|
||||
df = pd.read_excel('Max.xlsx', sheet_name=sheet_name)
|
||||
print(df.head(20))
|
||||
print(f"\nColumns: {df.columns.tolist()}")
|
||||
6
requirements-dev.txt
Normal file
6
requirements-dev.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
-r requirements.txt
|
||||
pytest==8.3.4
|
||||
pytest-mock==3.14.0
|
||||
pytest-cov==6.0.0
|
||||
httpx==0.28.1
|
||||
ruff==0.9.4
|
||||
@@ -1,9 +1,9 @@
|
||||
flask
|
||||
flask-cors
|
||||
requests
|
||||
beautifulsoup4
|
||||
playwright
|
||||
urllib3
|
||||
openpyxl
|
||||
pandas
|
||||
schedule
|
||||
flask==3.1.0
|
||||
flask-cors==5.0.1
|
||||
requests==2.32.3
|
||||
beautifulsoup4==4.12.3
|
||||
playwright==1.49.1
|
||||
gunicorn==23.0.0
|
||||
cachetools==5.5.1
|
||||
python-dotenv==1.0.1
|
||||
certifi==2024.12.14
|
||||
|
||||
191
scrapers.py
Normal file
191
scrapers.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Unified lottery jackpot scrapers with TTL caching.
|
||||
|
||||
Consolidates all scraping logic that was previously duplicated across
|
||||
app.py, email_sender.py, and ``import requests.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from config import load_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Browser-like request headers
|
||||
# ---------------------------------------------------------------------------
|
||||
HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": (
|
||||
"text/html,application/xhtml+xml,application/xml;"
|
||||
"q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
|
||||
),
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Cache-Control": "max-age=0",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simple TTL cache
|
||||
# ---------------------------------------------------------------------------
|
||||
_cache: dict[str, tuple[float, object]] = {}
|
||||
|
||||
|
||||
def _get_cached(key: str, ttl: int) -> object | None:
|
||||
"""Return cached value if it exists and hasn't expired."""
|
||||
entry = _cache.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
ts, value = entry
|
||||
if time.time() - ts > ttl:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _set_cached(key: str, value: object) -> None:
|
||||
_cache[key] = (time.time(), value)
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Clear the scraper cache (useful for testing or forcing refresh)."""
|
||||
_cache.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Individual scrapers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_jackpot_from_lotto_net(html: str) -> float | None:
|
||||
"""Extract the *Next Jackpot* dollar amount from a lotto.net page."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
text = soup.get_text()
|
||||
lines = text.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if "Next Jackpot" in line and i + 1 < len(lines):
|
||||
next_line = lines[i + 1].strip()
|
||||
if "$" in next_line:
|
||||
match = re.search(r"\$(\d+(?:,\d+)?(?:\.\d+)?)", next_line)
|
||||
if match:
|
||||
value = float(match.group(1).replace(",", ""))
|
||||
if "Billion" in next_line:
|
||||
return value * 1_000_000_000
|
||||
if "Million" in next_line:
|
||||
return value * 1_000_000
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def scrape_powerball(url: str | None = None) -> float | None:
|
||||
"""Scrape the current Powerball jackpot from lotto.net."""
|
||||
cfg = load_config()
|
||||
target = url or cfg.urls.powerball
|
||||
try:
|
||||
resp = requests.get(target, timeout=15, headers=HEADERS)
|
||||
resp.raise_for_status()
|
||||
return _parse_jackpot_from_lotto_net(resp.text)
|
||||
except Exception:
|
||||
logger.exception("Failed to scrape Powerball from %s", target)
|
||||
return None
|
||||
|
||||
|
||||
def scrape_mega_millions(url: str | None = None) -> float | None:
|
||||
"""Scrape the current Mega Millions jackpot from lotto.net."""
|
||||
cfg = load_config()
|
||||
target = url or cfg.urls.mega_millions
|
||||
try:
|
||||
resp = requests.get(target, timeout=15, headers=HEADERS)
|
||||
resp.raise_for_status()
|
||||
return _parse_jackpot_from_lotto_net(resp.text)
|
||||
except Exception:
|
||||
logger.exception("Failed to scrape Mega Millions from %s", target)
|
||||
return None
|
||||
|
||||
|
||||
def scrape_canadian_lotteries(url: str | None = None) -> dict[str, float | None]:
|
||||
"""Scrape Lotto Max and Lotto 6/49 from OLG using Playwright."""
|
||||
cfg = load_config()
|
||||
target = url or cfg.urls.olg
|
||||
results: dict[str, float | None] = {"lottoMax": None, "lotto649": None}
|
||||
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
page.goto(target, wait_until="networkidle", timeout=30000)
|
||||
page.wait_for_timeout(3000)
|
||||
content = page.content()
|
||||
browser.close()
|
||||
|
||||
# Lotto Max
|
||||
max_match = re.search(
|
||||
r"LOTTO\s*MAX(?:(?!LOTTO\s*6/49).)*?\$\s*([\d.,]+)\s*Million",
|
||||
content,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if max_match:
|
||||
results["lottoMax"] = float(max_match.group(1).replace(",", "")) * 1_000_000
|
||||
|
||||
# Lotto 6/49
|
||||
match_649 = re.search(
|
||||
r"LOTTO\s*6/49(?:(?!LOTTO\s*MAX).)*?\$\s*([\d.,]+)\s*Million",
|
||||
content,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if match_649:
|
||||
results["lotto649"] = float(match_649.group(1).replace(",", "")) * 1_000_000
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to scrape Canadian lotteries from %s", target)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aggregated fetchers (with cache)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_all_jackpots(*, force_refresh: bool = False) -> dict:
|
||||
"""Return all four lottery jackpots, using cache when available.
|
||||
|
||||
Returns::
|
||||
|
||||
{
|
||||
"us": {"powerball": float|None, "megaMillions": float|None},
|
||||
"canadian": {"lottoMax": float|None, "lotto649": float|None},
|
||||
}
|
||||
"""
|
||||
cfg = load_config()
|
||||
cache_key = "all_jackpots"
|
||||
|
||||
if not force_refresh:
|
||||
cached = _get_cached(cache_key, cfg.cache_ttl)
|
||||
if cached is not None:
|
||||
return cached # type: ignore[return-value]
|
||||
|
||||
pb = scrape_powerball()
|
||||
mm = scrape_mega_millions()
|
||||
canadian = scrape_canadian_lotteries()
|
||||
|
||||
result = {
|
||||
"us": {"powerball": pb, "megaMillions": mm},
|
||||
"canadian": canadian,
|
||||
}
|
||||
_set_cached(cache_key, result)
|
||||
return result
|
||||
@@ -1,137 +0,0 @@
|
||||
"""
|
||||
Secure email sender that prompts for password instead of storing it.
|
||||
This version is safer and works without App Passwords.
|
||||
"""
|
||||
import asyncio
|
||||
from email_sender import (
|
||||
get_powerball,
|
||||
get_mega_millions,
|
||||
get_canadian_lotteries,
|
||||
create_email_html,
|
||||
format_currency
|
||||
)
|
||||
from datetime import datetime
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import getpass
|
||||
|
||||
def send_email_secure(sender_email, sender_password, recipient_email, subject, html_content):
|
||||
"""Send email with provided credentials"""
|
||||
try:
|
||||
# Create message
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = sender_email
|
||||
msg['To'] = recipient_email
|
||||
|
||||
# Attach HTML content
|
||||
html_part = MIMEText(html_content, 'html')
|
||||
msg.attach(html_part)
|
||||
|
||||
# Try Gmail first
|
||||
try:
|
||||
print(" Trying Gmail SMTP...")
|
||||
with smtplib.SMTP('smtp.gmail.com', 587) as server:
|
||||
server.starttls()
|
||||
server.login(sender_email, sender_password)
|
||||
server.send_message(msg)
|
||||
print(f"✅ Email sent successfully via Gmail!")
|
||||
return True
|
||||
except Exception as gmail_error:
|
||||
print(f" Gmail failed: {gmail_error}")
|
||||
|
||||
# Try alternative method - Gmail SSL port
|
||||
try:
|
||||
print(" Trying Gmail SSL (port 465)...")
|
||||
with smtplib.SMTP_SSL('smtp.gmail.com', 465) as server:
|
||||
server.login(sender_email, sender_password)
|
||||
server.send_message(msg)
|
||||
print(f"✅ Email sent successfully via Gmail SSL!")
|
||||
return True
|
||||
except Exception as ssl_error:
|
||||
print(f" Gmail SSL also failed: {ssl_error}")
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"❌ Error sending email: {e}")
|
||||
print("\n⚠️ Common issues:")
|
||||
print(" 1. Gmail requires 2-Step Verification + App Password")
|
||||
print(" 2. Check if 'Less secure app access' is enabled (not recommended)")
|
||||
print(" 3. Verify your email and password are correct")
|
||||
return False
|
||||
|
||||
def send_lottery_email():
|
||||
"""Fetch jackpots and send email with secure password prompt"""
|
||||
print("\n" + "="*60)
|
||||
print("🎰 LOTTERY JACKPOT EMAIL SENDER")
|
||||
print("="*60)
|
||||
|
||||
# Email configuration
|
||||
sender_email = "mblanke@gmail.com"
|
||||
recipient_email = "mblanke@gmail.com"
|
||||
|
||||
print(f"\n📧 Email will be sent from/to: {sender_email}")
|
||||
print("\n🔐 Please enter your Gmail password:")
|
||||
print(" (Note: Gmail may require an App Password if you have 2FA enabled)")
|
||||
|
||||
# Securely prompt for password (won't show on screen)
|
||||
sender_password = getpass.getpass(" Password: ")
|
||||
|
||||
if not sender_password:
|
||||
print("❌ No password provided. Exiting.")
|
||||
return
|
||||
|
||||
print("\n" + "-"*60)
|
||||
print("📊 Fetching lottery jackpots...")
|
||||
print("-"*60)
|
||||
|
||||
# Get US lotteries
|
||||
print("\n🇺🇸 US Lotteries:")
|
||||
print(" Fetching Powerball...")
|
||||
powerball = get_powerball()
|
||||
print(f" ✓ Powerball: {format_currency(powerball)}")
|
||||
|
||||
print(" Fetching Mega Millions...")
|
||||
mega_millions = get_mega_millions()
|
||||
print(f" ✓ Mega Millions: {format_currency(mega_millions)}")
|
||||
|
||||
# Get Canadian lotteries
|
||||
print("\n🇨🇦 Canadian Lotteries:")
|
||||
print(" Fetching Lotto Max and Lotto 6/49...")
|
||||
lotto_max, lotto_649 = asyncio.run(get_canadian_lotteries())
|
||||
print(f" ✓ Lotto Max: {format_currency(lotto_max)}")
|
||||
print(f" ✓ Lotto 6/49: {format_currency(lotto_649)}")
|
||||
|
||||
# Create email
|
||||
print("\n" + "-"*60)
|
||||
print("📧 Creating email...")
|
||||
print("-"*60)
|
||||
subject = f"🎰 Lottery Report - {datetime.now().strftime('%B %d, %Y')}"
|
||||
html_content = create_email_html(powerball, mega_millions, lotto_max, lotto_649)
|
||||
print(" ✓ Email content created")
|
||||
|
||||
# Send email
|
||||
print("\n📤 Sending email...")
|
||||
success = send_email_secure(sender_email, sender_password, recipient_email, subject, html_content)
|
||||
|
||||
if success:
|
||||
print("\n" + "="*60)
|
||||
print("✅ SUCCESS!")
|
||||
print("="*60)
|
||||
print(f"📧 Check your inbox at: {recipient_email}")
|
||||
print("💡 The email includes all current jackpot amounts")
|
||||
print(" with beautiful HTML formatting!")
|
||||
else:
|
||||
print("\n" + "="*60)
|
||||
print("❌ FAILED!")
|
||||
print("="*60)
|
||||
print("\n🔧 Options to fix:")
|
||||
print(" 1. Enable 2-Step Verification in Gmail")
|
||||
print(" 2. Generate App Password: https://myaccount.google.com/apppasswords")
|
||||
print(" 3. Use the App Password instead of regular password")
|
||||
print("\n Alternative: Use a different email service (Outlook, Yahoo, etc.)")
|
||||
|
||||
print("\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
send_lottery_email()
|
||||
17
ssl/README.md
Normal file
17
ssl/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# SSL Certificate Setup
|
||||
#
|
||||
# Place your SSL certificates in this directory:
|
||||
# ssl/cert.pem — your certificate (or fullchain)
|
||||
# ssl/key.pem — your private key
|
||||
#
|
||||
# To generate self-signed certs for local testing:
|
||||
# openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
# -keyout ssl/key.pem -out ssl/cert.pem \
|
||||
# -subj "/CN=localhost"
|
||||
#
|
||||
# For production, use Let's Encrypt:
|
||||
# certbot certonly --standalone -d your-domain.com
|
||||
# cp /etc/letsencrypt/live/your-domain.com/fullchain.pem ssl/cert.pem
|
||||
# cp /etc/letsencrypt/live/your-domain.com/privkey.pem ssl/key.pem
|
||||
#
|
||||
# Then uncomment the HTTPS server block in nginx.conf.
|
||||
@@ -1,89 +0,0 @@
|
||||
"""
|
||||
Quick test script to send a lottery jackpot email immediately.
|
||||
Use this to verify your email configuration before scheduling.
|
||||
"""
|
||||
import asyncio
|
||||
from email_sender import (
|
||||
get_powerball,
|
||||
get_mega_millions,
|
||||
get_canadian_lotteries,
|
||||
create_email_html,
|
||||
send_email,
|
||||
format_currency,
|
||||
EMAIL_CONFIG
|
||||
)
|
||||
from datetime import datetime
|
||||
|
||||
def test_email():
|
||||
"""Test the email sender by sending immediately"""
|
||||
print("\n" + "="*60)
|
||||
print("🧪 TESTING LOTTERY EMAIL SENDER")
|
||||
print("="*60)
|
||||
|
||||
# Display current configuration
|
||||
print(f"\n📧 Email Configuration:")
|
||||
print(f" From: {EMAIL_CONFIG['sender_email']}")
|
||||
print(f" To: {EMAIL_CONFIG['recipient_email']}")
|
||||
print(f" SMTP Server: {EMAIL_CONFIG['smtp_server']}:{EMAIL_CONFIG['smtp_port']}")
|
||||
|
||||
if EMAIL_CONFIG['sender_email'] == 'your-email@gmail.com':
|
||||
print("\n⚠️ WARNING: You need to update EMAIL_CONFIG in email_sender.py!")
|
||||
print(" Please edit the file and add your email credentials.")
|
||||
print(" See EMAIL_SETUP.md for instructions.")
|
||||
return
|
||||
|
||||
print("\n" + "-"*60)
|
||||
print("📊 Fetching lottery jackpots...")
|
||||
print("-"*60)
|
||||
|
||||
# Get US lotteries
|
||||
print("\n🇺🇸 US Lotteries:")
|
||||
print(" Fetching Powerball...")
|
||||
powerball = get_powerball()
|
||||
print(f" ✓ Powerball: {format_currency(powerball)}")
|
||||
|
||||
print(" Fetching Mega Millions...")
|
||||
mega_millions = get_mega_millions()
|
||||
print(f" ✓ Mega Millions: {format_currency(mega_millions)}")
|
||||
|
||||
# Get Canadian lotteries
|
||||
print("\n🇨🇦 Canadian Lotteries:")
|
||||
print(" Fetching Lotto Max and Lotto 6/49...")
|
||||
lotto_max, lotto_649 = asyncio.run(get_canadian_lotteries())
|
||||
print(f" ✓ Lotto Max: {format_currency(lotto_max)}")
|
||||
print(f" ✓ Lotto 6/49: {format_currency(lotto_649)}")
|
||||
|
||||
# Create email
|
||||
print("\n" + "-"*60)
|
||||
print("📧 Creating email...")
|
||||
print("-"*60)
|
||||
subject = f"🎰 TEST - Lottery Report - {datetime.now().strftime('%B %d, %Y')}"
|
||||
html_content = create_email_html(powerball, mega_millions, lotto_max, lotto_649)
|
||||
print(" ✓ Email content created")
|
||||
|
||||
# Send email
|
||||
print("\n📤 Sending email...")
|
||||
success = send_email(subject, html_content)
|
||||
|
||||
if success:
|
||||
print("\n" + "="*60)
|
||||
print("✅ TEST SUCCESSFUL!")
|
||||
print("="*60)
|
||||
print(f"📧 Check your inbox at: {EMAIL_CONFIG['recipient_email']}")
|
||||
print("💡 If everything looks good, you can run email_sender.py")
|
||||
print(" to schedule daily emails at 7:00 AM")
|
||||
else:
|
||||
print("\n" + "="*60)
|
||||
print("❌ TEST FAILED!")
|
||||
print("="*60)
|
||||
print("🔍 Troubleshooting tips:")
|
||||
print(" 1. Check your email and password in EMAIL_CONFIG")
|
||||
print(" 2. For Gmail, use an App Password (not your regular password)")
|
||||
print(" 3. Verify SMTP server and port are correct")
|
||||
print(" 4. Check your internet connection")
|
||||
print(" 5. See EMAIL_SETUP.md for detailed instructions")
|
||||
|
||||
print("\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_email()
|
||||
21
tests/conftest.py
Normal file
21
tests/conftest.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Shared pytest fixtures for the Lottery Tracker test suite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app import create_app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app():
|
||||
"""Create a test Flask app."""
|
||||
application = create_app()
|
||||
application.config["TESTING"] = True
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(app):
|
||||
"""Flask test client."""
|
||||
return app.test_client()
|
||||
215
tests/test_api.py
Normal file
215
tests/test_api.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""Integration tests for Flask API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/health
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_health(client):
|
||||
resp = client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["status"] == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/jackpots
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("app.get_all_jackpots")
|
||||
def test_jackpots(mock_get, client):
|
||||
mock_get.return_value = {
|
||||
"us": {"powerball": 500_000_000, "megaMillions": 300_000_000},
|
||||
"canadian": {"lottoMax": 70_000_000, "lotto649": 20_000_000},
|
||||
}
|
||||
resp = client.get("/api/jackpots")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["us"]["powerball"] == 500_000_000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/calculate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_calculate_us(client):
|
||||
resp = client.post(
|
||||
"/api/calculate",
|
||||
json={"jackpot": 100_000_000, "type": "us"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["country"] == "US"
|
||||
assert data["originalJackpot"] == 100_000_000
|
||||
|
||||
|
||||
def test_calculate_canadian(client):
|
||||
resp = client.post(
|
||||
"/api/calculate",
|
||||
json={"jackpot": 50_000_000, "type": "canadian"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["country"] == "Canada"
|
||||
|
||||
|
||||
def test_calculate_with_state(client):
|
||||
resp = client.post(
|
||||
"/api/calculate",
|
||||
json={"jackpot": 100_000_000, "type": "us", "state": "CA"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["stateTaxRate"] == 0.133 # California
|
||||
|
||||
|
||||
def test_calculate_missing_jackpot(client):
|
||||
resp = client.post("/api/calculate", json={"type": "us"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_calculate_bad_type(client):
|
||||
resp = client.post("/api/calculate", json={"jackpot": 100, "type": "mars"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_calculate_no_body(client):
|
||||
resp = client.post("/api/calculate")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/states
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_states_list(client):
|
||||
resp = client.get("/api/states")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert len(data) == 51 # 50 states + DC
|
||||
codes = [s["code"] for s in data]
|
||||
assert "CA" in codes
|
||||
assert "TX" in codes
|
||||
|
||||
|
||||
def test_state_by_code(client):
|
||||
resp = client.get("/api/states/NY")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["name"] == "New York"
|
||||
assert data["rate"] == 0.109
|
||||
|
||||
|
||||
def test_state_not_found(client):
|
||||
resp = client.get("/api/states/ZZ")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/compare
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("app.get_all_jackpots")
|
||||
def test_compare(mock_get, client):
|
||||
mock_get.return_value = {
|
||||
"us": {"powerball": 500_000_000, "megaMillions": 300_000_000},
|
||||
"canadian": {"lottoMax": 70_000_000, "lotto649": 20_000_000},
|
||||
}
|
||||
resp = client.get("/api/compare")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert len(data) == 4
|
||||
names = [d["name"] for d in data]
|
||||
assert "Powerball" in names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/calculate/breakeven
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_breakeven(client):
|
||||
resp = client.post(
|
||||
"/api/calculate/breakeven",
|
||||
json={"lottery": "powerball"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["breakEvenJackpot"] > 0
|
||||
assert data["lottery"] == "Powerball"
|
||||
|
||||
|
||||
def test_breakeven_unknown_lottery(client):
|
||||
resp = client.post(
|
||||
"/api/calculate/breakeven",
|
||||
json={"lottery": "nosuchlottery"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/calculate/annuity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_annuity(client):
|
||||
resp = client.post(
|
||||
"/api/calculate/annuity",
|
||||
json={"jackpot": 500_000_000, "type": "us", "years": 30},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert len(data["schedule"]) == 30
|
||||
|
||||
|
||||
def test_annuity_canadian(client):
|
||||
resp = client.post(
|
||||
"/api/calculate/annuity",
|
||||
json={"jackpot": 100_000_000, "type": "canadian"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["country"] == "canadian"
|
||||
assert data["schedule"][0]["tax"] == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/calculate/group
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_group_play(client):
|
||||
resp = client.post(
|
||||
"/api/calculate/group",
|
||||
json={"jackpot": 100_000_000, "members": 4, "type": "us"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["members"] == 4
|
||||
assert len(data["memberResults"]) == 4
|
||||
|
||||
|
||||
def test_group_custom_shares(client):
|
||||
resp = client.post(
|
||||
"/api/calculate/group",
|
||||
json={"jackpot": 100_000_000, "members": 2, "shares": [0.7, 0.3], "type": "canadian"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["memberResults"][0]["share"] == pytest.approx(0.7)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/odds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_odds(client):
|
||||
resp = client.get("/api/odds")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert len(data) == 4
|
||||
names = [d["name"] for d in data]
|
||||
assert "Powerball" in names
|
||||
assert "Mega Millions" in names
|
||||
47
tests/test_config.py
Normal file
47
tests/test_config.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Tests for config.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from config import LOTTERY_ODDS, STATE_TAX_RATES, load_config
|
||||
|
||||
|
||||
def test_all_states_present():
|
||||
assert len(STATE_TAX_RATES) == 51 # 50 states + DC
|
||||
|
||||
|
||||
def test_all_lottery_odds_present():
|
||||
assert "powerball" in LOTTERY_ODDS
|
||||
assert "megaMillions" in LOTTERY_ODDS
|
||||
assert "lottoMax" in LOTTERY_ODDS
|
||||
assert "lotto649" in LOTTERY_ODDS
|
||||
|
||||
|
||||
def test_load_config_defaults():
|
||||
cfg = load_config()
|
||||
assert cfg.debug is False
|
||||
assert cfg.port == 5000
|
||||
assert cfg.tax.lump_sum_rate == 0.52
|
||||
assert cfg.investment.cycles == 8
|
||||
|
||||
|
||||
@patch.dict(os.environ, {"FLASK_DEBUG": "true", "FLASK_PORT": "8080"})
|
||||
def test_load_config_from_env():
|
||||
cfg = load_config()
|
||||
assert cfg.debug is True
|
||||
assert cfg.port == 8080
|
||||
|
||||
|
||||
@patch.dict(os.environ, {"LUMP_SUM_RATE": "0.60"})
|
||||
def test_tax_config_from_env():
|
||||
cfg = load_config()
|
||||
assert cfg.tax.lump_sum_rate == 0.60
|
||||
|
||||
|
||||
def test_no_tax_states():
|
||||
"""Florida, Texas, Nevada, etc. should have 0% tax."""
|
||||
no_tax = ["AK", "FL", "NV", "NH", "SD", "TN", "TX", "WA", "WY"]
|
||||
for code in no_tax:
|
||||
assert STATE_TAX_RATES[code]["rate"] == 0.0, f"{code} should have 0% tax"
|
||||
207
tests/test_lottery_calculator.py
Normal file
207
tests/test_lottery_calculator.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""Unit tests for lottery_calculator.py — pure calculation logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from lottery_calculator import (
|
||||
calculate_annuity,
|
||||
calculate_break_even,
|
||||
calculate_canadian_lottery,
|
||||
calculate_group_split,
|
||||
calculate_us_lottery,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# calculate_us_lottery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCalculateUSLottery:
|
||||
"""Tests for the US lottery calculation."""
|
||||
|
||||
def test_basic_calculation(self):
|
||||
result = calculate_us_lottery(100_000_000)
|
||||
assert result["country"] == "US"
|
||||
assert result["originalJackpot"] == 100_000_000
|
||||
|
||||
def test_cash_sum_is_lump_sum_rate(self):
|
||||
result = calculate_us_lottery(100_000_000, lump_sum_rate=0.52)
|
||||
assert result["cashSum"] == pytest.approx(52_000_000)
|
||||
|
||||
def test_federal_tax_applied(self):
|
||||
result = calculate_us_lottery(100_000_000, lump_sum_rate=0.52, federal_tax_rate=0.37)
|
||||
assert result["federalTax"] == pytest.approx(52_000_000 * 0.37)
|
||||
|
||||
def test_state_tax_applied(self):
|
||||
result = calculate_us_lottery(100_000_000, state_tax_rate=0.10)
|
||||
assert result["stateTaxRate"] == 0.10
|
||||
|
||||
def test_net_amount_usd(self):
|
||||
result = calculate_us_lottery(
|
||||
100_000_000, lump_sum_rate=0.50, federal_tax_rate=0.30, state_tax_rate=0.05
|
||||
)
|
||||
expected = 50_000_000 * (1 - 0.30 - 0.05)
|
||||
assert result["netAmountUsd"] == pytest.approx(expected)
|
||||
|
||||
def test_cad_conversion(self):
|
||||
result = calculate_us_lottery(100_000_000, usd_cad_rate=1.40)
|
||||
assert result["netAmountCad"] == pytest.approx(result["netAmountUsd"] * 1.40)
|
||||
|
||||
def test_investment_split(self):
|
||||
result = calculate_us_lottery(100_000_000, invest_percentage=0.80)
|
||||
total = result["investmentPrincipal"] + result["funMoney"]
|
||||
assert total == pytest.approx(result["netAmountCad"])
|
||||
assert result["funMoney"] == pytest.approx(result["netAmountCad"] * 0.20)
|
||||
|
||||
def test_cycles_count(self):
|
||||
result = calculate_us_lottery(100_000_000, cycles=5)
|
||||
assert len(result["cycles"]) == 5
|
||||
|
||||
def test_principal_grows(self):
|
||||
result = calculate_us_lottery(100_000_000, cycles=4)
|
||||
for i in range(1, len(result["cycles"])):
|
||||
assert result["cycles"][i]["principalStart"] >= result["cycles"][i - 1]["principalStart"]
|
||||
|
||||
def test_final_principal_positive(self):
|
||||
result = calculate_us_lottery(500_000_000)
|
||||
assert result["finalPrincipal"] > 0
|
||||
|
||||
def test_daily_income_positive(self):
|
||||
result = calculate_us_lottery(500_000_000)
|
||||
assert result["netDailyIncome"] > 0
|
||||
|
||||
def test_zero_state_tax(self):
|
||||
"""Florida / Texas have 0% state tax."""
|
||||
result = calculate_us_lottery(100_000_000, state_tax_rate=0.0)
|
||||
assert result["stateTax"] == 0.0
|
||||
assert result["netAmountUsd"] > calculate_us_lottery(100_000_000, state_tax_rate=0.10)["netAmountUsd"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# calculate_canadian_lottery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCalculateCanadianLottery:
|
||||
"""Tests for Canadian lottery calculation (tax-free winnings)."""
|
||||
|
||||
def test_basic_calculation(self):
|
||||
result = calculate_canadian_lottery(50_000_000)
|
||||
assert result["country"] == "Canada"
|
||||
assert result["originalJackpot"] == 50_000_000
|
||||
|
||||
def test_tax_free(self):
|
||||
result = calculate_canadian_lottery(50_000_000)
|
||||
assert result["netAmountCad"] == 50_000_000
|
||||
|
||||
def test_investment_split(self):
|
||||
result = calculate_canadian_lottery(50_000_000, invest_percentage=0.90)
|
||||
assert result["investmentPrincipal"] == pytest.approx(45_000_000)
|
||||
assert result["funMoney"] == pytest.approx(5_000_000)
|
||||
|
||||
def test_cycles_count(self):
|
||||
result = calculate_canadian_lottery(50_000_000, cycles=3)
|
||||
assert len(result["cycles"]) == 3
|
||||
|
||||
def test_daily_income_positive(self):
|
||||
result = calculate_canadian_lottery(50_000_000)
|
||||
assert result["netDailyIncome"] > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# calculate_break_even
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCalculateBreakEven:
|
||||
"""Tests for the break-even expected-value calculator."""
|
||||
|
||||
def test_us_break_even(self):
|
||||
result = calculate_break_even(odds=292_201_338, ticket_cost=2.0, country="us")
|
||||
assert result["breakEvenJackpot"] > 0
|
||||
assert result["breakEvenJackpot"] > 292_201_338 # must be >> odds because of tax
|
||||
|
||||
def test_canadian_break_even(self):
|
||||
result = calculate_break_even(odds=13_983_816, ticket_cost=3.0, country="canadian")
|
||||
# Canadian take-home fraction = 1.0, so break-even = 3 * 13_983_816
|
||||
expected = 3.0 * 13_983_816
|
||||
assert result["breakEvenJackpot"] == pytest.approx(expected)
|
||||
|
||||
def test_ev_equals_ticket_cost(self):
|
||||
result = calculate_break_even(odds=100, ticket_cost=5.0, country="canadian")
|
||||
# EV at break even = probability * jackpot * 1.0 = 5.0
|
||||
assert result["expectedValueAtBreakEven"] == pytest.approx(5.0, rel=1e-6)
|
||||
|
||||
def test_higher_tax_needs_bigger_jackpot(self):
|
||||
r1 = calculate_break_even(odds=100, ticket_cost=2.0, country="us", state_tax_rate=0.0)
|
||||
r2 = calculate_break_even(odds=100, ticket_cost=2.0, country="us", state_tax_rate=0.10)
|
||||
assert r2["breakEvenJackpot"] > r1["breakEvenJackpot"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# calculate_annuity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCalculateAnnuity:
|
||||
"""Tests for the annuity payout schedule."""
|
||||
|
||||
def test_us_annuity_schedule_length(self):
|
||||
result = calculate_annuity(500_000_000, country="us", years=30)
|
||||
assert len(result["schedule"]) == 30
|
||||
|
||||
def test_canadian_no_tax(self):
|
||||
result = calculate_annuity(100_000_000, country="canadian", years=10, annual_increase=0.0)
|
||||
for entry in result["schedule"]:
|
||||
assert entry["tax"] == 0.0
|
||||
assert entry["afterTax"] == entry["preTax"]
|
||||
|
||||
def test_total_pretax_approximates_jackpot(self):
|
||||
result = calculate_annuity(100_000_000, country="canadian", years=30, annual_increase=0.05)
|
||||
assert result["totalPreTax"] == pytest.approx(100_000_000, rel=1e-6)
|
||||
|
||||
def test_payments_increase_annually(self):
|
||||
result = calculate_annuity(100_000_000, years=5, annual_increase=0.05)
|
||||
for i in range(1, len(result["schedule"])):
|
||||
assert result["schedule"][i]["preTax"] > result["schedule"][i - 1]["preTax"]
|
||||
|
||||
def test_zero_increase(self):
|
||||
result = calculate_annuity(100_000_000, years=5, annual_increase=0.0)
|
||||
payments = [e["preTax"] for e in result["schedule"]]
|
||||
assert all(p == pytest.approx(payments[0]) for p in payments)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# calculate_group_split
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCalculateGroupSplit:
|
||||
"""Tests for the group play calculator."""
|
||||
|
||||
def test_equal_split(self):
|
||||
result = calculate_group_split(100_000_000, members=4, country="canadian")
|
||||
assert len(result["memberResults"]) == 4
|
||||
for m in result["memberResults"]:
|
||||
assert m["jackpotShare"] == pytest.approx(25_000_000)
|
||||
|
||||
def test_custom_shares(self):
|
||||
shares = [0.5, 0.3, 0.2]
|
||||
result = calculate_group_split(100_000_000, members=3, shares=shares, country="canadian")
|
||||
assert result["memberResults"][0]["jackpotShare"] == pytest.approx(50_000_000)
|
||||
assert result["memberResults"][1]["jackpotShare"] == pytest.approx(30_000_000)
|
||||
assert result["memberResults"][2]["jackpotShare"] == pytest.approx(20_000_000)
|
||||
|
||||
def test_shares_normalize(self):
|
||||
"""Shares that don't sum to 1.0 should be normalised."""
|
||||
shares = [2, 2, 1] # sums to 5
|
||||
result = calculate_group_split(100_000_000, members=3, shares=shares, country="canadian")
|
||||
assert result["memberResults"][0]["share"] == pytest.approx(0.4)
|
||||
assert result["memberResults"][2]["share"] == pytest.approx(0.2)
|
||||
|
||||
def test_mismatched_shares_falls_back_to_equal(self):
|
||||
result = calculate_group_split(100_000_000, members=3, shares=[0.5, 0.5], country="canadian")
|
||||
for m in result["memberResults"]:
|
||||
assert m["share"] == pytest.approx(1 / 3)
|
||||
|
||||
def test_each_member_gets_calculation(self):
|
||||
result = calculate_group_split(100_000_000, members=2, country="us")
|
||||
for m in result["memberResults"]:
|
||||
assert "calculation" in m
|
||||
assert m["calculation"]["country"] == "US"
|
||||
111
tests/test_scrapers.py
Normal file
111
tests/test_scrapers.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Tests for scrapers.py — uses mocks to avoid real HTTP calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from scrapers import (
|
||||
_parse_jackpot_from_lotto_net,
|
||||
clear_cache,
|
||||
get_all_jackpots,
|
||||
scrape_mega_millions,
|
||||
scrape_powerball,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML parser unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
POWERBALL_HTML = """
|
||||
<html><body>
|
||||
<h2>Powerball</h2>
|
||||
<div>
|
||||
Next Jackpot
|
||||
$350 Million
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
BILLION_HTML = """
|
||||
<html><body>
|
||||
<div>
|
||||
Next Jackpot
|
||||
$1.5 Billion
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_jackpot_millions():
|
||||
result = _parse_jackpot_from_lotto_net(POWERBALL_HTML)
|
||||
assert result == 350_000_000
|
||||
|
||||
|
||||
def test_parse_jackpot_billions():
|
||||
result = _parse_jackpot_from_lotto_net(BILLION_HTML)
|
||||
assert result == 1_500_000_000
|
||||
|
||||
|
||||
def test_parse_jackpot_no_match():
|
||||
result = _parse_jackpot_from_lotto_net("<html><body>No jackpot here</body></html>")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scraper integration tests (mocked HTTP)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("scrapers.requests.get")
|
||||
def test_scrape_powerball_success(mock_get):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.text = POWERBALL_HTML
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
result = scrape_powerball()
|
||||
assert result == 350_000_000
|
||||
|
||||
|
||||
@patch("scrapers.requests.get")
|
||||
def test_scrape_powerball_failure(mock_get):
|
||||
mock_get.side_effect = Exception("Network error")
|
||||
result = scrape_powerball()
|
||||
assert result is None
|
||||
|
||||
|
||||
@patch("scrapers.requests.get")
|
||||
def test_scrape_mega_millions_success(mock_get):
|
||||
html = POWERBALL_HTML.replace("Powerball", "Mega Millions")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.text = html
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
result = scrape_mega_millions()
|
||||
assert result == 350_000_000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("scrapers.scrape_powerball", return_value=100_000_000)
|
||||
@patch("scrapers.scrape_mega_millions", return_value=200_000_000)
|
||||
@patch("scrapers.scrape_canadian_lotteries", return_value={"lottoMax": 50_000_000, "lotto649": 10_000_000})
|
||||
def test_get_all_jackpots_caches(mock_ca, mock_mm, mock_pb):
|
||||
clear_cache()
|
||||
r1 = get_all_jackpots()
|
||||
r2 = get_all_jackpots()
|
||||
# Second call should use cache — scrapers called only once
|
||||
assert mock_pb.call_count == 1
|
||||
assert r1 == r2
|
||||
|
||||
|
||||
@patch("scrapers.scrape_powerball", return_value=100_000_000)
|
||||
@patch("scrapers.scrape_mega_millions", return_value=200_000_000)
|
||||
@patch("scrapers.scrape_canadian_lotteries", return_value={"lottoMax": 50_000_000, "lotto649": 10_000_000})
|
||||
def test_get_all_jackpots_force_refresh(mock_ca, mock_mm, mock_pb):
|
||||
clear_cache()
|
||||
get_all_jackpots()
|
||||
get_all_jackpots(force_refresh=True)
|
||||
assert mock_pb.call_count == 2
|
||||
203
updates.md
Normal file
203
updates.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# Lottery Tracker — Update Log
|
||||
|
||||
## Overview
|
||||
|
||||
Complete codebase overhaul: cleanup, modernization, new features, and full Next.js frontend build.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Code Review & Cleanup
|
||||
|
||||
### Files Deleted
|
||||
- `email_sender.py` — Email sending logic (removed entire email system)
|
||||
- `send_email_now.py` — Manual email trigger
|
||||
- `test_email.py` — Email tests
|
||||
- `analyze_excel.py` — Excel analysis utility
|
||||
- `read_excel.py` — Excel reader utility
|
||||
- `import requests.py` — Misnamed utility script
|
||||
- `powerball_numbers.html` — Debug HTML file
|
||||
- `megamillions_debug.html` — Debug HTML file
|
||||
- `Dockerfile.email` — Email service Docker image
|
||||
- `EMAIL_SETUP.md` — Email configuration docs
|
||||
|
||||
### Issues Fixed
|
||||
- **Hardcoded credentials** removed (Gmail password in docker docs)
|
||||
- **`verify=False`** on all HTTP requests replaced with default SSL verification
|
||||
- **Code duplication** — scraping logic was copy-pasted across 3 files, consolidated into one
|
||||
- **No tests** — full test suite added
|
||||
- **No linting** — ruff configured
|
||||
- **Flask `debug=True`** hardcoded — replaced with env var
|
||||
- **Docker healthcheck** used `curl` (not installed in slim image) — switched to Python urllib
|
||||
- **`.dockerignore`** missing `.venv/` — build context was 456 MB, now much smaller
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Backend Rewrite
|
||||
|
||||
### New Modules
|
||||
|
||||
#### `config.py`
|
||||
- `AppConfig` dataclass with nested `ScraperURLs`, `TaxConfig`, `InvestmentDefaults`
|
||||
- `STATE_TAX_RATES` — all 51 US states + DC with 2024-2025 tax rates
|
||||
- `LOTTERY_ODDS` — odds and ticket costs for all 4 lotteries
|
||||
- `ANNUITY_YEARS`, `ANNUITY_ANNUAL_INCREASE` constants
|
||||
- `load_config()` reads from environment variables with sensible defaults
|
||||
|
||||
#### `scrapers.py`
|
||||
- Unified scraping with TTL cache (6-hour default via `cachetools`)
|
||||
- `scrape_powerball()` / `scrape_mega_millions()` — requests + BeautifulSoup from lotto.net
|
||||
- `scrape_canadian_lotteries()` — Playwright sync API from olg.ca
|
||||
- `get_all_jackpots()` — returns all 4 jackpots with cache
|
||||
- `clear_cache()` — force refresh
|
||||
|
||||
#### `app.py` (Rewritten)
|
||||
Flask app factory pattern (`create_app()`) with endpoints:
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/jackpots` | GET | Current jackpots (cached) |
|
||||
| `/api/jackpots/refresh` | POST | Force-refresh jackpots |
|
||||
| `/api/calculate` | POST | Full investment calculation with state tax |
|
||||
| `/api/states` | GET | All US states with tax rates |
|
||||
| `/api/states/<code>` | GET | Single state info |
|
||||
| `/api/compare` | GET | Side-by-side all 4 lotteries |
|
||||
| `/api/calculate/breakeven` | POST | Break-even jackpot analysis |
|
||||
| `/api/calculate/annuity` | POST | Annuity payment schedule |
|
||||
| `/api/calculate/group` | POST | Group play split |
|
||||
| `/api/odds` | GET | Probability data for all lotteries |
|
||||
| `/api/health` | GET | Health check |
|
||||
|
||||
#### `lottery_calculator.py` (Rewritten)
|
||||
Pure calculation functions with explicit parameters:
|
||||
- `_run_investment_cycles()` — shared 90-day reinvestment cycle logic
|
||||
- `calculate_us_lottery()` — lump sum + federal/state tax + CAD conversion + cycles
|
||||
- `calculate_canadian_lottery()` — tax-free + investment cycles
|
||||
- `calculate_break_even()` — EV formula: required jackpot for positive expected value
|
||||
- `calculate_annuity()` — 30-year schedule with configurable annual increase
|
||||
- `calculate_group_split()` — N-way split with custom share ratios
|
||||
|
||||
### Configuration Files
|
||||
- `requirements.txt` — pinned versions, removed openpyxl/pandas/schedule
|
||||
- `requirements-dev.txt` — pytest, pytest-mock, pytest-cov, httpx, ruff
|
||||
- `pyproject.toml` — ruff config (line-length=100, py311 target), pytest config
|
||||
- `.env.example` — all environment variables documented
|
||||
|
||||
### Test Suite (64 tests, all passing)
|
||||
- `tests/conftest.py` — Flask test fixtures
|
||||
- `tests/test_lottery_calculator.py` — ~25 tests (US calc, Canadian calc, break-even, annuity, group split)
|
||||
- `tests/test_api.py` — ~15 endpoint integration tests with mocked scrapers
|
||||
- `tests/test_config.py` — config loading, env override, state validation
|
||||
- `tests/test_scrapers.py` — parser tests, mocked HTTP, cache behavior
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Frontend (Next.js 15 + MUI 6)
|
||||
|
||||
### Scaffold
|
||||
- `frontend/package.json` — Next.js 15.1.4, MUI 6.3.1, Recharts 2.15, Axios
|
||||
- `frontend/tsconfig.json` — standard Next.js TypeScript config with `@/*` paths
|
||||
- `frontend/next.config.js` — standalone output, `/api/*` rewrite to backend
|
||||
- `frontend/.env.local` — `NEXT_PUBLIC_API_URL=http://localhost:5000`
|
||||
|
||||
### Shared Libraries
|
||||
- `frontend/lib/types.ts` — Full TypeScript interfaces (JackpotData, Calculation, StateInfo, BreakEvenResult, AnnuityResult, GroupResult, OddsInfo, etc.)
|
||||
- `frontend/lib/api.ts` — Typed Axios client with all API endpoint functions
|
||||
- `frontend/lib/format.ts` — `formatCurrency`, `formatCurrencyFull`, `formatPercent`, `formatNumber`
|
||||
- `frontend/lib/theme.ts` — Dark MUI theme (primary: `#4fc3f7`, background: `#0a1929`)
|
||||
|
||||
### Components
|
||||
- `frontend/components/ThemeRegistry.tsx` — MUI ThemeProvider wrapper with CssBaseline
|
||||
- `frontend/components/NavBar.tsx` — Sticky app bar with navigation links
|
||||
|
||||
### Pages (7 routes)
|
||||
|
||||
| Route | File | Description |
|
||||
|-------|------|-------------|
|
||||
| `/` | `app/page.tsx` | Home — jackpot cards grid (4 lotteries) + tool cards grid (6 tools) |
|
||||
| `/calculator` | `app/calculator/page.tsx` | Investment calculator — jackpot input, lottery type, state selector with tax rates, invest % slider, annual return slider, cycles, results summary, 90-day cycle table |
|
||||
| `/compare` | `app/compare/page.tsx` | Side-by-side comparison — state selector, 4 lottery cards with jackpot, odds, after-tax take-home, daily/annual income |
|
||||
| `/breakeven` | `app/breakeven/page.tsx` | Break-even analysis — lottery/state selectors, required jackpot, odds, probability, take-home fraction, expected value |
|
||||
| `/annuity` | `app/annuity/page.tsx` | Annuity calculator — jackpot, type, years, annual increase slider, summary cards, year-by-year payment schedule table |
|
||||
| `/group` | `app/group/page.tsx` | Group play — member count, custom share weights, per-member breakdown table (share %, jackpot share, after-tax, daily/annual income) |
|
||||
| `/odds` | `app/odds/page.tsx` | Probability display — odds, percentage, ticket cost, log-scale relative bar, "50% chance" ticket count |
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Docker & Infrastructure
|
||||
|
||||
### Docker Images Built
|
||||
| Image | Size | Description |
|
||||
|-------|------|-------------|
|
||||
| `lottery-tracker-backend` | 2.28 GB | Python 3.13-slim + Playwright Chromium + Gunicorn |
|
||||
| `lottery-tracker-frontend` | 285 MB | Node.js 20-alpine + Next.js standalone |
|
||||
|
||||
### Docker Fixes Applied
|
||||
1. **Removed `playwright install-deps chromium`** from Dockerfile.backend — system deps already installed manually; the auto-installer fails on newer Debian due to renamed font packages (`ttf-unifont` → `fonts-unifont`)
|
||||
2. **Generated `package-lock.json`** — required by `npm ci` in frontend Dockerfile
|
||||
3. **Created `frontend/public/`** directory with `manifest.json` — Dockerfile COPY step requires it
|
||||
4. **Added `.venv/` to `.dockerignore`** — was inflating build context
|
||||
5. **Removed `version: '3.8'`** warning — obsolete in modern Docker Compose
|
||||
6. **Removed email-scheduler service** from `docker-compose.yml`
|
||||
7. **Switched healthcheck** from `curl` to `python -c "import urllib.request; ..."`
|
||||
8. **Backend CMD** changed to gunicorn (2 workers, 120s timeout)
|
||||
|
||||
### MUI Grid Fix
|
||||
All 7 frontend pages used `<Grid size={{ xs: 12, sm: 6 }}>` syntax (Grid2 API), but imported `Grid` from `@mui/material` (legacy Grid v1). Fixed by changing imports to `Grid2 as Grid` in all pages.
|
||||
|
||||
### Running Containers
|
||||
| Container | Status | Port Mapping |
|
||||
|-----------|--------|-------------|
|
||||
| `lottery-backend` | Up (healthy) | `0.0.0.0:5000 → 5000` |
|
||||
| `lottery-frontend` | Up | `0.0.0.0:3003 → 3000` |
|
||||
|
||||
### Access URLs
|
||||
- **Frontend**: http://localhost:3003
|
||||
- **Backend API**: http://localhost:5000/api/health
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Documentation & Config
|
||||
|
||||
### Updated Files
|
||||
- **`.gitignore`** — added `.next/`, `*.xlsx`, `*.html`, `ssl/` certs, coverage outputs
|
||||
- **`AGENTS.md`** — filled in repo facts (Python 3.13/Flask + Next.js 15/MUI 6, pytest, ruff, docker compose)
|
||||
- **`DOCKER_README.md`** — rewritten: removed email references, removed hardcoded password, added `.env.example` workflow, config table, production deployment guide
|
||||
- **`DOCKER_QUICKSTART.md`** — rewritten: removed email service, updated container descriptions, simplified quick start
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
- **64/64 tests passing** (`pytest -q`)
|
||||
- **ruff lint clean** (`ruff check .` — all checks passed)
|
||||
- **Docker build successful** — both images built
|
||||
- **Containers running** — backend healthy, frontend serving
|
||||
|
||||
---
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ nginx (prod) │
|
||||
│ Rate limiting + SSL │
|
||||
├─────────────────┬───────────────────────────────┤
|
||||
│ Frontend │ Backend │
|
||||
│ Next.js 15 │ Flask 3.1 │
|
||||
│ MUI 6 │ Gunicorn │
|
||||
│ TypeScript │ Python 3.13 │
|
||||
│ Port 3000 │ Port 5000 │
|
||||
│ │ │
|
||||
│ 7 pages │ 11 API endpoints │
|
||||
│ Dark theme │ 4 lottery scrapers │
|
||||
│ Axios client │ TTL cache (6h) │
|
||||
│ │ 51 state tax rates │
|
||||
│ │ Playwright (Canadian) │
|
||||
└─────────────────┴───────────────────────────────┘
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
- **Backend**: Python 3.13, Flask 3.1, Gunicorn, BeautifulSoup, Playwright
|
||||
- **Frontend**: Next.js 15, TypeScript, Material-UI 6, Recharts, Axios
|
||||
- **Testing**: pytest (64 tests), ruff (lint)
|
||||
- **Docker**: Multi-container (backend + frontend + nginx for prod)
|
||||
- **Config**: Environment variables via `.env` + `python-dotenv`
|
||||
Reference in New Issue
Block a user