chore: scaffold uv project, config, personas, risk, model profiles

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DvdRZkyCy9NJpHYDdPqmS
This commit is contained in:
Aryan SinghandClaude Opus 4.8 committed 2026-07-01 16:29:29 -05:00
commit 52450c0835
12 files changed
+2696

No files matched your search

View File
Whitespace-only changes.
+67
View File
@@ -0,0 +1,67 @@
"""Model profiles — toggle between local models with one env var (MODEL_PROFILE).
Served by oMLX (Apple MLX inference server) on this machine. `served_model_id` is the id
oMLX reports at GET /v1/models; `match_keywords` let the client resolve the profile even if
that id string changes. Both models below are already downloaded in ~/.omlx/models.
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class ModelProfile(BaseModel):
key: str
display_name: str
served_model_id: str # id as oMLX reports it; client also falls back to keyword match
match_keywords: list[str] = Field(default_factory=list)
context_length: int = 16384
temperature: float = 0.7
downloaded: bool = False
notes: str = ""
GPT_OSS_20B = ModelProfile(
key="gpt_oss",
display_name="gpt-oss-20b MXFP4 (~11GB)",
served_model_id="gpt-oss-20b-MXFP4-Q8",
match_keywords=["gpt-oss"],
context_length=131072, # full window; oMLX SSD KV cache absorbs the growth
temperature=0.7,
downloaded=True,
notes="OpenAI open-weight, trained for tool-use/browsing + reasoning. Lighter → more "
"RAM headroom for long overnight runs. Good default.",
)
QWEN35_27B = ModelProfile(
key="qwen35_27b",
display_name="Qwen3.5-27B Claude-Opus-distilled (~14GB)",
served_model_id="Qwen3.5-27B-Claude-4.6-Opus-Distilled-MLX-4bit",
match_keywords=["qwen3.5", "opus-distilled", "qwen3.5-27b"],
context_length=262144, # Qwen3.5 supports even more; SSD KV cache absorbs the growth
temperature=0.7,
downloaded=True,
notes="Qwen (strong tool-calling) distilled from Claude Opus (strong reasoning). Bigger "
"→ tighter RAM, but oMLX's SSD KV cache absorbs long-context growth.",
)
MODEL_PROFILES: dict[str, ModelProfile] = {p.key: p for p in [GPT_OSS_20B, QWEN35_27B]}
DEFAULT_PROFILE = "gpt_oss"
def get_profile(key: str) -> ModelProfile:
if key not in MODEL_PROFILES:
raise ValueError(f"Unknown MODEL_PROFILE {key!r}. Options: {', '.join(MODEL_PROFILES)}")
return MODEL_PROFILES[key]
def resolve_profile(value: str) -> ModelProfile:
"""Tolerant lookup: accepts a profile key ('gpt_oss'), a served model id
('gpt-oss-20b-MXFP4-Q8'), or a keyword. Falls back to the default profile."""
if value in MODEL_PROFILES:
return MODEL_PROFILES[value]
low = (value or "").lower()
for p in MODEL_PROFILES.values():
if low == p.served_model_id.lower() or any(kw in low for kw in p.match_keywords):
return p
return MODEL_PROFILES[DEFAULT_PROFILE]
+96
View File
@@ -0,0 +1,96 @@
"""The three trader personas. Differ by risk knobs, research focus, and prompt voice
— not just flavor text. Each persona's sizing/stop/take defaults feed both the prompt
AND the morning guardrail clamps in ``execution/guardrails.py``.
"""
from __future__ import annotations
from pydantic import BaseModel
_COMMON_RULES = (
"\n\nHARD RULES:\n"
"- You have tools for market data, web search/fetch, a portfolio view, and a Python "
"calculator (`run_python`). ALWAYS use tools for prices, ratios, and any arithmetic. "
"NEVER estimate a number yourself.\n"
"- Ground every claim in data you actually fetched. Cite tickers, prices, and sources.\n"
"- You are trading a PAPER account. Be decisive but honest about uncertainty.\n"
"- When done researching, output your picks as structured JSON exactly matching the "
"requested schema — ticker, direction, conviction, thesis, stop/take, position size."
)
class Persona(BaseModel):
id: str
display_name: str
system_prompt: str
max_position_pct: float
target_pick_count: int
default_stop_pct: float
default_take_pct: float
preferred_horizon: str
tool_emphasis: list[str]
NOVA = Persona(
id="nova",
display_name="Nova — Momentum Growth Hunter",
system_prompt=(
"You are Nova, an aggressive momentum growth trader. You hunt stocks with strong "
"upward momentum, relative strength versus the market, breakouts to new highs, and "
"near-term catalysts — earnings beats, product launches, analyst upgrades, and sector "
"hype, especially in AI, semiconductors, and high-growth tech. You move fast and trade "
"with conviction. You prefer swing trades held days to a few weeks. You size winners "
"aggressively but always protect capital with TIGHT stop-losses (~5%) and let winners "
"run toward larger take-profits (~20%+). You are skeptical of stale, slow, low-volatility "
"names — you want where the flow is going now." + _COMMON_RULES
),
max_position_pct=0.10,
target_pick_count=3,
default_stop_pct=0.05,
default_take_pct=0.20,
preferred_horizon="swing",
tool_emphasis=["get_bars", "get_news", "web_search"],
)
WARREN_LITE = Persona(
id="warren_lite",
display_name="Warren-lite — Patient Value / Contrarian",
system_prompt=(
"You are Warren-lite, a patient value and contrarian investor. You buy quality "
"businesses trading cheaply relative to fundamentals — low P/E versus peers and history, "
"strong free cash flow, healthy balance sheets, and temporary pessimism you believe is "
"overdone. You IGNORE hype and momentum. You demand a margin of safety and think in weeks "
"to months. You size positions moderately and give theses room to work with WIDER "
"stop-losses (~10-12%), targeting steady take-profits. You read fundamentals, earnings, "
"and filings carefully and always ask: what is this business actually worth?" + _COMMON_RULES
),
max_position_pct=0.08,
target_pick_count=2,
default_stop_pct=0.11,
default_take_pct=0.15,
preferred_horizon="months",
tool_emphasis=["get_fundamentals", "web_fetch", "run_python"],
)
ATLAS = Persona(
id="atlas",
display_name="Atlas — Macro / Event-Driven Thematic",
system_prompt=(
"You are Atlas, a macro and event-driven thematic trader. You think top-down: the macro "
"regime (rates, inflation, growth, the dollar, credit), sector rotation, policy and "
"geopolitical catalysts, and scheduled events (FOMC, CPI, jobs reports, major earnings). "
"You build positions around a THEME and diversify across a few names or ETFs expressing "
"one view. You size moderately and use medium stops and take-profits. You connect macro "
"dots to specific tickers. You always ask: what regime are we in, and what catalyst is "
"next?" + _COMMON_RULES
),
max_position_pct=0.09,
target_pick_count=3,
default_stop_pct=0.08,
default_take_pct=0.15,
preferred_horizon="weeks",
tool_emphasis=["web_search", "get_news", "web_fetch"],
)
PERSONAS: list[Persona] = [NOVA, WARREN_LITE, ATLAS]
PERSONAS_BY_ID: dict[str, Persona] = {p.id: p for p in PERSONAS}
+24
View File
@@ -0,0 +1,24 @@
"""Risk guardrails. Generous-but-present defaults for a paper account.
These are enforced programmatically in ``execution/guardrails.py`` — the LLM's
requested sizing and stop/take levels are inputs that get clamped here, never trusted.
"""
from __future__ import annotations
from pydantic import BaseModel
class RiskConfig(BaseModel):
max_position_pct: float = 0.10 # cap per single position, % of equity
max_symbol_exposure_pct: float = 0.15 # aggregate cap per ticker across all agents
max_trades_per_agent: int = 3
max_trades_per_day: int = 8
allow_shorts: bool = True # shorts enabled; persona prompts decide when to use them
default_stop_pct: float = 0.07
default_take_pct: float = 0.15
min_price: float = 3.0 # skip sub-$3 names
max_price: float | None = None
DEFAULT_RISK = RiskConfig()
+137
View File
@@ -0,0 +1,137 @@
"""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()