Initial v1 autonomous alpaca llm trading bot with dark dashboard

This commit is contained in:
2026-02-25 18:51:24 -08:00
commit cce199d73a
10 changed files with 399 additions and 0 deletions

24
.env.example Normal file
View File

@@ -0,0 +1,24 @@
# Alpaca
ALPACA_API_KEY=REPLACE_ME
ALPACA_API_SECRET=REPLACE_ME
ALPACA_BASE_URL=https://paper-api.alpaca.markets
# Runtime
PAPER_MODE=true
MAX_ORDER_USD=5
TRADE_INTERVAL_HOURS=2
TIMEZONE=America/Los_Angeles
# Ollama
OLLAMA_URL=http://10.30.20.110:11434
OLLAMA_MODEL=gemma3:latest
# Data sources
SEARX_URL=http://10.30.20.35:6969/search
SCRAPER_API_URL=http://10.30.20.115:24125
# App
APP_HOST=0.0.0.0
APP_PORT=8089
DB_PATH=sqlite:///./bot.db
SYMBOLS=SPY,QQQ,AAPL,MSFT,NVDA,AMD,TSLA,META,AMZN

33
README.md Normal file
View File

@@ -0,0 +1,33 @@
# alpaca-llm-bot-v1
Autonomous 2-hour trading loop powered by:
- Alpaca trading API
- Ollama local model inference
- Searx web data ingestion
- FastAPI dark dashboard
## Safety defaults
- `PAPER_MODE=true`
- max order notional `$5`
- confidence gate `>= 0.55` before order placement
## Quick start
```bash
cp .env.example .env
# fill ALPACA_API_KEY / ALPACA_API_SECRET
chmod +x run.sh
./run.sh
```
Open dashboard:
- `http://<host>:8089/`
Trigger immediate cycle:
```bash
curl -X POST http://127.0.0.1:8089/run-now
```
## Production notes
- Put behind reverse proxy + auth
- Keep paper mode until behavior validated
- Add hard stop-loss and max daily drawdown before enabling live mode

46
app.py Normal file
View File

@@ -0,0 +1,46 @@
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import func
from datetime import datetime, timedelta
from db import init_db, SessionLocal, BotDecision, TradeExecution
from bot import start_scheduler, run_cycle
from services import account_snapshot
app = FastAPI(title="alpaca-llm-bot-v1")
templates = Jinja2Templates(directory="templates")
@app.on_event("startup")
def startup():
init_db()
start_scheduler()
@app.get("/health")
def health():
return {"ok": True}
@app.post("/run-now")
def run_now():
run_cycle()
return {"ok": True, "ran": True}
@app.get("/api/account")
def api_account():
return JSONResponse(account_snapshot())
@app.get("/")
def home(request: Request):
db = SessionLocal()
try:
since = datetime.utcnow() - timedelta(hours=24)
decisions = db.query(BotDecision).order_by(BotDecision.ts.desc()).limit(120).all()
trades = db.query(TradeExecution).order_by(TradeExecution.ts.desc()).limit(120).all()
stats = {
"decisions": db.query(func.count(BotDecision.id)).filter(BotDecision.ts >= since).scalar() or 0,
"trades": db.query(func.count(TradeExecution.id)).filter(TradeExecution.ts >= since).scalar() or 0,
"executed": db.query(func.count(BotDecision.id)).filter(BotDecision.ts >= since, BotDecision.status == "executed").scalar() or 0,
"failed": db.query(func.count(BotDecision.id)).filter(BotDecision.ts >= since, BotDecision.status == "failed").scalar() or 0,
}
return templates.TemplateResponse("index.html", {"request": request, "decisions": decisions, "trades": trades, "stats": stats})
finally:
db.close()

55
bot.py Normal file
View File

@@ -0,0 +1,55 @@
from apscheduler.schedulers.background import BackgroundScheduler
from datetime import datetime
import json
from config import settings
from db import SessionLocal, BotDecision, TradeExecution
from services import searx_news, ollama_decide, place_order
scheduler = BackgroundScheduler(timezone=settings.timezone)
def run_cycle():
db = SessionLocal()
try:
for symbol in settings.symbols:
news = searx_news(symbol)
decision = ollama_decide(symbol, news)
drow = BotDecision(
symbol=symbol,
action=decision["action"],
confidence=decision["confidence"],
reason=decision["reason"],
market_context=json.dumps(news)[:60000],
order_usd=decision["order_usd"],
status="planned",
)
db.add(drow)
db.commit()
db.refresh(drow)
if decision["action"] in {"buy", "sell"} and decision["confidence"] >= 0.55:
res = place_order(symbol, decision["action"], min(settings.max_order_usd, decision["order_usd"]))
ok = bool(res and res.get("ok"))
drow.status = "executed" if ok else "failed"
db.add(drow)
db.add(TradeExecution(
symbol=symbol,
side=decision["action"],
qty=float((res or {}).get("json", {}).get("qty", 0) or 0),
notional=min(settings.max_order_usd, decision["order_usd"]),
alpaca_order_id=(res or {}).get("json", {}).get("id", ""),
raw=json.dumps(res)[:60000],
))
db.commit()
else:
drow.status = "skipped"
db.add(drow)
db.commit()
finally:
db.close()
def start_scheduler():
scheduler.add_job(run_cycle, "interval", hours=settings.trade_interval_hours, id="trade_cycle", replace_existing=True)
scheduler.start()

