Files
Lottery-Tracker/app.py
2026-02-18 08:24:54 -05:00

444 lines
16 KiB
Python

"""
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
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 — restrict origins via env or allow all in dev
if cfg.allowed_origins == "*":
CORS(app)
else:
CORS(app, origins=[o.strip() for o in cfg.allowed_origins.split(",")])
# ------------------------------------------------------------------
# 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:
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
# ------------------------------------------------------------------
# Jackpot endpoints
# ------------------------------------------------------------------
@app.route("/api/jackpots", methods=["GET"])
def get_jackpots():
"""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)
@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"])
def calculate():
"""Calculate investment returns for a given jackpot."""
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")
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=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=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:
logger.exception("Calculation error")
return jsonify({"error": "Internal calculation error"}), 500
# ------------------------------------------------------------------
# State tax endpoints
# ------------------------------------------------------------------
@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", "version": "2.0.0"})
return app
# ---------------------------------------------------------------------------
# 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)