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.
197 lines
6.8 KiB
Python
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()
|