Initial v1 autonomous alpaca llm trading bot with dark dashboard
This commit is contained in:
24
.env.example
Normal file
24
.env.example
Normal 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
33
README.md
Normal 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
46
app.py
Normal 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
55
bot.py
Normal 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
27
config.py
Normal 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
35
db.py
Normal 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
8
requirements.txt
Normal 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
6
run.sh
Executable 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
109
services.py
Normal 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
56
templates/index.html
Normal 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>
|
||||
Reference in New Issue
Block a user