27
config.py Normal file
View File

@@ -0,0 +1,27 @@
from dotenv import load_dotenv
import os
load_dotenv()
class Settings:
alpaca_key = os.getenv("ALPACA_API_KEY", "")
alpaca_secret = os.getenv("ALPACA_API_SECRET", "")
alpaca_base = os.getenv("ALPACA_BASE_URL", "https://paper-api.alpaca.markets")
paper_mode = os.getenv("PAPER_MODE", "true").lower() == "true"
max_order_usd = float(os.getenv("MAX_ORDER_USD", "5"))
trade_interval_hours = int(os.getenv("TRADE_INTERVAL_HOURS", "2"))
timezone = os.getenv("TIMEZONE", "America/Los_Angeles")
ollama_url = os.getenv("OLLAMA_URL", "http://10.30.20.110:11434")
ollama_model = os.getenv("OLLAMA_MODEL", "gemma3:latest")
searx_url = os.getenv("SEARX_URL", "http://10.30.20.35:6969/search")
scraper_api = os.getenv("SCRAPER_API_URL", "http://10.30.20.115:24125")
db_path = os.getenv("DB_PATH", "sqlite:///./bot.db")
host = os.getenv("APP_HOST", "0.0.0.0")
port = int(os.getenv("APP_PORT", "8089"))
symbols = [s.strip().upper() for s in os.getenv("SYMBOLS", "SPY,QQQ").split(",") if s.strip()]
settings = Settings()

35
db.py Normal file
View File

@@ -0,0 +1,35 @@
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, Text
from sqlalchemy.orm import declarative_base, sessionmaker
from datetime import datetime
from config import settings
Base = declarative_base()
engine = create_engine(settings.db_path, echo=False)
SessionLocal = sessionmaker(bind=engine)
class BotDecision(Base):
__tablename__ = "decisions"
id = Column(Integer, primary_key=True)
ts = Column(DateTime, default=datetime.utcnow)
symbol = Column(String(16), index=True)
action = Column(String(16)) # buy/sell/hold
confidence = Column(Float)
reason = Column(Text)
market_context = Column(Text)
order_usd = Column(Float)
status = Column(String(32), default="planned")
class TradeExecution(Base):
__tablename__ = "trades"
id = Column(Integer, primary_key=True)
ts = Column(DateTime, default=datetime.utcnow)
symbol = Column(String(16), index=True)
side = Column(String(8))
qty = Column(Float)
notional = Column(Float)
alpaca_order_id = Column(String(128))
raw = Column(Text)
def init_db():
Base.metadata.create_all(bind=engine)

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
jinja2==3.1.4
requests==2.32.3
apscheduler==3.10.4
python-dotenv==1.0.1
sqlalchemy==2.0.36
pydantic==2.10.3

6
run.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app:app --host ${APP_HOST:-0.0.0.0} --port ${APP_PORT:-8089}

109
services.py Normal file
View File

