v1.1 autonomous upgrade: continuous curation, risk engine, scheduler hardening, systemd service
This commit is contained in:
@@ -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
|
||||
|
||||
43
README.md
43
README.md
@@ -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.
|
||||
|
||||
BIN
__pycache__/app.cpython-312.pyc
Normal file
BIN
__pycache__/app.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/bot.cpython-312.pyc
Normal file
BIN
__pycache__/bot.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/config.cpython-312.pyc
Normal file
BIN
__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/db.cpython-312.pyc
Normal file
BIN
__pycache__/db.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/services.cpython-312.pyc
Normal file
BIN
__pycache__/services.cpython-312.pyc
Normal file
Binary file not shown.
13
app.py
13
app.py
@@ -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
59
bot.py
@@ -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()
|
||||
|
||||
@@ -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
8
db.py
@@ -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)
|
||||
|
||||
70
services.py
70
services.py
@@ -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
|
||||
|
||||
14
systemd/alpaca-llm-bot-v1.service
Normal file
14
systemd/alpaca-llm-bot-v1.service
Normal 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
|
||||
@@ -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,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">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>
|
||||
|
||||
<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 class="row">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user