Files
trading-agent/config/settings.py
T
Aryan Singh e1ac514f30 Add dynamic agent registry and options mandate
Introduces a dashboard-managed agent registry (`state/agents.json`) used by nightly research and market-open execution, including support for custom/disabled agents and per-agent time budgets. Expands personas to four agents (adds Maverick), shifts pick targets to include mandatory options, and updates prompts/finalization nudges to enforce option research without hard-failing runs.

Strengthens execution safety by adding option-specific guardrails (conviction floor, premium cap, reserved option slot through trade/day/buying-power caps), validates OCC option symbols in schemas, and normalizes invalid option direction cases. The dashboard was refactored into modular views, adds an Agents admin tab, and improves live portfolio visibility with Alpaca-first data and snapshot fallback.
2026-07-07 22:00:20 -05:00

197 lines
6.8 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
@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_<ID>_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_<ID>_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()