Files
trading-agent/config/settings.py
T

155 lines
5.2 KiB
Python

"""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 # 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
# --- 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."""
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:
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()