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
@@ -0,0 +1,36 @@
|
|||||||
|
# ---- Alpaca paper accounts — ONE PER PERSONA (paper keys) ----
|
||||||
|
# Each persona trades its own account. Fill key + secret for each.
|
||||||
|
ALPACA_NOVA_KEY=your_agent1_paper_key
|
||||||
|
ALPACA_NOVA_SECRET=your_agent1_paper_secret
|
||||||
|
ALPACA_WARREN_LITE_KEY=your_agent2_paper_key
|
||||||
|
ALPACA_WARREN_LITE_SECRET=your_agent2_paper_secret
|
||||||
|
ALPACA_ATLAS_KEY=your_agent3_paper_key
|
||||||
|
ALPACA_ATLAS_SECRET=your_agent3_paper_secret
|
||||||
|
ALPACA_PAPER=true
|
||||||
|
# (Optional single-account fallback if the per-persona keys above are blank)
|
||||||
|
ALPACA_API_KEY=
|
||||||
|
ALPACA_SECRET_KEY=
|
||||||
|
|
||||||
|
# ---- Finnhub (https://finnhub.io) — free tier key ----
|
||||||
|
FINNHUB_API_KEY=your_finnhub_key_here
|
||||||
|
|
||||||
|
# ---- oMLX local server (Apple MLX; auto-starts on login, port 8090) ----
|
||||||
|
LLM_HOST=127.0.0.1
|
||||||
|
LLM_PORT=8090
|
||||||
|
# oMLX API key. Blank = auto-read from ~/.omlx/settings.json (auth.api_key)
|
||||||
|
OMLX_API_KEY=
|
||||||
|
# Model toggle (see config/models.py): gpt_oss | qwen35_27b
|
||||||
|
MODEL_PROFILE=gpt_oss
|
||||||
|
# Optional: force an exact served model id (blank = use profile + resolve against /v1/models)
|
||||||
|
LLM_MODEL=
|
||||||
|
LLM_MAX_TOKENS=2048
|
||||||
|
|
||||||
|
# ---- Per-agent budgets ----
|
||||||
|
PER_AGENT_WALLCLOCK_MIN=130
|
||||||
|
PER_AGENT_STEP_BUDGET=60
|
||||||
|
PER_AGENT_MIN_STEPS=25 # keep researching (nudge) until at least this many steps — deeper research
|
||||||
|
HARD_STOP=07:00
|
||||||
|
|
||||||
|
# ---- Timezones (market clock is authoritative; local is your Mac) ----
|
||||||
|
MARKET_TZ=America/New_York
|
||||||
|
LOCAL_TZ=America/Chicago
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# secrets
|
||||||
|
.env
|
||||||
|
|
||||||
|
# python
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# runtime state (keep dirs via .gitkeep, ignore contents)
|
||||||
|
state/trading.db
|
||||||
|
state/*.db
|
||||||
|
state/output/
|
||||||
|
state/logs/
|
||||||
|
|
||||||
|
# os
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.11
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Overnight Local-LLM Paper-Trading Agent
|
||||||
|
|
||||||
|
Every night the Mac wakes itself, runs **3 trader personas** one at a time against **one local LLM**
|
||||||
|
(gpt-oss-20b via [oMLX](https://omlx.ai), an Apple-MLX inference server). Each persona researches the
|
||||||
|
web + live market data, forms a stock thesis, and writes JSON. In the morning at market open a
|
||||||
|
separate job reads that JSON and places
|
||||||
|
**Alpaca paper trades** with programmatic risk guardrails. Everything is visible in a local
|
||||||
|
Streamlit dashboard.
|
||||||
|
|
||||||
|
> **Paper trading only.** Fake money, real prices. "The build works" ≠ "it's profitable" — the
|
||||||
|
> quality of a local model's theses is the open question you tune over time.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- Apple Silicon Mac (built/tested on M4 Pro, 24 GB), **plugged in, lid open, logged in** overnight.
|
||||||
|
- [oMLX](https://omlx.ai) (Apple-MLX server) auto-running on `:8090` with a model downloaded
|
||||||
|
(default `gpt-oss-20b`; toggle `qwen35_27b`). Enforces an API key, auto-read from `~/.omlx/settings.json`.
|
||||||
|
- [uv](https://docs.astral.sh/uv/) for Python env management.
|
||||||
|
- Alpaca **paper** account + Finnhub free key.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
```bash
|
||||||
|
# 1. Python env + deps
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# 2. Secrets
|
||||||
|
cp .env.example .env # then paste your Alpaca paper + Finnhub keys
|
||||||
|
|
||||||
|
# 3. Model host — oMLX auto-starts on login; if not, bring it up + verify tool-calling
|
||||||
|
omlx start # OpenAI-compatible API on http://127.0.0.1:8090
|
||||||
|
uv run python scripts/smoke_toolcalling.py # GATE: multi-turn tool calls must PASS
|
||||||
|
|
||||||
|
# 4. Phase-0 gate
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Switch models any time with one line in `.env`: `MODEL_PROFILE=gpt_oss` or `qwen35_27b`.
|
||||||
|
|
||||||
|
## Entry points
|
||||||
|
| Command | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `uv run python -m orchestration.run_nightly` | Nightly research run (3 personas → JSON). `--compressed` for a fast test |
|
||||||
|
| `uv run python -m execution.run_market_open` | Morning: JSON → guardrails → Alpaca paper orders. `--force` to test while closed |
|
||||||
|
| `uv run streamlit run web/dashboard.py` | Dashboard: positions, PnL, theses, orders, logs |
|
||||||
|
| `uv run python -m reporting.report` | CLI summary |
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
`run_nightly` starts oMLX, then runs Nova → Warren-lite → Atlas **sequentially** (one model,
|
||||||
|
time-sliced with rollover budgets). Each agent researches with tools (`run_python` for all math,
|
||||||
|
web search/fetch, Alpaca/Finnhub/yfinance market data, `get_portfolio` for continuity) and its
|
||||||
|
output is checkpointed incrementally to `state/output/<date>/`. A min-steps nudge keeps agents
|
||||||
|
researching broadly instead of stopping early.
|
||||||
|
|
||||||
|
**Each persona trades its own Alpaca paper account** (Nova→agent1, Warren-lite→agent2, Atlas→agent3),
|
||||||
|
so the three books — and their PnL — stay separate and comparable. `run_market_open` gates on
|
||||||
|
Alpaca's clock, then per account applies `execution/guardrails.py` (position/day caps, sizing,
|
||||||
|
buying-power) and places **GTC bracket** orders (marketable-limit entry so stop/take legs survive
|
||||||
|
multi-day holds).
|
||||||
|
|
||||||
|
## Automate it (America/Chicago)
|
||||||
|
- **00:00–07:00** research (~2.3 hr/agent), machine woken at **23:55**.
|
||||||
|
- **08:30** execution (= 09:30 ET open; gated on Alpaca's clock, never local time).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/install_launchd.sh # loads the 3 launchd jobs
|
||||||
|
sudo pmset repeat wakeorpoweron MTWRFSU 23:55:00 # one-time: wake the Mac nightly (needs sudo)
|
||||||
|
```
|
||||||
|
Overnight requirements: **plugged in, lid open, logged in** (lock screen OK), oMLX running, `.env` filled.
|
||||||
|
Remove with `scripts/uninstall_launchd.sh` + `sudo pmset repeat cancel`.
|
||||||
|
|
||||||
|
## Instruments & safety
|
||||||
|
- Trades **stocks (long or short)** and **single-leg long options** (buy calls/puts). Per-trade
|
||||||
|
+ per-day caps; stops/takes enforced in code, not trusted from the LLM. Shorts use inverted
|
||||||
|
brackets; options are sized by premium×100 (max loss = premium; see `docs/options-phase.md`).
|
||||||
|
- `run_python` executes model-generated code locally — kept math-only, sandboxed, no network.
|
||||||
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()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
[project]
|
||||||
|
name = "trading-agent"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Overnight local-LLM paper-trading agent system (Alpaca paper + MLX + LangGraph)"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11,<3.13"
|
||||||
|
dependencies = [
|
||||||
|
"langgraph",
|
||||||
|
"langchain-core",
|
||||||
|
"langchain-openai",
|
||||||
|
"langgraph-checkpoint-sqlite",
|
||||||
|
"alpaca-py",
|
||||||
|
"finnhub-python",
|
||||||
|
"yfinance",
|
||||||
|
"httpx",
|
||||||
|
"trafilatura",
|
||||||
|
"ddgs",
|
||||||
|
"pydantic>=2.7",
|
||||||
|
"pydantic-settings>=2.3",
|
||||||
|
"python-dotenv",
|
||||||
|
"tenacity",
|
||||||
|
"pandas",
|
||||||
|
"streamlit",
|
||||||
|
"loguru",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = ["pytest>=8", "ruff"]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = false
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["."]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
Whitespace-only changes.
Reference in new issue
Block a user