- compact at memory-safe token budget (~20k) not the 131k window; gpt-oss hits oMLX's prefill memory guard ~47k tokens on a 24GB machine - repair invalid_tool_calls (malformed JSON args) before echoing history — LangChain serializes the raw malformed string which oMLX 500s on - retry invokes (4x) with an oMLX watchdog restart between attempts - ALWAYS finalize: salvage picks from findings even after invoke failures Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016DvdRZkyCy9NJpHYDdPqmS
159 lines
5.5 KiB
Python
159 lines
5.5 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
|
|
# 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."""
|
|
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()
|