"""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 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 = "" # --- 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 per_agent_step_budget: int = 60 per_agent_min_steps: int = 25 # keep researching (nudge) until at least this many steps hard_stop: str = "07:00" # local wall-clock HH:MM; nightly run must finish by this # --- 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 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.""" key = getattr(self, f"alpaca_{persona_id}_key", "") or "" secret = getattr(self, f"alpaca_{persona_id}_secret", "") or "" 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.""" for pid in ("nova", "warren_lite", "atlas"): 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: self.output_dir.mkdir(parents=True, exist_ok=True) self.log_dir.mkdir(parents=True, exist_ok=True) @lru_cache def get_settings() -> Settings: return Settings()