Compare commits

..

2 Commits

10 changed files with 214 additions and 59 deletions

View File

@@ -6,7 +6,11 @@ ALPACA_BASE_URL=https://paper-api.alpaca.markets
# Runtime
PAPER_MODE=true
MAX_ORDER_USD=5
MAX_DAILY_NOTIONAL=50
MAX_OPEN_POSITIONS=6
MIN_CONFIDENCE=0.60
TRADE_INTERVAL_HOURS=2
CURATE_INTERVAL_MINUTES=30
TIMEZONE=America/Los_Angeles
# Ollama

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
__pycache__/
*.pyc
.venv/
.env
bot.db

View File

@@ -1,33 +1,50 @@
# alpaca-llm-bot-v1
Autonomous 2-hour trading loop powered by:
Autonomous LLM trading system (paper-first) powered by:
- Alpaca trading API
- Ollama local model inference
- Searx web data ingestion
- FastAPI dark dashboard
- APScheduler autonomous loops
## Safety defaults
- `PAPER_MODE=true`
- max order notional `$5`
- confidence gate `>= 0.55` before order placement
## Autonomous behavior
- Curates market/news context every `CURATE_INTERVAL_MINUTES` (default 30)
- Runs trade decision cycle every `TRADE_INTERVAL_HOURS` (default 2)
- LLM decides buy/sell/hold per symbol
- Executes only if confidence >= `MIN_CONFIDENCE`
- Hard risk caps:
- max `$5` order notional (default)
- max daily notional cap
- max open positions cap
- market-open gate
## Quick start
```bash
cp .env.example .env
# fill ALPACA_API_KEY / ALPACA_API_SECRET
chmod +x run.sh
./run.sh
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app:app --host 0.0.0.0 --port 8089
```
Open dashboard:
Dashboard:
- `http://<host>:8089/`
Trigger immediate cycle:
Manual triggers:
```bash
curl -X POST http://127.0.0.1:8089/curate-now
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
## Production service (systemd)
```bash
sudo cp systemd/alpaca-llm-bot-v1.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now alpaca-llm-bot-v1
sudo systemctl status alpaca-llm-bot-v1
```
## Important
- Keep `PAPER_MODE=true` until you observe stable behavior.
- This is experimental and not financial advice.

13
app.py
View File

@@ -3,8 +3,8 @@ 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 db import init_db, SessionLocal, BotDecision, TradeExecution, CuratedInsight
from bot import start_scheduler, run_cycle, curate_cycle
from services import account_snapshot
app = FastAPI(title="alpaca-llm-bot-v1")
@@ -24,6 +24,11 @@ def run_now():
run_cycle()
return {"ok": True, "ran": True}
@app.post("/curate-now")
def curate_now():
curate_cycle()
return {"ok": True, "curated": True}
@app.get("/api/account")
def api_account():
return JSONResponse(account_snapshot())
@@ -35,12 +40,14 @@ def home(request: Request):
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()
insights = db.query(CuratedInsight).order_by(CuratedInsight.ts.desc()).limit(30).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,
"insights": db.query(func.count(CuratedInsight.id)).filter(CuratedInsight.ts >= since).scalar() or 0,
}
return templates.TemplateResponse("index.html", {"request": request, "decisions": decisions, "trades": trades, "stats": stats})
return templates.TemplateResponse("index.html", {"request": request, "decisions": decisions, "trades": trades, "insights": insights, "stats": stats})
finally:
db.close()

59
bot.py
View File

@@ -1,17 +1,49 @@
from apscheduler.schedulers.background import BackgroundScheduler
from datetime import datetime
from datetime import datetime, timedelta
import json
from sqlalchemy import func
from config import settings
from db import SessionLocal, BotDecision, TradeExecution
from services import searx_news, ollama_decide, place_order
from db import SessionLocal, BotDecision, TradeExecution, CuratedInsight
from services import searx_news, ollama_decide, place_order, market_open, positions_snapshot, summarize_news_with_ollama
scheduler = BackgroundScheduler(timezone=settings.timezone)
def _daily_spent(db):
since = datetime.utcnow() - timedelta(hours=24)
return float(db.query(func.coalesce(func.sum(TradeExecution.notional), 0)).filter(TradeExecution.ts >= since).scalar() or 0)
def curate_cycle():
db = SessionLocal()
try:
for symbol in settings.symbols:
news = searx_news(symbol)
summary = summarize_news_with_ollama(symbol, news)
row = CuratedInsight(symbol=symbol, summary=summary, sources=json.dumps(news)[:60000])
db.add(row)
db.commit()
finally:
db.close()
def run_cycle():
db = SessionLocal()
try:
if not market_open():
return
spent = _daily_spent(db)
if spent >= settings.max_daily_notional:
return
pos = positions_snapshot()
if len(pos) >= settings.max_open_positions:
return
for symbol in settings.symbols:
if spent >= settings.max_daily_notional:
break
news = searx_news(symbol)
decision = ollama_decide(symbol, news)
@@ -28,8 +60,20 @@ def run_cycle():
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"]))
should_trade = (
decision["action"] in {"buy", "sell"}
and decision["confidence"] >= settings.min_confidence
)
if should_trade:
notional = min(settings.max_order_usd, decision["order_usd"], settings.max_daily_notional - spent)
if notional <= 0:
drow.status = "risk_blocked"
db.add(drow)
db.commit()
continue
res = place_order(symbol, decision["action"], notional)
ok = bool(res and res.get("ok"))
drow.status = "executed" if ok else "failed"
db.add(drow)
@@ -37,11 +81,13 @@ def run_cycle():
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"]),
notional=notional,
alpaca_order_id=(res or {}).get("json", {}).get("id", ""),
raw=json.dumps(res)[:60000],
))
db.commit()
if ok:
spent += notional
else:
drow.status = "skipped"
db.add(drow)
@@ -51,5 +97,6 @@ def run_cycle():
def start_scheduler():
scheduler.add_job(curate_cycle, "interval", minutes=settings.curate_interval_minutes, id="curate_cycle", replace_existing=True)
scheduler.add_job(run_cycle, "interval", hours=settings.trade_interval_hours, id="trade_cycle", replace_existing=True)
scheduler.start()

View File

@@ -10,7 +10,12 @@ class Settings:
paper_mode = os.getenv("PAPER_MODE", "true").lower() == "true"
max_order_usd = float(os.getenv("MAX_ORDER_USD", "5"))
max_daily_notional = float(os.getenv("MAX_DAILY_NOTIONAL", "50"))
max_open_positions = int(os.getenv("MAX_OPEN_POSITIONS", "6"))
min_confidence = float(os.getenv("MIN_CONFIDENCE", "0.60"))
trade_interval_hours = int(os.getenv("TRADE_INTERVAL_HOURS", "2"))
curate_interval_minutes = int(os.getenv("CURATE_INTERVAL_MINUTES", "30"))
timezone = os.getenv("TIMEZONE", "America/Los_Angeles")
ollama_url = os.getenv("OLLAMA_URL", "http://10.30.20.110:11434")

8
db.py
View File

@@ -30,6 +30,14 @@ class TradeExecution(Base):
alpaca_order_id = Column(String(128))
raw = Column(Text)
class CuratedInsight(Base):
__tablename__ = "insights"
id = Column(Integer, primary_key=True)
ts = Column(DateTime, default=datetime.utcnow)
symbol = Column(String(16), index=True)
summary = Column(Text)
sources = Column(Text)
def init_db():
Base.metadata.create_all(bind=engine)

View File

@@ -1,12 +1,11 @@
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"
def searx_news(symbol: str, limit: int = 10):
q = f"{symbol} stock news earnings guidance analyst macro risk"
params = {"q": q, "format": "json", "language": "en"}
try:
r = requests.get(settings.searx_url, params=params, timeout=20)
@@ -14,19 +13,41 @@ def searx_news(symbol: str, limit: int = 8):
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]})
out.append({"title": it.get("title", ""), "url": it.get("url", ""), "content": it.get("content", "")[:500]})
return out
except Exception:
return []
def summarize_news_with_ollama(symbol: str, context_items: list):
payload = {
"model": settings.ollama_model,
"stream": False,
"prompt": json.dumps({
"task": "Summarize market-moving info into a concise, neutral brief.",
"symbol": symbol,
"news": context_items,
"format": {"summary": "<=140 words", "bullish_points": ["..."], "bearish_points": ["..."]}
}),
"format": "json",
}
try:
r = requests.post(f"{settings.ollama_url}/api/generate", json=payload, timeout=45)
r.raise_for_status()
resp = r.json().get("response", "{}")
parsed = json.loads(resp)
return parsed.get("summary", "no-summary")
except Exception:
return "summary-unavailable"
def ollama_decide(symbol: str, context_items: list):
prompt = {
"task": "You are a strict trading policy engine. Return JSON only.",
"task": "You are a conservative autonomous trading policy engine. Return strict JSON only.",
"constraints": {
"actions": ["buy", "sell", "hold"],
"max_order_usd": settings.max_order_usd,
"style": "conservative intraday swing",
"risk": "do not overtrade; prefer hold on weak signal",
},
"symbol": symbol,
"news": context_items,
@@ -44,19 +65,18 @@ def ollama_decide(symbol: str, context_items: list):
"format": "json",
}
try:
r = requests.post(f"{settings.ollama_url}/api/generate", json=payload, timeout=40)
r = requests.post(f"{settings.ollama_url}/api/generate", json=payload, timeout=45)
r.raise_for_status()
resp = r.json().get("response", "{}")
d = json.loads(resp)
action = d.get("action", "hold").lower()
action = str(d.get("action", "hold")).lower()
if action not in {"buy", "sell", "hold"}:
action = "hold"
confidence = float(d.get("confidence", 0.5))
confidence = max(0.0, min(1.0, 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,
@@ -73,16 +93,6 @@ def alpaca_headers():
}
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
@@ -107,3 +117,23 @@ def account_snapshot():
return r.json()
except Exception:
return {}
def positions_snapshot():
try:
r = requests.get(f"{settings.alpaca_base}/v2/positions", headers=alpaca_headers(), timeout=20)
if r.ok:
return r.json()
except Exception:
pass
return []
def market_open():
try:
r = requests.get(f"{settings.alpaca_base}/v2/clock", headers=alpaca_headers(), timeout=20)
if r.ok:
return bool(r.json().get("is_open", False))
except Exception:
pass
return False

View File

@@ -0,0 +1,14 @@
[Unit]
Description=alpaca-llm-bot-v1 autonomous trading bot
After=network.target
[Service]
Type=simple
WorkingDirectory=/home/drjones/.openclaw/workspace/alpaca-llm-bot-v1
EnvironmentFile=/home/drjones/.openclaw/workspace/alpaca-llm-bot-v1/.env
ExecStart=/home/drjones/.openclaw/workspace/alpaca-llm-bot-v1/.venv/bin/uvicorn app:app --host 0.0.0.0 --port 8089
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -7,11 +7,12 @@
<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}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,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)}
.row{display:grid;grid-template-columns:2fr 1fr;gap:12px}
</style>
</head>
<body>
@@ -21,8 +22,11 @@
<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 class="card"><div class="h">Curated Insights</div><div class="v">{{ stats.insights }}</div></div>
</div>
<div class="row">
<div>
<h3>Recent Decisions</h3>
<div class="card">
<table>
@@ -40,6 +44,20 @@
</tbody>
</table>
</div>
</div>
<div>
<h3>Curated Market Briefs</h3>
<div class="card" style="max-height:520px;overflow:auto">
{% for i in insights %}
<div style="margin-bottom:10px;padding-bottom:10px;border-bottom:1px solid #1f2937">
<div><b>{{ i.symbol }}</b> · <span class="h">{{ i.ts }}</span></div>
<div style="font-size:13px;line-height:1.4">{{ i.summary }}</div>
</div>
{% endfor %}
</div>
</div>
</div>
<h3>Recent Trades</h3>
<div class="card">