@@ -0,0 +1,109 @@
import json
import random
import requests
from datetime import datetime
from config import settings
def searx_news(symbol: str, limit: int = 8):
q = f"{symbol} stock news earnings guidance analyst"
params = {"q": q, "format": "json", "language": "en"}
try:
r = requests.get(settings.searx_url, params=params, timeout=20)
r.raise_for_status()
data = r.json()
out = []
for it in data.get("results", [])[:limit]:
out.append({"title": it.get("title", ""), "url": it.get("url", ""), "content": it.get("content", "")[:400]})
return out
except Exception:
return []
def ollama_decide(symbol: str, context_items: list):
prompt = {
"task": "You are a strict trading policy engine. Return JSON only.",
"constraints": {
"actions": ["buy", "sell", "hold"],
"max_order_usd": settings.max_order_usd,
"style": "conservative intraday swing",
},
"symbol": symbol,
"news": context_items,
"output_schema": {
"action": "buy|sell|hold",
"confidence": "0-1",
"reason": "short rationale",
"order_usd": f"<= {settings.max_order_usd}",
},
}
payload = {
"model": settings.ollama_model,
"prompt": json.dumps(prompt),
"stream": False,
"format": "json",
}
try:
r = requests.post(f"{settings.ollama_url}/api/generate", json=payload, timeout=40)
r.raise_for_status()
resp = r.json().get("response", "{}")
d = json.loads(resp)
action = d.get("action", "hold").lower()
if action not in {"buy", "sell", "hold"}:
action = "hold"
confidence = float(d.get("confidence", 0.5))
order_usd = min(float(d.get("order_usd", settings.max_order_usd)), settings.max_order_usd)
reason = d.get("reason", "fallback")
return {"action": action, "confidence": confidence, "order_usd": order_usd, "reason": reason}
except Exception:
# resilient fallback to hold or tiny buy
return {
"action": random.choice(["hold", "hold", "buy"]),
"confidence": 0.3,
"order_usd": min(1.0, settings.max_order_usd),
"reason": "fallback-mode",
}
def alpaca_headers():
return {
"APCA-API-KEY-ID": settings.alpaca_key,
"APCA-API-SECRET-KEY": settings.alpaca_secret,
"Content-Type": "application/json",
}
def alpaca_last_price(symbol: str):
url = f"https://data.alpaca.markets/v2/stocks/{symbol}/trades/latest"
try:
r = requests.get(url, headers=alpaca_headers(), timeout=20)
r.raise_for_status()
return float(r.json()["trade"]["p"])
except Exception:
return None
def place_order(symbol: str, action: str, order_usd: float):
if action not in {"buy", "sell"}:
return None
payload = {
"symbol": symbol,
"side": action,
"type": "market",
"time_in_force": "day",
"notional": round(order_usd, 2),
}
try:
r = requests.post(f"{settings.alpaca_base}/v2/orders", headers=alpaca_headers(), json=payload, timeout=20)
return {"ok": r.ok, "status": r.status_code, "json": r.json() if r.text else {}}
except Exception as e:
return {"ok": False, "status": 0, "json": {"error": str(e)}}
def account_snapshot():
try:
r = requests.get(f"{settings.alpaca_base}/v2/account", headers=alpaca_headers(), timeout=20)
r.raise_for_status()
return r.json()
except Exception:
return {}

56
templates/index.html Normal file
View File

@@ -0,0 +1,56 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>alpaca-llm-bot-v1</title>
<style>
:root { --bg:#0b0f17; --card:#111827; --text:#e5e7eb; --muted:#9ca3af; --ok:#10b981; --bad:#ef4444; --acc:#60a5fa; }
body{background:var(--bg);color:var(--text);font-family:Inter,system-ui,sans-serif;margin:0;padding:24px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}
.card{background:var(--card);border:1px solid #1f2937;border-radius:12px;padding:14px}
.h{font-size:13px;color:var(--muted)} .v{font-size:24px;font-weight:700}
table{width:100%;border-collapse:collapse} th,td{padding:8px;border-bottom:1px solid #1f2937;text-align:left;font-size:13px}
.buy{color:var(--ok)} .sell{color:var(--bad)} .hold{color:var(--muted)}
</style>
</head>
<body>
<h2>alpaca-llm-bot-v1</h2>
<div class="grid">
<div class="card"><div class="h">Decisions (24h)</div><div class="v">{{ stats.decisions }}</div></div>
<div class="card"><div class="h">Trades (24h)</div><div class="v">{{ stats.trades }}</div></div>
<div class="card"><div class="h">Executed</div><div class="v">{{ stats.executed }}</div></div>
<div class="card"><div class="h">Failed</div><div class="v">{{ stats.failed }}</div></div>
</div>
<h3>Recent Decisions</h3>
<div class="card">
<table>
<thead><tr><th>Time</th><th>Symbol</th><th>Action</th><th>Confidence</th><th>Status</th><th>Reason</th></tr></thead>
<tbody>
{% for d in decisions %}
<tr>
<td>{{ d.ts }}</td><td>{{ d.symbol }}</td>
<td class="{{ d.action }}">{{ d.action }}</td>
<td>{{ '%.2f'|format(d.confidence or 0) }}</td>
<td>{{ d.status }}</td>
<td>{{ d.reason }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<h3>Recent Trades</h3>
<div class="card">
<table>
<thead><tr><th>Time</th><th>Symbol</th><th>Side</th><th>Notional</th><th>Order ID</th></tr></thead>
<tbody>
{% for t in trades %}
<tr><td>{{ t.ts }}</td><td>{{ t.symbol }}</td><td class="{{ t.side }}">{{ t.side }}</td><td>${{ '%.2f'|format(t.notional or 0) }}</td><td>{{ t.alpaca_order_id }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</body>
</html>