""" config.py — Single source of truth for all configuration. Validates .env on import. Exits on any failure with a clear message. Run `python config.py migrate` to update .env with new keys. """ from __future__ import annotations import logging import os import re import sys from pathlib import Path from dotenv import dotenv_values, load_dotenv, set_key from contracts import ConfigError logger = logging.getLogger("trading_system.config") # ── Constants ──────────────────────────────────────────────────────────────── EXPECTED_SCHEMA_VERSION = 2 BASE_DIR = Path(__file__).resolve().parent ENV_FILE = BASE_DIR / ".env" ENV_EXAMPLE = BASE_DIR / ".env.example" # ── Load .env ──────────────────────────────────────────────────────────────── load_dotenv(ENV_FILE, override=True) def _get(key: str, cast=str, default=None): """Get env var with type casting.""" val = os.getenv(key) if val is None or val.strip() == "": if default is not None: return default return None val = val.strip() if cast is bool: return val.lower() in ("true", "1", "yes") return cast(val) def _require(key: str, cast=str): """Get required env var or exit.""" val = _get(key, cast) if val is None: print(f"MISSING ENV KEY: {key}") sys.exit(1) return val # ── Parse example keys ────────────────────────────────────────────────────── def _parse_example_keys() -> set[str]: """Extract all KEY names from .env.example.""" keys = set() if not ENV_EXAMPLE.exists(): return keys for line in ENV_EXAMPLE.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#"): continue match = re.match(r"^([A-Z_][A-Z0-9_]*)=", line) if match: keys.add(match.group(1)) return keys # ── Validation ─────────────────────────────────────────────────────────────── def _validate_all_keys_present(): """Check 1: Every key in .env.example must exist in .env.""" example_keys = _parse_example_keys() loaded = dotenv_values(ENV_FILE) if ENV_FILE.exists() else {} for key in example_keys: if key not in loaded and os.getenv(key) is None: print(f"MISSING ENV KEY: {key}") sys.exit(1) def _validate_schema_version(): """Check 2: ENV_SCHEMA_VERSION must match expected.""" version = _get("ENV_SCHEMA_VERSION", int, 0) if version != EXPECTED_SCHEMA_VERSION: print( f"ENV schema mismatch: expected v{EXPECTED_SCHEMA_VERSION}, got v{version}.\n" f"Run `python config.py migrate` to update your .env." ) sys.exit(1) def _validate(): """Run all validation checks.""" _validate_all_keys_present() _validate_schema_version() # Check 3: RISK_PER_TRADE_PCT <= 3.0 (swing uses 2%) risk = _get("RISK_PER_TRADE_PCT", float, 1.0) if risk > 3.0: raise ConfigError( f"RISK_PER_TRADE_PCT={risk} exceeds hard cap of 3.0. " "Refusing to start. Lower your risk or accept the cap." ) # Check 4: weights sum to 1.0 tw = _get("TECHNICAL_WEIGHT", float, 0.65) sw = _get("SENTIMENT_WEIGHT", float, 0.35) if abs((tw + sw) - 1.0) > 0.001: raise ConfigError( f"TECHNICAL_WEIGHT ({tw}) + SENTIMENT_WEIGHT ({sw}) = {tw + sw}, " "must equal 1.0 (±0.001)." ) # Check 5: HISTORY_DAILY_DAYS >= 250 hdd = _get("HISTORY_DAILY_DAYS", int, 500) if hdd < 250: logger.warning( "HISTORY_DAILY_DAYS overridden to 250 " "(EMA200 requires minimum 250 daily bars)." ) os.environ["HISTORY_DAILY_DAYS"] = "250" # Check 6: short selling requires margin allow_short = _get("ALLOW_SHORT", bool, False) acct_type = _get("ALPACA_ACCOUNT_TYPE", str, "margin") if allow_short and acct_type == "cash": raise ConfigError("Short selling requires a margin account.") # Check 8: ALLOCATED_CAPITAL must be > 0 for live trading mode = _get("TRADING_MODE", str, "paper") alloc = _get("ALLOCATED_CAPITAL", float, 0.0) dry = _get("DRY_RUN", bool, True) if mode == "live" and not dry and alloc <= 0: raise ConfigError( "ALLOCATED_CAPITAL must be > 0 for live trading. " "Set ALLOCATED_CAPITAL to your maximum bot allocation (e.g., 300). " "This prevents the bot from using your full account balance." ) # Check 7: overnight positions warning allow_overnight = _get("ALLOW_OVERNIGHT_POSITIONS", bool, False) if allow_overnight: logger.warning( "Overnight positions enabled. Using wider stops (2x daily ATR) " "to absorb gap risk. Max %d positions.", _get("MAX_OPEN_POSITIONS", int, 3), ) # ── Run validation on import ──────────────────────────────────────────────── try: _validate() except ConfigError as e: print(f"CONFIG ERROR: {e}") sys.exit(1) # ── Expose all config values as module-level constants ─────────────────────── ALPACA_API_KEY: str = _get("ALPACA_API_KEY", str, "") ALPACA_SECRET_KEY: str = _get("ALPACA_SECRET_KEY", str, "") ALPACA_BASE_URL: str = _get("ALPACA_BASE_URL", str, "https://paper-api.alpaca.markets") ALPACA_DATA_URL: str = _get("ALPACA_DATA_URL", str, "https://data.alpaca.markets") ALPACA_ACCOUNT_TYPE: str = _get("ALPACA_ACCOUNT_TYPE", str, "margin") NEWS_API_KEY: str = _get("NEWS_API_KEY", str, "") GNEWS_API_KEY: str = _get("GNEWS_API_KEY", str, "") TELEGRAM_BOT_TOKEN: str = _get("TELEGRAM_BOT_TOKEN", str, "") TELEGRAM_CHAT_ID: str = _get("TELEGRAM_CHAT_ID", str, "") TRADING_MODE: str = _get("TRADING_MODE", str, "paper") DRY_RUN: bool = _get("DRY_RUN", bool, True) TARGET_DAILY_PROFIT_USD: float = _get("TARGET_DAILY_PROFIT_USD", float, 20.0) MAX_DAILY_LOSS_USD: float = _get("MAX_DAILY_LOSS_USD", float, 40.0) MAX_LOSS_PER_POSITION_USD: float = _get("MAX_LOSS_PER_POSITION_USD", float, 2.0) RISK_PER_TRADE_PCT: float = _get("RISK_PER_TRADE_PCT", float, 1.0) MAX_OPEN_POSITIONS: int = _get("MAX_OPEN_POSITIONS", int, 3) MAX_DAILY_TRADES: int = _get("MAX_DAILY_TRADES", int, 10) ALLOW_SHORT: bool = _get("ALLOW_SHORT", bool, False) ALLOW_CRYPTO: bool = _get("ALLOW_CRYPTO", bool, False) # ── Universe rotation (A/B daily switch) ───────────────────────────────────── _UNIVERSE_A_RAW: str = _get("UNIVERSE_A", str, "") _UNIVERSE_B_RAW: str = _get("UNIVERSE_B", str, "") _UNIVERSE_RAW: str = _get("UNIVERSE", str, "") if _UNIVERSE_A_RAW and _UNIVERSE_B_RAW: UNIVERSE_A: list[str] = [s.strip() for s in _UNIVERSE_A_RAW.split(",") if s.strip()] UNIVERSE_B: list[str] = [s.strip() for s in _UNIVERSE_B_RAW.split(",") if s.strip()] # Rotate based on day-of-year: odd days → A, even days → B from datetime import datetime, timezone _today_doy = datetime.now(timezone.utc).timetuple().tm_yday UNIVERSE: list[str] = UNIVERSE_A if (_today_doy % 2 == 1) else UNIVERSE_B UNIVERSE_LABEL: str = "A" if (_today_doy % 2 == 1) else "B" logger.info("Universe rotation: Day %d → Universe %s (%d tickers)", _today_doy, UNIVERSE_LABEL, len(UNIVERSE)) else: UNIVERSE_A = [] UNIVERSE_B = [] UNIVERSE: list[str] = [s.strip() for s in _UNIVERSE_RAW.split(",") if s.strip()] if _UNIVERSE_RAW and _UNIVERSE_RAW != "auto" else ["AAPL", "MSFT", "NVDA", "SPY", "QQQ", "TSLA"] UNIVERSE_LABEL: str = "single" GPU_AVAILABLE: bool = _get("GPU_AVAILABLE", bool, True) MAX_SPREAD_PCT: float = _get("MAX_SPREAD_PCT", float, 0.50) LIMIT_ORDER_TIMEOUT_SEC: int = _get("LIMIT_ORDER_TIMEOUT_SEC", int, 60) ALLOW_MARKET_ORDERS: bool = _get("ALLOW_MARKET_ORDERS", bool, False) AGGRESSIVE_ENTRY: bool = _get("AGGRESSIVE_ENTRY", bool, False) LOCK_PROFITS_AT_TARGET: bool = _get("LOCK_PROFITS_AT_TARGET", bool, True) ALLOW_OVERNIGHT_POSITIONS: bool = _get("ALLOW_OVERNIGHT_POSITIONS", bool, False) ALLOCATED_CAPITAL: float = _get("ALLOCATED_CAPITAL", float, 0.0) MIN_POSITION_NOTIONAL_PCT: float = _get("MIN_POSITION_NOTIONAL_PCT", float, 0.05) MAX_SECTOR_POSITIONS: int = _get("MAX_SECTOR_POSITIONS", int, 2) STRATEGY_MODE: str = _get("STRATEGY_MODE", str, "auto") TECHNICAL_WEIGHT: float = _get("TECHNICAL_WEIGHT", float, 0.65) SENTIMENT_WEIGHT: float = _get("SENTIMENT_WEIGHT", float, 0.35) SIGNAL_BUY_THRESHOLD: float = _get("SIGNAL_BUY_THRESHOLD", float, 0.55) SIGNAL_SELL_THRESHOLD: float = _get("SIGNAL_SELL_THRESHOLD", float, -0.55) ATR_PERCENTILE_WINDOW_DAYS: int = _get("ATR_PERCENTILE_WINDOW_DAYS", int, 30) ACCOUNT_BALANCE_ABOVE_25K: bool = _get("ACCOUNT_BALANCE_ABOVE_25K", bool, True) PDT_MAX_DAY_TRADES: int = _get("PDT_MAX_DAY_TRADES", int, 3) HISTORY_DAILY_DAYS: int = max(_get("HISTORY_DAILY_DAYS", int, 500), 250) HISTORY_HOURLY_DAYS: int = _get("HISTORY_HOURLY_DAYS", int, 180) HISTORY_5MIN_DAYS: int = _get("HISTORY_5MIN_DAYS", int, 30) DB_BACKEND: str = _get("DB_BACKEND", str, "sqlite") # ── Trading Schedule (ET) ──────────────────────────────────────────────────── TRADING_START_ET: str = _get("TRADING_START_ET", str, "09:45") # begin entries TRADING_STOP_ET: str = _get("TRADING_STOP_ET", str, "14:00") # stop new entries FORCE_CLOSE_ET: str = _get("FORCE_CLOSE_ET", str, "14:00") # close positions SYSTEM_START_MINUTES_BEFORE_OPEN: int = _get("SYSTEM_START_MINUTES_BEFORE_OPEN", int, 10) def _parse_et_time(hhmm: str) -> tuple[int, int]: """Parse 'HH:MM' string to (hour, minute).""" parts = hhmm.strip().split(":") return int(parts[0]), int(parts[1]) TRADING_START_HOUR, TRADING_START_MIN = _parse_et_time(TRADING_START_ET) TRADING_STOP_HOUR, TRADING_STOP_MIN = _parse_et_time(TRADING_STOP_ET) FORCE_CLOSE_HOUR, FORCE_CLOSE_MIN = _parse_et_time(FORCE_CLOSE_ET) # ── Migrate command ────────────────────────────────────────────────────────── def migrate(): """Add any missing keys from .env.example to .env without deleting existing ones.""" example_keys = _parse_example_keys() loaded = dotenv_values(ENV_FILE) if ENV_FILE.exists() else {} example_vals = dotenv_values(ENV_EXAMPLE) if ENV_EXAMPLE.exists() else {} added = [] for key in sorted(example_keys): if key not in loaded: default = example_vals.get(key, "") set_key(str(ENV_FILE), key, default) added.append(key) # Update schema version set_key(str(ENV_FILE), "ENV_SCHEMA_VERSION", str(EXPECTED_SCHEMA_VERSION)) if added: print(f"Added {len(added)} missing keys: {', '.join(added)}") else: print("No missing keys. .env is up to date.") print(f"ENV_SCHEMA_VERSION set to {EXPECTED_SCHEMA_VERSION}.") if __name__ == "__main__": if len(sys.argv) > 1 and sys.argv[1] == "migrate": migrate() else: print("Config loaded successfully. All validations passed.") print(f" TRADING_MODE={TRADING_MODE}") print(f" DRY_RUN={DRY_RUN}") print(f" UNIVERSE={UNIVERSE}") print(f" GPU_AVAILABLE={GPU_AVAILABLE}") print(f" DB_BACKEND={DB_BACKEND}") print(f" ACCOUNT_BALANCE_ABOVE_25K={ACCOUNT_BALANCE_ABOVE_25K}")