diff --git a/.env.example b/.env.example index 5ba7b55..fd5697c 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 3bae18e..3d70963 100644 --- a/README.md +++ b/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://: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. diff --git a/__pycache__/app.cpython-312.pyc b/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000..34104a7 Binary files /dev/null and b/__pycache__/app.cpython-312.pyc differ diff --git a/__pycache__/bot.cpython-312.pyc b/__pycache__/bot.cpython-312.pyc new file mode 100644 index 0000000..6e6b928 Binary files /dev/null and b/__pycache__/bot.cpython-312.pyc differ diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..a64e7c3 Binary files /dev/null and b/__pycache__/config.cpython-312.pyc differ diff --git a/__pycache__/db.cpython-312.pyc b/__pycache__/db.cpython-312.pyc new file mode 100644 index 0000000..8534708 Binary files /dev/null and b/__pycache__/db.cpython-312.pyc differ diff --git a/__pycache__/services.cpython-312.pyc b/__pycache__/services.cpython-312.pyc new file mode 100644 index 0000000..e12be40 Binary files /dev/null and b/__pycache__/services.cpython-312.pyc differ diff --git a/app.py b/app.py index b33c276..611fca2 100644 --- a/app.py +++ b/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() diff --git a/bot.py b/bot.py index 02aa83e..7c47798 100644 --- a/bot.py +++ b/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() diff --git a/config.py b/config.py index 693b832..61b0c87 100644 --- a/config.py +++ b/config.py @@ -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") diff --git a/db.py b/db.py index 084d4b8..167432a 100644 --- a/db.py +++ b/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) diff --git a/services.py b/services.py index 83ae10b..5c4c291 100644 --- a/services.py +++ b/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 diff --git a/systemd/alpaca-llm-bot-v1.service b/systemd/alpaca-llm-bot-v1.service new file mode 100644 index 0000000..e85cdd8 --- /dev/null +++ b/systemd/alpaca-llm-bot-v1.service @@ -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 diff --git a/templates/index.html b/templates/index.html index 571c9de..dbf006c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -7,11 +7,12 @@ @@ -21,24 +22,41 @@
Trades (24h)
{{ stats.trades }}
Executed
{{ stats.executed }}
Failed
{{ stats.failed }}
+
Curated Insights
{{ stats.insights }}
-

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 Decisions

+
+ + + + {% for d in decisions %} + + + + + + + + {% endfor %} + +
TimeSymbolActionConfidenceStatusReason
{{ d.ts }}{{ d.symbol }}{{ d.action }}{{ '%.2f'|format(d.confidence or 0) }}{{ d.status }}{{ d.reason }}
+
+
+ +
+

Curated Market Briefs

+
+ {% for i in insights %} +
+
{{ i.symbol }} ยท {{ i.ts }}
+
{{ i.summary }}
+
+ {% endfor %} +
+

Recent Trades