commit cce199d73afd0837af653b30adffc7d36d3cbb52 Author: drjones Date: Wed Feb 25 18:51:24 2026 -0800 Initial v1 autonomous alpaca llm trading bot with dark dashboard diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5ba7b55 --- /dev/null +++ b/.env.example @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bae18e --- /dev/null +++ b/README.md @@ -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://: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 diff --git a/app.py b/app.py new file mode 100644 index 0000000..b33c276 --- /dev/null +++ b/app.py @@ -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() diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..02aa83e --- /dev/null +++ b/bot.py @@ -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() diff --git a/config.py b/config.py new file mode 100644 index 0000000..693b832 --- /dev/null +++ b/config.py @@ -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() diff --git a/db.py b/db.py new file mode 100644 index 0000000..084d4b8 --- /dev/null +++ b/db.py @@ -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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e005201 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..e9b3971 --- /dev/null +++ b/run.sh @@ -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} diff --git a/services.py b/services.py new file mode 100644 index 0000000..83ae10b --- /dev/null +++ b/services.py @@ -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 {} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..571c9de --- /dev/null +++ b/templates/index.html @@ -0,0 +1,56 @@ + + + + + + alpaca-llm-bot-v1 + + + +

alpaca-llm-bot-v1

+
+
Decisions (24h)
{{ stats.decisions }}
+
Trades (24h)
{{ stats.trades }}
+
Executed
{{ stats.executed }}
+
Failed
{{ stats.failed }}
+
+ +

Recent Decisions

+
+ + + + {% for d in decisions %} + + + + + + + + {% endfor %} + +
TimeSymbolActionConfidenceStatusReason
{{ d.ts }}{{ d.symbol }}{{ d.action }}{{ '%.2f'|format(d.confidence or 0) }}{{ d.status }}{{ d.reason }}
+
+ +

Recent Trades

+
+ + + + {% for t in trades %} + + {% endfor %} + +
TimeSymbolSideNotionalOrder ID
{{ t.ts }}{{ t.symbol }}{{ t.side }}${{ '%.2f'|format(t.notional or 0) }}{{ t.alpaca_order_id }}
+
+ +