from __future__ import annotations import os from dataclasses import dataclass, field from pathlib import Path from dotenv import load_dotenv def _load_env() -> None: root = Path(__file__).resolve().parents[2] for candidate in (root / ".env", root.parent / ".env"): if candidate.exists(): load_dotenv(candidate, override=False) return def _bool(val: str | None, default: bool = False) -> bool: if val is None: return default return val.strip().lower() in {"1", "true", "yes", "y", "on"} def _float(val: str | None, default: float) -> float: try: return float(val) if val is not None else default except ValueError: return default def _int(val: str | None, default: int) -> int: try: return int(val) if val is not None else default except ValueError: return default @dataclass(frozen=True) class AlpacaConfig: key: str secret: str base_url: str env: str # "paper" or "live" @property def is_paper(self) -> bool: return self.env.lower() != "live" @dataclass(frozen=True) class WheelConfig: universe: tuple[str, ...] put_dte_min: int put_dte_max: int put_target_delta: float put_otm_pct: float call_otm_pct: float min_annual_yield: float max_short_puts_per_symbol: int @dataclass(frozen=True) class RiskConfig: max_position_pct: float min_cash_buffer_pct: float trailing_stop_enabled: bool trailing_stop_pct: float @dataclass(frozen=True) class Config: mode: str # "dry" or "live" alpaca: AlpacaConfig wheel: WheelConfig risk: RiskConfig tick_interval: int data_dir: Path log_dir: Path discord_webhook: str | None = None @property def dry_run(self) -> bool: return self.mode.lower() != "live" def load_config() -> Config: _load_env() env = os.environ alpaca = AlpacaConfig( key=env.get("ALPACA_API_KEY", ""), secret=env.get("ALPACA_API_SECRET", ""), base_url=env.get("ALPACA_BASE_URL", "https://paper-api.alpaca.markets"), env=env.get("ALPACA_ENV", "paper"), ) universe = tuple( s.strip().upper() for s in env.get("WHEEL_UNIVERSE", "TSLA").split(",") if s.strip() ) wheel = WheelConfig( universe=universe, put_dte_min=_int(env.get("WHEEL_PUT_DTE_MIN"), 14), put_dte_max=_int(env.get("WHEEL_PUT_DTE_MAX"), 28), put_target_delta=_float(env.get("WHEEL_PUT_TARGET_DELTA"), 0.30), put_otm_pct=_float(env.get("WHEEL_PUT_OTM_PCT"), 0.10), call_otm_pct=_float(env.get("WHEEL_CALL_OTM_PCT"), 0.10), min_annual_yield=_float(env.get("WHEEL_MIN_ANNUAL_YIELD"), 0.15), max_short_puts_per_symbol=_int(env.get("WHEEL_MAX_SHORT_PUTS_PER_SYMBOL"), 1), ) risk = RiskConfig( max_position_pct=_float(env.get("MAX_POSITION_PCT"), 0.25), min_cash_buffer_pct=_float(env.get("MIN_CASH_BUFFER_PCT"), 0.05), trailing_stop_enabled=_bool(env.get("TRAILING_STOP_ENABLED"), True), trailing_stop_pct=_float(env.get("TRAILING_STOP_PCT"), 0.08), ) root = Path(__file__).resolve().parents[2] data_dir = Path(env.get("DATA_DIR") or (root / "data")).expanduser().resolve() log_dir = Path(env.get("LOG_DIR") or (root / "logs")).expanduser().resolve() data_dir.mkdir(parents=True, exist_ok=True) log_dir.mkdir(parents=True, exist_ok=True) return Config( mode=env.get("BOT_MODE", "dry"), alpaca=alpaca, wheel=wheel, risk=risk, tick_interval=_int(env.get("TICK_INTERVAL_SECONDS"), 900), data_dir=data_dir, log_dir=log_dir, discord_webhook=env.get("DISCORD_WEBHOOK_URL") or None, )