v1.1 autonomous upgrade: continuous curation, risk engine, scheduler hardening, systemd service

This commit is contained in:
2026-02-25 19:09:05 -08:00
parent cce199d73a
commit 4d17bf8018
14 changed files with 209 additions and 59 deletions

View File

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

View File

@@ -1,33 +1,50 @@
# alpaca-llm-bot-v1 # alpaca-llm-bot-v1
Autonomous 2-hour trading loop powered by: Autonomous LLM trading system (paper-first) powered by:
- Alpaca trading API - Alpaca trading API
- Ollama local model inference - Ollama local model inference
- Searx web data ingestion - Searx web data ingestion
- FastAPI dark dashboard - FastAPI dark dashboard
- APScheduler autonomous loops
## Safety defaults ## Autonomous behavior
- `PAPER_MODE=true` - Curates market/news context every `CURATE_INTERVAL_MINUTES` (default 30)
- max order notional `$5` - Runs trade decision cycle every `TRADE_INTERVAL_HOURS` (default 2)
- confidence gate `>= 0.55` before order placement - 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 ## Quick start
```bash ```bash
cp .env.example .env cp .env.example .env
# fill ALPACA_API_KEY / ALPACA_API_SECRET # fill ALPACA_API_KEY / ALPACA_API_SECRET
chmod +x run.sh python3 -m venv .venv
./run.sh 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/` - `http://<host>:8089/`
Trigger immediate cycle: Manual triggers:
```bash ```bash
curl -X POST http://127.0.0.1:8089/curate-now
curl -X POST http://127.0.0.1:8089/run-now curl -X POST http://127.0.0.1:8089/run-now
``` ```
## Production notes ## Production service (systemd)
- Put behind reverse proxy + auth ```bash
- Keep paper mode until behavior validated sudo cp systemd/alpaca-llm-bot-v1.service /etc/systemd/system/
- Add hard stop-loss and max daily drawdown before enabling live mode 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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

13
app.py
View File

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

59
bot.py
View File

@@ -1,17 +1,49 @@
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from datetime import datetime from datetime import datetime, timedelta
import json import json
from sqlalchemy import func
from config import settings from config import settings
from db import SessionLocal, BotDecision, TradeExecution from db import SessionLocal, BotDecision, TradeExecution, CuratedInsight
from services import searx_news, ollama_decide, place_order from services import searx_news, ollama_decide, place_order, market_open, positions_snapshot, summarize_news_with_ollama
scheduler = BackgroundScheduler(timezone=settings.timezone) 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(): def run_cycle():
db = SessionLocal() db = SessionLocal()
try: 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: for symbol in settings.symbols:
if spent >= settings.max_daily_notional:
break
news = searx_news(symbol) news = searx_news(symbol)
decision = ollama_decide(symbol, news) decision = ollama_decide(symbol, news)
@@ -28,8 +60,20 @@ def run_cycle():
db.commit() db.commit()
db.refresh(drow) db.refresh(drow)
if decision["action"] in {"buy", "sell"} and decision["confidence"] >= 0.55: should_trade = (
res = place_order(symbol, decision["action"], min(settings.max_order_usd, decision["order_usd"])) 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")) ok = bool(res and res.get("ok"))
drow.status = "executed" if ok else "failed" drow.status = "executed" if ok else "failed"
db.add(drow) db.add(drow)
@@ -37,11 +81,13 @@ def run_cycle():
symbol=symbol, symbol=symbol,
side=decision["action"], side=decision["action"],
qty=float((res or {}).get("json", {}).get("qty", 0) or 0), 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", ""), alpaca_order_id=(res or {}).get("json", {}).get("id", ""),
raw=json.dumps(res)[:60000], raw=json.dumps(res)[:60000],
)) ))
db.commit() db.commit()
if ok:
spent += notional
else: else:
drow.status = "skipped" drow.status = "skipped"
db.add(drow) db.add(drow)
@@ -51,5 +97,6 @@ def run_cycle():
def start_scheduler(): 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.add_job(run_cycle, "interval", hours=settings.trade_interval_hours, id="trade_cycle", replace_existing=True)
scheduler.start() scheduler.start()

View File

@@ -10,7 +10,12 @@ class Settings:
paper_mode = os.getenv("PAPER_MODE", "true").lower() == "true" paper_mode = os.getenv("PAPER_MODE", "true").lower() == "true"
max_order_usd = float(os.getenv("MAX_ORDER_USD", "5")) 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")) 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") timezone = os.getenv("TIMEZONE", "America/Los_Angeles")
ollama_url = os.getenv("OLLAMA_URL", "http://10.30.20.110:11434") 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)) alpaca_order_id = Column(String(128))
raw = Column(Text) 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(): def init_db():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)

View File

@@ -1,12 +1,11 @@
import json import json
import random import random
import requests import requests
from datetime import datetime
from config import settings from config import settings
def searx_news(symbol: str, limit: int = 8): def searx_news(symbol: str, limit: int = 10):
q = f"{symbol} stock news earnings guidance analyst" q = f"{symbol} stock news earnings guidance analyst macro risk"
params = {"q": q, "format": "json", "language": "en"} params = {"q": q, "format": "json", "language": "en"}
try: try:
r = requests.get(settings.searx_url, params=params, timeout=20) 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() data = r.json()
out = [] out = []
for it in data.get("results", [])[:limit]: 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 return out
except Exception: except Exception:
return [] 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): def ollama_decide(symbol: str, context_items: list):
prompt = { 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": { "constraints": {
"actions": ["buy", "sell", "hold"], "actions": ["buy", "sell", "hold"],
"max_order_usd": settings.max_order_usd, "max_order_usd": settings.max_order_usd,
"style": "conservative intraday swing", "risk": "do not overtrade; prefer hold on weak signal",
}, },
"symbol": symbol, "symbol": symbol,
"news": context_items, "news": context_items,
@@ -44,19 +65,18 @@ def ollama_decide(symbol: str, context_items: list):
"format": "json", "format": "json",
} }
try: 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() r.raise_for_status()
resp = r.json().get("response", "{}") resp = r.json().get("response", "{}")
d = json.loads(resp) d = json.loads(resp)
action = d.get("action", "hold").lower() action = str(d.get("action", "hold")).lower()
if action not in {"buy", "sell", "hold"}: if action not in {"buy", "sell", "hold"}:
action = "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) order_usd = min(float(d.get("order_usd", settings.max_order_usd)), settings.max_order_usd)
reason = d.get("reason", "fallback") reason = d.get("reason", "fallback")
return {"action": action, "confidence": confidence, "order_usd": order_usd, "reason": reason} return {"action": action, "confidence": confidence, "order_usd": order_usd, "reason": reason}
except Exception: except Exception:
# resilient fallback to hold or tiny buy
return { return {
"action": random.choice(["hold", "hold", "buy"]), "action": random.choice(["hold", "hold", "buy"]),
"confidence": 0.3, "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): def place_order(symbol: str, action: str, order_usd: float):
if action not in {"buy", "sell"}: if action not in {"buy", "sell"}:
return None return None
@@ -107,3 +117,23 @@ def account_snapshot():
return r.json() return r.json()
except Exception: except Exception:
return {} 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> <style>
:root { --bg:#0b0f17; --card:#111827; --text:#e5e7eb; --muted:#9ca3af; --ok:#10b981; --bad:#ef4444; --acc:#60a5fa; } :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} 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} .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} .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} 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)} .buy{color:var(--ok)} .sell{color:var(--bad)} .hold{color:var(--muted)}
.row{display:grid;grid-template-columns:2fr 1fr;gap:12px}
</style> </style>
</head> </head>
<body> <body>
@@ -21,24 +22,41 @@
<div class="card"><div class="h">Trades (24h)</div><div class="v">{{ stats.trades }}</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">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">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>
<h3>Recent Decisions</h3> <div class="row">
<div class="card"> <div>
<table> <h3>Recent Decisions</h3>
<thead><tr><th>Time</th><th>Symbol</th><th>Action</th><th>Confidence</th><th>Status</th><th>Reason</th></tr></thead> <div class="card">
<tbody> <table>
{% for d in decisions %} <thead><tr><th>Time</th><th>Symbol</th><th>Action</th><th>Confidence</th><th>Status</th><th>Reason</th></tr></thead>
<tr> <tbody>
<td>{{ d.ts }}</td><td>{{ d.symbol }}</td> {% for d in decisions %}
<td class="{{ d.action }}">{{ d.action }}</td> <tr>
<td>{{ '%.2f'|format(d.confidence or 0) }}</td> <td>{{ d.ts }}</td><td>{{ d.symbol }}</td>
<td>{{ d.status }}</td> <td class="{{ d.action }}">{{ d.action }}</td>
<td>{{ d.reason }}</td> <td>{{ '%.2f'|format(d.confidence or 0) }}</td>
</tr> <td>{{ d.status }}</td>
{% endfor %} <td>{{ d.reason }}</td>
</tbody> </tr>
</table> {% endfor %}
</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> </div>
<h3>Recent Trades</h3> <h3>Recent Trades</h3>