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:
commit
52450c0835
12 files changed
+2696
No files matched your search
Whitespace-only changes.
@@ -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]
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user