From cce199d73afd0837af653b30adffc7d36d3cbb52 Mon Sep 17 00:00:00 2001 From: drjones Date: Wed, 25 Feb 2026 18:51:24 -0800 Subject: [PATCH] Initial v1 autonomous alpaca llm trading bot with dark dashboard --- .env.example | 24 ++++++++++ README.md | 33 +++++++++++++ app.py | 46 ++++++++++++++++++ bot.py | 55 ++++++++++++++++++++++ config.py | 27 +++++++++++ db.py | 35 ++++++++++++++ requirements.txt | 8 ++++ run.sh | 6 +++ services.py | 109 +++++++++++++++++++++++++++++++++++++++++++ templates/index.html | 56 ++++++++++++++++++++++ 10 files changed, 399 insertions(+) create mode 100644 .env.example create mode 100644 README.md create mode 100644 app.py create mode 100644 bot.py create mode 100644 config.py create mode 100644 db.py create mode 100644 requirements.txt create mode 100755 run.sh create mode 100644 services.py create mode 100644 templates/index.html 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 }}
+
+ +