"""Central configuration. Single source of truth for keys, budgets, timezones, paths. Reads from the repo-root ``.env`` (see ``.env.example``). Env var names map to the snake_case fields below case-insensitively (pydantic-settings default). """ from __future__ import annotations from functools import lru_cache from pathlib import Path from pydantic_settings import BaseSettings, SettingsConfigDict from .models import ModelProfile, resolve_profile REPO_ROOT = Path(__file__).resolve().parent.parent @lru_cache def _dotenv_extras() -> dict[str, str]: """Raw .env entries (uppercased keys). pydantic-settings drops keys that have no Settings field (extra='ignore') and does NOT export them to os.environ — but dashboard-added agents need their ALPACA__KEY/SECRET found anyway.""" try: from dotenv import dotenv_values return {k.upper(): (v or "") for k, v in dotenv_values(REPO_ROOT / ".env").items()} except Exception: return {} class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=REPO_ROOT / ".env", env_file_encoding="utf-8", extra="ignore", case_sensitive=False, ) # --- Alpaca: one paper account per persona (single-account fields are a fallback) --- alpaca_paper: bool = True alpaca_api_key: str = "" alpaca_secret_key: str = "" alpaca_nova_key: str = "" alpaca_nova_secret: str = "" alpaca_warren_lite_key: str = "" alpaca_warren_lite_secret: str = "" alpaca_atlas_key: str = "" alpaca_atlas_secret: str = "" alpaca_maverick_key: str = "" alpaca_maverick_secret: str = "" # --- Finnhub --- finnhub_api_key: str = "" # --- oMLX local server (Apple MLX; OpenAI-compatible; auto-starts on login) --- llm_host: str = "127.0.0.1" llm_port: int = 8090 # oMLX enforces an API key. Blank → auto-read from ~/.omlx/settings.json (auth.api_key). omlx_api_key: str = "" # Model toggle (presets in config/models.py): gpt_oss | qwen35_27b model_profile: str = "gpt_oss" # Optional explicit served model id; blank = use the profile + resolve against /v1/models. llm_model: str = "" llm_max_tokens: int = 2048 # --- Budgets --- per_agent_wallclock_min: int = 130 # hard wall-clock cap per agent per_agent_min_minutes: int = 120 # TIME FLOOR: keep researching until at least this long per_agent_step_budget: int = 400 # high safety cap so steps don't cap before the time floor per_agent_min_steps: int = 25 # secondary floor (steps) hard_stop: str = "07:00" # local wall-clock HH:MM; nightly run must finish by this sandbox_timeout_s: int = 90 # run_analysis script wall-clock limit # Compact when the prompt approaches this many tokens. This is the MEMORY-safe KV ceiling # for a 24GB Mac (gpt-oss hits oMLX's prefill guard ~47k tokens / ~16GB) — NOT the model's # 131k context window. Keeping context well under the guard is what makes long runs survive. compaction_token_budget: int = 26000 # --- Email (daily report; use a Gmail App Password, never your real password) --- email_to: str = "" email_from: str = "" smtp_host: str = "smtp.gmail.com" smtp_port: int = 587 email_app_password: str = "" # --- Timezones --- market_tz: str = "America/New_York" # authoritative market clock local_tz: str = "America/Chicago" # this Mac # --- Derived paths --- @property def state_dir(self) -> Path: return REPO_ROOT / "state" @property def output_dir(self) -> Path: return REPO_ROOT / "state" / "output" @property def log_dir(self) -> Path: return REPO_ROOT / "state" / "logs" @property def db_path(self) -> Path: return REPO_ROOT / "state" / "trading.db" @property def reports_dir(self) -> Path: return REPO_ROOT / "state" / "reports" @property def sandbox_dir(self) -> Path: return REPO_ROOT / "state" / "sandbox" @property def llm_base_url(self) -> str: return f"http://{self.llm_host}:{self.llm_port}/v1" @property def llm_api_key(self) -> str: """oMLX API key: explicit env value, else auto-discovered from oMLX's settings.json.""" if self.omlx_api_key: return self.omlx_api_key try: import json data = json.loads((Path.home() / ".omlx" / "settings.json").read_text()) return data["auth"]["api_key"] except Exception: return "not-needed" @property def active_profile(self) -> ModelProfile: return resolve_profile(self.model_profile) @property def effective_model_id(self) -> str: """Explicit override if set, else the active profile's served id. The client further resolves this against oMLX's /v1/models.""" return self.llm_model or self.active_profile.served_model_id @property def llm_context_length(self) -> int: return self.active_profile.context_length @property def llm_temperature(self) -> float: return self.active_profile.temperature def alpaca_creds(self, persona_id: str) -> tuple[str, str]: """(key, secret) for a persona's paper account, falling back to the single-account keys. Dashboard-added agents have no Settings field, so ``ALPACA__KEY/SECRET`` is also looked up in the process environment and the raw .env file. """ import os pid = persona_id.strip().lower() env_key, env_secret = f"ALPACA_{pid.upper()}_KEY", f"ALPACA_{pid.upper()}_SECRET" extras = _dotenv_extras() key = ( getattr(self, f"alpaca_{pid}_key", "") or os.environ.get(env_key, "") or extras.get(env_key, "") ) secret = ( getattr(self, f"alpaca_{pid}_secret", "") or os.environ.get(env_secret, "") or extras.get(env_secret, "") ) if key and secret: return key, secret return self.alpaca_api_key, self.alpaca_secret_key def alpaca_data_creds(self) -> tuple[str, str]: """Any valid creds — Alpaca market data is account-agnostic.""" try: from config.agent_registry import load_personas # lazy: registry imports settings ids = [p.id for p in load_personas(include_disabled=True)] except Exception: ids = ["nova", "warren_lite", "atlas", "maverick"] for pid in ids: key, secret = self.alpaca_creds(pid) if key and secret: return key, secret return self.alpaca_api_key, self.alpaca_secret_key def ensure_dirs(self) -> None: for d in (self.output_dir, self.log_dir, self.reports_dir, self.sandbox_dir): d.mkdir(parents=True, exist_ok=True) @lru_cache def get_settings() -> Settings: return Settings()