from __future__ import annotations from dataclasses import dataclass, field import os from pathlib import Path from app.core.competitions import COMPETITIONS DEFAULT_SPORT_KEYS = tuple(COMPETITIONS.keys()) MODEL_VERSION = "2.2-precision" VALID_ODDS_REGIONS = frozenset({"us", "us2", "uk", "au", "eu"}) def _default_data_dir() -> Path: """Prefer the persistent volume mounted by Hugging Face Spaces.""" configured = os.getenv("DATA_DIR", "").strip() if configured: return Path(configured).expanduser() persistent_root = Path("/data") if persistent_root.is_dir(): return persistent_root / "safe-bet-ai" return Path("/app/data") if Path("/app").is_dir() else Path("./data") def _int(name: str, default: int, minimum: int, maximum: int) -> int: try: value = int(os.getenv(name, str(default))) except ValueError: value = default return max(minimum, min(maximum, value)) def _float(name: str, default: float, minimum: float, maximum: float) -> float: try: value = float(os.getenv(name, str(default))) except ValueError: value = default return max(minimum, min(maximum, value)) def _odds_regions() -> str: raw = os.getenv("ODDS_REGIONS") if raw is None: return "eu" unique = list(dict.fromkeys( part.strip().lower() for part in raw.split(",") if part.strip() )) if not unique or any(region not in VALID_ODDS_REGIONS for region in unique): return "" return ",".join(unique) @dataclass(frozen=True) class Settings: football_data_token: str = field(default_factory=lambda: os.getenv("FOOTBALL_DATA_TOKEN", "").strip()) odds_api_key: str = field(default_factory=lambda: os.getenv("ODDS_API_KEY", "").strip()) cron_secret: str = field(default_factory=lambda: os.getenv("CRON_SECRET", "").strip()) odds_regions: str = field(default_factory=_odds_regions) history_days: int = field(default_factory=lambda: _int("HISTORY_DAYS", 240, 120, 730)) scan_horizon_hours: int = field(default_factory=lambda: _int("SCAN_HORIZON_HOURS", 36, 6, 96)) min_scan_interval_minutes: int = field(default_factory=lambda: _int("MIN_SCAN_INTERVAL_MINUTES", 180, 15, 1440)) min_safe_score: float = field(default_factory=lambda: _float("MIN_SAFE_SCORE", 76.0, 55.0, 95.0)) min_probability: float = field(default_factory=lambda: _float("MIN_PROBABILITY", 0.64, 0.55, 0.90)) min_conservative_probability: float = field(default_factory=lambda: _float("MIN_CONSERVATIVE_PROBABILITY", 0.57, 0.50, 0.85)) min_bookmakers: int = field(default_factory=lambda: _int("MIN_BOOKMAKERS", 3, 1, 10)) min_name_score: float = field(default_factory=lambda: _float("MIN_NAME_SCORE", 82.0, 70.0, 98.0)) top_picks_limit: int = field(default_factory=lambda: _int("TOP_PICKS_LIMIT", 10, 1, 20)) tz_display: str = field(default_factory=lambda: os.getenv("TZ_DISPLAY", "America/Sao_Paulo").strip() or "America/Sao_Paulo") hf_token: str = field(default_factory=lambda: (os.getenv("HF_WRITE_TOKEN", "").strip() or os.getenv("HF_TOKEN", "").strip())) hf_dataset_repo: str = field(default_factory=lambda: os.getenv("HF_DATASET_REPO", "").strip()) data_dir: Path = field(default_factory=_default_data_dir) @property def sport_keys(self) -> tuple[str, ...]: raw = os.getenv("ODDS_SPORT_KEYS", "").strip() if not raw: return DEFAULT_SPORT_KEYS keys = tuple(dict.fromkeys(x.strip() for x in raw.split(",") if x.strip())) # Fail closed when any requested league cannot be joined safely to # football-data.org history; silently falling back would waste quota. if not keys or any(key not in COMPETITIONS for key in keys): return () return keys @property def required_ready(self) -> bool: return bool( self.football_data_token and self.odds_api_key and self.cron_secret and self.odds_regions and self.sport_keys ) settings = Settings() settings.data_dir.mkdir(parents=True, exist_ok=True)