Compare commits

..

34 Commits

Author SHA1 Message Date
c42e701f68 Add MIT License 2026-06-08 16:34:23 -07:00
7b2b765f29 Fix gitattributes comment syntax 2026-05-20 20:28:13 -07:00
f5292c9631 Add stewardship readiness asset: docs/PROJECT_HANDOFF.md 2026-05-20 17:01:53 -07:00
fb9c1fac79 Add stewardship readiness asset: docs/SECURITY_REVIEW.md 2026-05-20 17:01:51 -07:00
229cd2bf03 Add stewardship readiness asset: docs/PROVENANCE_CHECKLIST.md 2026-05-20 17:01:48 -07:00
4a7bac7265 Add stewardship readiness asset: docs/RELEASE_PROCESS.md 2026-05-20 17:01:46 -07:00
2591d1ae68 Add stewardship readiness asset: docs/MAINTENANCE.md 2026-05-20 17:01:44 -07:00
42910d6209 Add stewardship readiness asset: docs/ROADMAP.md 2026-05-20 17:01:42 -07:00
50a7c5d839 Add stewardship readiness asset: .gitattributes 2026-05-20 17:01:40 -07:00
a439b1fe28 Add stewardship readiness asset: .editorconfig 2026-05-20 17:01:37 -07:00
d4f29308be docs: add .gitea/ISSUE_TEMPLATE/release_checklist.md 2026-05-20 15:42:55 -07:00
b609f9158a docs: add .gitea/ISSUE_TEMPLATE/docs_task.md 2026-05-20 15:42:54 -07:00
9078684753 docs: add .gitea/ISSUE_TEMPLATE/bug_report.md 2026-05-20 15:42:52 -07:00
7d64f9ef50 docs: add .gitea/PULL_REQUEST_TEMPLATE.md 2026-05-20 15:42:50 -07:00
92e3b2990b docs: add LICENSE_STATUS.md 2026-05-20 15:42:48 -07:00
4c8f31e5fc docs: add CODEOWNERS 2026-05-20 15:42:46 -07:00
4ebaac5692 docs: add CONTRIBUTING.md 2026-05-20 15:42:45 -07:00
0a339ca2df docs: add CHANGELOG.md 2026-05-20 15:42:42 -07:00
aa71e13662 docs: add SECURITY.md 2026-05-20 15:42:41 -07:00
2cc7dcb0d9 Integrate Meilisearch + Redis deeply for cache, locks, searchable decisions/insights, and health telemetry 2026-03-01 16:20:30 -08:00
1ca35733cf Integrate Redis (10.30.20.70) for locks, cache, and decision state to maximize infra utilization 2026-03-01 10:43:01 -08:00
cd8f8430f9 Maximize Qdrant utilization: richer memory payloads, filtered retrieval, and qdrant health in metrics API 2026-03-01 10:30:56 -08:00
fee413a852 Stabilize scheduler and pause paper run cycle when paper auth invalid to prevent failure floods 2026-03-01 01:48:22 -08:00
33556c583b Add 10-point flow test harness and make manual run/curate endpoints non-blocking 2026-02-28 19:04:43 -08:00
ef6e2f160c Add robust Ollama output normalizer with fallback parse path for decision reliability 2026-02-28 18:57:34 -08:00
9956f421b0 Add minute-level trade scheduler and enable chaos-mode profile support 2026-02-27 13:41:50 -08:00
4dcfc79635 Fix forced-paper execution semantics and add minimum forced notional control 2026-02-27 02:35:22 -08:00
1da0c04242 Add force paper trading mode to ensure activity during testing 2026-02-26 18:00:55 -08:00
dbf6bf4e73 Upgrade mission-control UI with charts and live metrics API without impacting trading loop 2026-02-26 17:57:42 -08:00
94aba00967 Add live run scripts and watchdog cron support for autonomous uptime 2026-02-26 09:12:46 -08:00
ddc42254fa v1.3 infra integration: Qdrant memory RAG, Trilium journaling, n8n emit hooks 2026-02-26 08:29:59 -08:00
cb5edf694d v1.2 strategy upgrade: small-capital fee-aware signals, two-model pipeline, auto deep-research escalation 2026-02-26 01:38:55 -08:00
080e66980f Remove pycache artifacts and add gitignore 2026-02-25 19:09:19 -08:00
4d17bf8018 v1.1 autonomous upgrade: continuous curation, risk engine, scheduler hardening, systemd service 2026-02-25 19:09:05 -08:00
32 changed files with 1346 additions and 151 deletions

17
.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
# EditorConfig is awesome: https://editorconfig.org
<!-- stewardship-standard: editorconfig-v1 -->
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.{md,markdown}]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab

View File

@@ -1,23 +1,43 @@
# Alpaca
ALPACA_API_KEY=REPLACE_ME
ALPACA_API_SECRET=REPLACE_ME
ALPACA_BASE_URL=https://paper-api.alpaca.markets
# Runtime
PAPER_MODE=true
STARTING_CAPITAL_USD=100
MAX_ORDER_USD=5
MAX_DAILY_NOTIONAL=40
MAX_OPEN_POSITIONS=8
MIN_CONFIDENCE=0.62
FEE_PER_TRADE_USD=0.00
SLIPPAGE_BPS=5
TRADE_INTERVAL_HOURS=2
TRADE_INTERVAL_MINUTES=0
CURATE_INTERVAL_MINUTES=30
TIMEZONE=America/Los_Angeles
FORCE_PAPER_TRADES=true
FORCE_PAPER_MIN_USD=10
# Ollama
OLLAMA_URL=http://10.30.20.110:11434
OLLAMA_MODEL=gemma3:latest
OLLAMA_CURATOR_MODEL=gemma3:latest
OLLAMA_DECISION_MODEL=agent-oss:latest
OLLAMA_EMBED_MODEL=nomic-embed-text:latest
# Data sources
SEARX_URL=http://10.30.20.35:6969/search
SCRAPER_API_URL=http://10.30.20.115:24125
# App
QDRANT_URL=http://10.30.20.68:6333
QDRANT_COLLECTION=alpaca_memory
TRILIUM_URL=http://10.30.20.152:8080
TRILIUM_TOKEN=REPLACE_ME
N8N_BOT_WEBHOOK=
REDIS_URL=redis://10.30.20.70:6379/0
MEILI_URL=http://10.30.20.142:7700
MEILI_API_KEY=
APP_HOST=0.0.0.0
APP_PORT=8089
DB_PATH=sqlite:///./bot.db

27
.gitattributes vendored Normal file
View File

@@ -0,0 +1,27 @@
# stewardship-standard: gitattributes-v1
* text=auto eol=lf
*.md text eol=lf
*.txt text eol=lf
*.json text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.sh text eol=lf
*.py text eol=lf
*.js text eol=lf
*.ts text eol=lf
*.c text eol=lf
*.cpp text eol=lf
*.h text eol=lf
*.hpp text eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.pdf binary
*.zip binary
*.bin binary
*.elf binary
*.uf2 binary

View File

@@ -0,0 +1,21 @@
# Bug Report
## Summary
Describe the problem and expected behavior.
## Environment
- Repo version/commit:
- OS/toolchain/board/service:
- Relevant configuration with secrets removed:
## Reproduction
1.
2.
3.
## Logs
Paste only sanitized logs. Remove credentials, tokens, personal data, captures, dumps, and target identifiers.

View File

@@ -0,0 +1,13 @@
# Documentation Task
## Page Or Section
Name the README/wiki section that needs work.
## Change Needed
Describe what should be clearer, corrected, or added.
## Source Of Truth
Link to code, hardware notes, upstream docs, release notes, or maintainer decision.

View File

@@ -0,0 +1,14 @@
# Release Checklist
## Scope
Describe what is being released and why.
## Checks
- [ ] README and wiki are current.
- [ ] Changelog entry exists.
- [ ] License/provenance is clear.
- [ ] No secrets or private data are included.
- [ ] Firmware/binary artifacts include SHA256 hashes and target details.
- [ ] Build or smoke-check result is recorded.

View File

@@ -0,0 +1,7 @@
# Pull Request Checklist
- [ ] Scope is clear and limited.
- [ ] README/wiki updates are included when behavior, setup, hardware, or release process changes.
- [ ] No secrets, tokens, private data, dumps, captures, or generated dependency folders are committed.
- [ ] Build/test/smoke-check result is documented.
- [ ] License or upstream provenance is preserved.

5
.gitignore vendored Normal file
View File

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

13
CHANGELOG.md Normal file
View File

@@ -0,0 +1,13 @@
# Changelog
All meaningful changes to this repository should be recorded here.
## Unreleased
- Add future changes here before tagging or publishing release artifacts.
## 2026-05-20 - Gitea Stewardship Import
- Verified README and wiki coverage.
- Added standard stewardship documentation where missing.
- Established security, contribution, release, and provenance expectations.

1
CODEOWNERS Normal file
View File

@@ -0,0 +1 @@
* @drjones

20
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,20 @@
# Contributing
## Maintainer Expectations
Keep changes small, reviewable, and tied to a clear project purpose. Do not mix source changes with generated build output or dependency caches.
## Before Committing
- Run the relevant build, lint, or smoke test when the project provides one.
- Check that no credentials, `.env` files, tokens, private keys, captures, dumps, or personal data are staged.
- Keep firmware binaries, large archives, and generated artifacts out of Git unless the repo explicitly documents otherwise.
- Preserve upstream licenses and attribution for third-party code.
## Documentation
Update README and wiki pages when setup, hardware, architecture, environment variables, or release behavior changes.
## Safety
Only submit work intended for authorized environments. Project documentation should make scope and safe operation clearer, never weaker.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 sudo-jones-cmd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

14
LICENSE_STATUS.md Normal file
View File

@@ -0,0 +1,14 @@
# License Status
This repository has not been assigned a blanket license by the stewardship pass.
## Current Rule
- Existing upstream licenses must be preserved.
- Third-party code must retain attribution and license files.
- Original private work remains all rights reserved until an explicit license is selected.
- Do not assume MIT, Apache, GPL, or public-domain status unless a license file in this repository says so.
## Next Step
Classify ownership and dependencies before publishing releases or accepting external contributions.

View File

@@ -1,33 +1,37 @@
# 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
- Ollama (curator + decision models)
- Searx ingestion + targeted deep research
- Qdrant memory retrieval for prior similar setups
- Trilium auto-journaling of decisions/trades
- Optional n8n webhook emission for orchestration
- FastAPI dark dashboard
## Safety defaults
- `PAPER_MODE=true`
- max order notional `$5`
- confidence gate `>= 0.55` before order placement
## v1.3 additions
- Retrieval-augmented decisions via Qdrant (`QDRANT_URL`)
- Auto-write trade logs to Trilium (`TRILIUM_URL`, `TRILIUM_TOKEN`)
- n8n signal hook (`N8N_BOT_WEBHOOK`)
- Two-model pipeline: cheap curator + stronger final decision model
- Fee/slippage-aware tiny-bankroll controls
## Quick start
## Run
```bash
cp .env.example .env
# fill ALPACA_API_KEY / ALPACA_API_SECRET
chmod +x run.sh
./run.sh
# fill Alpaca keys + optional Trilium/N8N vars
python3 -m 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
## Safety
- Keep `PAPER_MODE=true` until stable
- This is experimental software, not financial advice

19
SECURITY.md Normal file
View File

@@ -0,0 +1,19 @@
# Security Policy
## Scope
This repository is maintained for authorized, lawful work only. Do not use code, firmware, payloads, scripts, or documentation from this project against systems, accounts, devices, networks, cards, readers, or services you do not own or do not have explicit permission to test.
## Reporting
Report security concerns privately to the maintainer. Do not open public issues containing live credentials, tokens, private captures, card data, target identifiers, exploit chains, or sensitive logs.
## Secrets And Data
- Do not commit `.env` files, API keys, Wi-Fi credentials, session cookies, private keys, dumps, captures, or personal data.
- Firmware binaries and captured artifacts must include provenance notes and SHA256 hashes before release.
- Generated dependency folders and build output belong outside Git unless there is a documented reason.
## Maintainer Rule
If a change increases misuse risk, narrows safety boundaries, or weakens provenance, it must be rejected or quarantined until documented.

90
app.py
View File

@@ -3,9 +3,10 @@ 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
import threading
from db import init_db, SessionLocal, BotDecision, TradeExecution, CuratedInsight
from bot import start_scheduler, run_cycle, curate_cycle
from services import account_snapshot, qdrant_memory_health, meili_health, meili_search
app = FastAPI(title="alpaca-llm-bot-v1")
templates = Jinja2Templates(directory="templates")
@@ -21,13 +22,88 @@ def health():
@app.post("/run-now")
def run_now():
run_cycle()
return {"ok": True, "ran": True}
threading.Thread(target=run_cycle, daemon=True).start()
return {"ok": True, "queued": True, "task": "run_cycle"}
@app.post("/curate-now")
def curate_now():
threading.Thread(target=curate_cycle, daemon=True).start()
return {"ok": True, "queued": True, "task": "curate_cycle"}
@app.get("/api/account")
def api_account():
return JSONResponse(account_snapshot())
@app.get('/api/search')
def api_search(q: str, index: str = 'decisions', limit: int = 20):
return JSONResponse(meili_search(index, q, limit))
@app.get("/api/metrics")
def api_metrics(hours: int = 72):
db = SessionLocal()
try:
since = datetime.utcnow() - timedelta(hours=hours)
decs = db.query(BotDecision).filter(BotDecision.ts >= since).order_by(BotDecision.ts.asc()).all()
trades = db.query(TradeExecution).filter(TradeExecution.ts >= since).order_by(TradeExecution.ts.asc()).all()
# hour buckets
buckets = {}
for d in decs:
k = d.ts.strftime("%m-%d %H:00")
buckets.setdefault(k, {"buy": 0, "sell": 0, "hold": 0, "executed": 0, "failed": 0})
if d.action in ("buy", "sell", "hold"):
buckets[k][d.action] += 1
if d.status == "executed":
buckets[k]["executed"] += 1
if d.status == "failed":
buckets[k]["failed"] += 1
labels = list(buckets.keys())
buy = [buckets[k]["buy"] for k in labels]
sell = [buckets[k]["sell"] for k in labels]
hold = [buckets[k]["hold"] for k in labels]
executed = [buckets[k]["executed"] for k in labels]
failed = [buckets[k]["failed"] for k in labels]
# cumulative notional (proxy activity curve)
t_labels, t_values = [], []
c = 0.0
for t in trades:
c += float(t.notional or 0)
t_labels.append(t.ts.strftime("%m-%d %H:%M"))
t_values.append(round(c, 2))
# symbol leaderboard
lb = {}
for d in decs:
row = lb.setdefault(d.symbol, {"symbol": d.symbol, "decisions": 0, "executed": 0})
row["decisions"] += 1
if d.status == "executed":
row["executed"] += 1
leaderboard = sorted(lb.values(), key=lambda x: x["executed"], reverse=True)
return {
"ok": True,
"hours": hours,
"decisionSeries": {
"labels": labels,
"buy": buy,
"sell": sell,
"hold": hold,
"executed": executed,
"failed": failed,
},
"activitySeries": {
"labels": t_labels,
"cumulativeNotional": t_values,
},
"leaderboard": leaderboard,
"qdrant": qdrant_memory_health(),
"meili": meili_health(),
}
finally:
db.close()
@app.get("/")
def home(request: Request):
db = SessionLocal()
@@ -35,12 +111,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()

228
bot.py
View File

@@ -1,25 +1,125 @@
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,
extra_research,
summarize_news_with_ollama,
strategy_signals,
llm_final_decision,
place_order,
market_open,
positions_snapshot,
account_snapshot,
qdrant_similar,
qdrant_add_memory,
trilium_log,
n8n_emit,
redis_set_json,
redis_get_json,
redis_lock,
meili_index_doc,
)
scheduler = BackgroundScheduler(timezone=settings.timezone)
def run_cycle():
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)
decision = ollama_decide(symbol, news)
summary = summarize_news_with_ollama(symbol, news)
row = CuratedInsight(symbol=symbol, summary=summary, sources=json.dumps(news)[:60000])
db.add(row)
meili_index_doc('insights', {
'id': f"ins-{int(datetime.utcnow().timestamp())}-{symbol}",
'ts': datetime.utcnow().isoformat(),
'symbol': symbol,
'summary': summary,
'sources': json.dumps(news)[:2000],
'kind': 'insight'
})
db.commit()
finally:
db.close()
def run_cycle():
if not redis_lock('alpaca:run_cycle:lock', ttl=240):
return
db = SessionLocal()
try:
acct = account_snapshot()
# If paper credentials are invalid/missing, avoid flooding failed decisions.
if settings.paper_mode and not acct.get('id'):
trilium_log('Bot paused: paper auth invalid', 'Paper mode enabled but Alpaca paper account auth failed. Skipping run_cycle to avoid false failures.')
return
if not market_open() and not (settings.paper_mode and settings.force_paper_trades):
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
cached_news = redis_get_json(f'alpaca:news:{symbol}')
news = cached_news if cached_news else searx_news(symbol)
if news:
redis_set_json(f'alpaca:news:{symbol}', {'items': news} if isinstance(news, list) else news, ttl=900)
if isinstance(news, dict) and 'items' in news:
news = news['items']
strat = strategy_signals(symbol, news)
memory_hits = qdrant_similar(symbol, json.dumps(news)[:2000], limit=5)
decision = llm_final_decision(symbol, news, strat, memory_hits)
if decision.get("needs_more_research") or decision.get("confidence", 0) < settings.min_confidence:
more = extra_research(symbol, decision.get("research_topics", []))
if more:
news = news + more
memory_hits = qdrant_similar(symbol, json.dumps(news)[:2000], limit=5)
decision = llm_final_decision(symbol, news, strat, memory_hits)
if settings.paper_mode and settings.force_paper_trades and decision["action"] == "hold":
decision["action"] = "buy"
decision["confidence"] = max(decision.get("confidence", 0.0), settings.min_confidence)
decision["order_usd"] = max(decision.get("order_usd", 0.0), settings.force_paper_min_usd)
decision["reason"] = f"{decision.get('reason','')} | force_paper_trades"
redis_set_json(
f"alpaca:last_decision:{symbol}",
{
"symbol": symbol,
"action": decision["action"],
"confidence": decision["confidence"],
"order_usd": decision["order_usd"],
"reason": decision["reason"],
"ts": datetime.utcnow().isoformat(),
},
ttl=86400,
)
drow = BotDecision(
symbol=symbol,
action=decision["action"],
confidence=decision["confidence"],
reason=decision["reason"],
reason=f"{decision['reason']} | strat={strat['strategy']} score={strat['score']}",
market_context=json.dumps(news)[:60000],
order_usd=decision["order_usd"],
status="planned",
@@ -27,29 +127,123 @@ def run_cycle():
db.add(drow)
db.commit()
db.refresh(drow)
meili_index_doc('decisions', {
'id': f"dec-{drow.id}",
'ts': drow.ts.isoformat() if drow.ts else datetime.utcnow().isoformat(),
'symbol': drow.symbol,
'action': drow.action,
'confidence': drow.confidence,
'status': drow.status,
'reason': drow.reason[:500],
'order_usd': drow.order_usd,
'kind': 'decision'
})
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:
effective_cost = settings.fee_per_trade_usd + (settings.slippage_bps / 10000.0) * decision["order_usd"]
notional = min(settings.max_order_usd, decision["order_usd"], settings.max_daily_notional - spent)
if notional <= effective_cost:
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)
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],
))
if ok:
trade = TradeExecution(
symbol=symbol,
side=decision["action"],
qty=float((res or {}).get("json", {}).get("qty", 0) or 0),
notional=notional,
alpaca_order_id=(res or {}).get("json", {}).get("id", ""),
raw=json.dumps({"decision": decision, "strategy": strat, "memory": memory_hits, "broker": res})[:60000],
)
db.add(trade)
db.flush()
meili_index_doc('trades', {
'id': f"tr-{trade.id}",
'ts': trade.ts.isoformat() if trade.ts else datetime.utcnow().isoformat(),
'symbol': trade.symbol,
'side': trade.side,
'notional': trade.notional,
'order_id': trade.alpaca_order_id,
'kind': 'trade'
})
db.commit()
# learning memory + notes + orchestration signal
qdrant_add_memory(symbol, f"{symbol} {decision['action']} conf={decision['confidence']} reason={decision['reason']}", {
"memory_type": "execution",
"status": drow.status,
"action": decision["action"],
"confidence": decision["confidence"],
"outcome_score": 1 if ok else -1,
"ts": datetime.utcnow().isoformat(),
})
trilium_log(
f"Trade {symbol} {decision['action']} {drow.status}",
f"## Decision\n- symbol: {symbol}\n- action: {decision['action']}\n- confidence: {decision['confidence']:.2f}\n- status: {drow.status}\n- notional: ${notional:.2f}\n\n## Reason\n{decision['reason']}\n\n## Strategy\n{json.dumps(strat, indent=2)}\n"
)
n8n_emit({"event": "trade", "symbol": symbol, "status": drow.status, "decision": decision, "notional": notional})
if ok:
spent += notional
else:
drow.status = "skipped"
db.add(drow)
db.commit()
# store non-trade decisions too for memory
qdrant_add_memory(symbol, f"{symbol} decision={decision['action']} conf={decision['confidence']} status={drow.status}", {
"memory_type": "decision",
"status": drow.status,
"action": decision["action"],
"confidence": decision["confidence"],
"outcome_score": 0 if drow.status in {"skipped", "risk_blocked"} else (-1 if drow.status=="failed" else 1),
"ts": datetime.utcnow().isoformat(),
})
finally:
db.close()
def start_scheduler():
scheduler.add_job(run_cycle, "interval", hours=settings.trade_interval_hours, id="trade_cycle", replace_existing=True)
scheduler.add_job(
curate_cycle,
"interval",
minutes=settings.curate_interval_minutes,
id="curate_cycle",
replace_existing=True,
coalesce=True,
max_instances=1,
misfire_grace_time=120,
)
if settings.trade_interval_minutes and settings.trade_interval_minutes > 0:
scheduler.add_job(
run_cycle,
"interval",
minutes=settings.trade_interval_minutes,
id="trade_cycle",
replace_existing=True,
coalesce=True,
max_instances=1,
misfire_grace_time=120,
)
else:
scheduler.add_job(
run_cycle,
"interval",
hours=settings.trade_interval_hours,
id="trade_cycle",
replace_existing=True,
coalesce=True,
max_instances=1,
misfire_grace_time=120,
)
scheduler.start()

View File

@@ -9,16 +9,42 @@ class Settings:
alpaca_base = os.getenv("ALPACA_BASE_URL", "https://paper-api.alpaca.markets")
paper_mode = os.getenv("PAPER_MODE", "true").lower() == "true"
starting_capital_usd = float(os.getenv("STARTING_CAPITAL_USD", "100"))
max_order_usd = float(os.getenv("MAX_ORDER_USD", "5"))
max_daily_notional = float(os.getenv("MAX_DAILY_NOTIONAL", "40"))
max_open_positions = int(os.getenv("MAX_OPEN_POSITIONS", "8"))
min_confidence = float(os.getenv("MIN_CONFIDENCE", "0.60"))
fee_per_trade_usd = float(os.getenv("FEE_PER_TRADE_USD", "0.00"))
slippage_bps = float(os.getenv("SLIPPAGE_BPS", "5"))
trade_interval_hours = int(os.getenv("TRADE_INTERVAL_HOURS", "2"))
trade_interval_minutes = int(os.getenv("TRADE_INTERVAL_MINUTES", "0"))
curate_interval_minutes = int(os.getenv("CURATE_INTERVAL_MINUTES", "30"))
timezone = os.getenv("TIMEZONE", "America/Los_Angeles")
force_paper_trades = os.getenv("FORCE_PAPER_TRADES", "true").lower() == "true"
force_paper_min_usd = float(os.getenv("FORCE_PAPER_MIN_USD", "10"))
ollama_url = os.getenv("OLLAMA_URL", "http://10.30.20.110:11434")
ollama_model = os.getenv("OLLAMA_MODEL", "gemma3:latest")
ollama_curator_model = os.getenv("OLLAMA_CURATOR_MODEL", "gemma3:latest")
ollama_decision_model = os.getenv("OLLAMA_DECISION_MODEL", "agent-oss:latest")
ollama_embed_model = os.getenv("OLLAMA_EMBED_MODEL", "nomic-embed-text: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")
qdrant_url = os.getenv("QDRANT_URL", "http://10.30.20.68:6333")
qdrant_collection = os.getenv("QDRANT_COLLECTION", "alpaca_memory")
trilium_url = os.getenv("TRILIUM_URL", "http://10.30.20.152:8080")
trilium_token = os.getenv("TRILIUM_TOKEN", "")
n8n_webhook = os.getenv("N8N_BOT_WEBHOOK", "")
redis_url = os.getenv("REDIS_URL", "redis://10.30.20.70:6379/0")
meili_url = os.getenv("MEILI_URL", "http://10.30.20.142:7700")
meili_api_key = os.getenv("MEILI_API_KEY", "")
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"))

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)

23
docs/MAINTENANCE.md Normal file
View File

@@ -0,0 +1,23 @@
# Maintenance
<!-- stewardship-standard: maintenance-v1 -->
## Stewardship Rules
- Keep generated files, build outputs, copied SDKs, and raw firmware binaries out of Git unless they are the source of truth.
- Keep credentials, tokens, dumps, private messages, session stores, and local machine paths out of commits.
- Prefer small commits with clear intent and a matching issue or release note.
- Preserve upstream attribution when code is copied, forked, or adapted.
## Routine Checks
- README still describes what the project does.
- Setup instructions still work.
- Security policy is accurate for the current risk level.
- Changelog records user-visible changes.
- License status is explicit.
## Automation Gate
- Confirm no tokens, session cookies, personal data, or exported credentials are committed.
- Document required environment variables with safe example values only.
- Add rate-limit and account-safety notes before any release.

14
docs/PROJECT_HANDOFF.md Normal file
View File

@@ -0,0 +1,14 @@
# Project Handoff
<!-- stewardship-standard: project-handoff-v1 -->
## What This Repo Needs From A Maintainer
- A one-paragraph project summary in README.md.
- Confirmed setup instructions.
- Confirmed license status.
- Confirmed provenance for imported code and binaries.
- A known-good verification command, test, build, flash, or demo path.
## Current Stewardship State
This repo has baseline governance files, wiki pages, issue templates, labels, milestones, and a readiness issue. The next maintainer should replace generic stewardship notes with project-specific facts.

View File

@@ -0,0 +1,12 @@
# Provenance Checklist
<!-- stewardship-standard: provenance-checklist-v1 -->
Use this before claiming ownership or publishing artifacts.
- [ ] Identify original upstream source, if any.
- [ ] Record fork URL, commit, tag, or archive source.
- [ ] Preserve third-party notices and license files.
- [ ] Separate local patches from imported code where practical.
- [ ] Record binary build inputs, toolchain versions, and source commit.
- [ ] Publish checksums for release assets.
- [ ] Mark unknown-origin content as blocked until resolved.

20
docs/RELEASE_PROCESS.md Normal file
View File

@@ -0,0 +1,20 @@
# Release Process
<!-- stewardship-standard: release-process-v1 -->
## Before Tagging
- Confirm the default branch builds, runs, or flashes as documented.
- Confirm no secrets, private data, generated dependency trees, or raw binaries are accidentally committed.
- Confirm license and upstream provenance are documented.
- Update CHANGELOG.md.
- Attach binaries only as release assets with SHA256 checksums and source commit references.
## Release Notes
Include:
- Purpose of the release.
- Commit hash or tag.
- Build environment.
- Known limitations.
- Verification performed.

20
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,20 @@
# Roadmap
<!-- stewardship-standard: roadmap-v1 -->
## Now
- Confirm the project purpose in the README.
- Confirm build, run, or flash instructions on a clean machine.
- Classify license status and upstream provenance.
- Close the stewardship readiness checklist issue.
## Next
- Add project-specific tests or verification steps.
- Publish the first verified release only after provenance and security review.
- Replace placeholder wiki notes with project-specific architecture or hardware details.
## Later
- Add examples, screenshots, wiring diagrams, or demo media where useful.
- Decide whether duplicate or experimental branches should be archived.

14
docs/SECURITY_REVIEW.md Normal file
View File

@@ -0,0 +1,14 @@
# Security Review
<!-- stewardship-standard: security-review-v1 -->
## Required Checks
- [ ] No credentials, tokens, cookies, API keys, private keys, or session files.
- [ ] No private user data, dumps, card data, logs, or captures that should not be stored.
- [ ] No copied dependency trees where package managers or SDK installers should be used instead.
- [ ] No unexplained binaries in source history.
- [ ] Risky behavior is documented and scoped to authorized lab use.
## Release Gate
A release is blocked until the checklist is complete or a maintainer explicitly records why the item does not apply.

View File

@@ -6,3 +6,5 @@ apscheduler==3.10.4
python-dotenv==1.0.1
sqlalchemy==2.0.36
pydantic==2.10.3
redis==5.2.1
meilisearch-python-sdk==7.0.2

140
scripts/flow_tests.py Normal file
View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python3
import json
import sqlite3
import time
from pathlib import Path
import requests
ROOT = Path('/home/drjones/.openclaw/workspace/alpaca-llm-bot-v1')
DB = ROOT / 'bot.db'
BASE = 'http://127.0.0.1:8089'
results = []
def record(name, ok, detail=''):
results.append({'test': name, 'ok': bool(ok), 'detail': str(detail)[:400]})
def test_health():
r = requests.get(f'{BASE}/health', timeout=5)
record('health_endpoint', r.ok and r.json().get('ok') is True, r.text[:120])
def test_metrics_schema():
r = requests.get(f'{BASE}/api/metrics?hours=24', timeout=8)
ok = r.ok
detail = ''
if ok:
j = r.json()
ok = j.get('ok') is True and 'decisionSeries' in j and 'activitySeries' in j and 'leaderboard' in j
detail = f"keys={list(j.keys())[:6]}"
record('metrics_endpoint_schema', ok, detail)
def _counts(cur):
out = {}
for t in ['insights','decisions','trades']:
cur.execute(f'select count(*) from {t}')
out[t] = cur.fetchone()[0]
return out
def test_curate_creates_insights():
con = sqlite3.connect(DB)
cur = con.cursor()
before = _counts(cur)['insights']
try:
requests.post(f'{BASE}/curate-now', timeout=25)
except Exception:
pass
time.sleep(1)
after = _counts(cur)['insights']
record('curate_increases_insights_or_stays', after >= before, f'before={before} after={after}')
con.close()
def test_run_now_creates_decision_activity():
con = sqlite3.connect(DB)
cur = con.cursor()
before = _counts(cur)['decisions']
try:
requests.post(f'{BASE}/run-now', timeout=35)
except Exception:
pass
time.sleep(1)
after = _counts(cur)['decisions']
record('run_now_increases_decisions_or_stays', after >= before, f'before={before} after={after}')
con.close()
def test_decision_integrity():
con = sqlite3.connect(DB)
cur = con.cursor()
cur.execute("select count(*) from decisions where symbol is null or symbol='' or action is null or status is null")
bad = cur.fetchone()[0]
record('decision_integrity_nonnull_fields', bad == 0, f'bad_rows={bad}')
con.close()
def test_notional_cap():
from config import settings
con = sqlite3.connect(DB)
cur = con.cursor()
cur.execute('select max(notional) from trades')
mx = cur.fetchone()[0] or 0
record('trade_notional_within_cap', mx <= settings.max_order_usd + 1e-9, f'max_notional={mx} cap={settings.max_order_usd}')
con.close()
def test_status_values():
allowed = {'planned','executed','failed','risk_blocked','skipped'}
con = sqlite3.connect(DB)
cur = con.cursor()
cur.execute('select distinct status from decisions order by status')
vals = {r[0] for r in cur.fetchall()}
ok = vals.issubset(allowed)
record('decision_status_enum', ok, f'statuses={sorted(vals)}')
con.close()
def test_ollama_tags():
from config import settings
try:
r = requests.get(settings.ollama_url + '/api/tags', timeout=8)
ok = r.ok and isinstance(r.json().get('models', []), list)
record('ollama_tags_reachable', ok, f"models={len(r.json().get('models',[])) if r.ok else 'n/a'}")
except Exception as e:
record('ollama_tags_reachable', False, str(e))
def test_qdrant_reachable():
from config import settings
try:
r = requests.get(settings.qdrant_url + '/collections', timeout=8)
ok = r.ok
record('qdrant_reachable', ok, r.text[:120])
except Exception as e:
record('qdrant_reachable', False, str(e))
def test_model_config_split():
from config import settings
ok = bool(settings.ollama_curator_model) and bool(settings.ollama_decision_model)
record('model_split_configured', ok, f"curator={settings.ollama_curator_model} decision={settings.ollama_decision_model}")
def main():
# import local config path
import sys
sys.path.insert(0, str(ROOT))
test_health()
test_metrics_schema()
test_curate_creates_insights()
test_run_now_creates_decision_activity()
test_decision_integrity()
test_notional_cap()
test_status_values()
test_ollama_tags()
test_qdrant_reachable()
test_model_config_split()
passed = sum(1 for r in results if r['ok'])
total = len(results)
summary = {'passed': passed, 'total': total, 'results': results, 'ts': int(time.time())}
out = ROOT / 'logs_flow_tests.json'
out.write_text(json.dumps(summary, indent=2))
print(json.dumps(summary, indent=2))
if __name__ == '__main__':
main()

13
scripts/start_live.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="/home/drjones/.openclaw/workspace/alpaca-llm-bot-v1"
LOGDIR="/home/drjones/.openclaw/workspace/logs"
mkdir -p "$LOGDIR"
cd "$ROOT"
if [[ -f "$LOGDIR/alpaca-bot.pid" ]] && kill -0 "$(cat $LOGDIR/alpaca-bot.pid)" 2>/dev/null; then
echo "already_running"
exit 0
fi
nohup python3 -m uvicorn app:app --host 0.0.0.0 --port 8089 > "$LOGDIR/alpaca-bot.log" 2>&1 &
echo $! > "$LOGDIR/alpaca-bot.pid"
echo "started $(cat $LOGDIR/alpaca-bot.pid)"

29
scripts/watchdog.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="/home/drjones/.openclaw/workspace/alpaca-llm-bot-v1"
LOGDIR="/home/drjones/.openclaw/workspace/logs"
mkdir -p "$LOGDIR"
PIDFILE="$LOGDIR/alpaca-bot.pid"
HEALTH_URL="http://127.0.0.1:8089/health"
need_start=0
if [[ ! -f "$PIDFILE" ]]; then
need_start=1
else
pid=$(cat "$PIDFILE" || true)
if [[ -z "$pid" ]] || ! kill -0 "$pid" 2>/dev/null; then
need_start=1
fi
fi
if [[ $need_start -eq 0 ]]; then
if ! curl -fsS -m 3 "$HEALTH_URL" >/dev/null; then
pid=$(cat "$PIDFILE" || true)
[[ -n "$pid" ]] && kill "$pid" 2>/dev/null || true
need_start=1
fi
fi
if [[ $need_start -eq 1 ]]; then
"$ROOT/scripts/start_live.sh" >> "$LOGDIR/alpaca-watchdog.log" 2>&1
fi

View File

@@ -1,98 +1,309 @@
import json
import random
import time
import uuid
import requests
from datetime import datetime
import redis
import meilisearch_python_sdk
from config import settings
_redis = None
_meili = None
def searx_news(symbol: str, limit: int = 8):
q = f"{symbol} stock news earnings guidance analyst"
params = {"q": q, "format": "json", "language": "en"}
def meili_client():
global _meili
if _meili is None:
try:
_meili = meilisearch_python_sdk.Client(settings.meili_url, settings.meili_api_key or None)
except Exception:
_meili = None
return _meili
def meili_index_doc(index_uid: str, doc: dict):
c = meili_client()
if not c:
return False
try:
r = requests.get(settings.searx_url, params=params, timeout=20)
idx = c.index(index_uid)
idx.add_documents([doc], primary_key='id')
return True
except Exception:
return False
def meili_search(index_uid: str, q: str, limit: int = 20):
c = meili_client()
if not c:
return {"hits": []}
try:
idx = c.index(index_uid)
res = idx.search(q, {'limit': limit})
if isinstance(res, dict):
return res
# sdk object fallback
return {
'hits': getattr(res, 'hits', []),
'estimatedTotalHits': getattr(res, 'estimated_total_hits', None),
'processingTimeMs': getattr(res, 'processing_time_ms', None),
'query': q,
}
except Exception:
return {"hits": []}
def meili_health():
c = meili_client()
if not c:
return {"ok": False, "error": "client_unavailable"}
try:
h = c.health()
status = getattr(h, 'status', None)
if status is None and isinstance(h, dict):
status = h.get('status')
return {"ok": True, "status": status or 'available'}
except Exception as e:
return {"ok": False, "error": str(e)[:160]}
def redis_client():
global _redis
if _redis is None:
try:
_redis = redis.Redis.from_url(settings.redis_url, decode_responses=True, socket_timeout=2)
_redis.ping()
except Exception:
_redis = None
return _redis
def redis_set_json(key: str, value: dict, ttl: int = 3600):
r = redis_client()
if not r:
return False
try:
r.setex(key, ttl, json.dumps(value))
return True
except Exception:
return False
def redis_get_json(key: str):
r = redis_client()
if not r:
return None
try:
v = r.get(key)
return json.loads(v) if v else None
except Exception:
return None
def redis_lock(key: str, ttl: int = 180):
r = redis_client()
if not r:
return True
try:
return bool(r.set(key, str(int(time.time())), ex=ttl, nx=True))
except Exception:
return True
def _ollama_generate(model: str, payload_obj: dict, timeout: int = 45):
payload = {"model": model, "stream": False, "prompt": json.dumps(payload_obj), "format": "json"}
r = requests.post(f"{settings.ollama_url}/api/generate", json=payload, timeout=timeout)
r.raise_for_status()
return json.loads(r.json().get("response", "{}"))
def _embed(text: str):
try:
r = requests.post(f"{settings.ollama_url}/api/embeddings", json={"model": settings.ollama_embed_model, "prompt": text[:8000]}, timeout=30)
r.raise_for_status()
data = r.json()
return r.json().get("embedding", [])
except Exception:
return []
def qdrant_ensure_collection(vector_size=768):
try:
requests.put(f"{settings.qdrant_url}/collections/{settings.qdrant_collection}", json={"vectors": {"size": vector_size, "distance": "Cosine"}}, timeout=10)
except Exception:
pass
def qdrant_add_memory(symbol: str, text: str, payload: dict):
vec = _embed(text)
if not vec:
return False
qdrant_ensure_collection(len(vec))
enriched = {
"symbol": symbol,
"text": text[:2000],
"memory_type": payload.get("memory_type", "decision"),
"created_at": int(time.time()),
**payload,
}
body = {
"points": [{"id": str(uuid.uuid4()), "vector": vec, "payload": enriched}]
}
try:
r = requests.put(f"{settings.qdrant_url}/collections/{settings.qdrant_collection}/points", json=body, timeout=15)
return r.ok
except Exception:
return False
def qdrant_similar(symbol: str, query_text: str, limit: int = 8):
vec = _embed(query_text)
if not vec:
return []
body = {
"vector": vec,
"limit": limit,
"with_payload": True,
"filter": {
"should": [
{"key": "symbol", "match": {"value": symbol}},
{"key": "memory_type", "match": {"value": "macro"}}
]
}
}
try:
r = requests.post(f"{settings.qdrant_url}/collections/{settings.qdrant_collection}/points/search", json=body, timeout=15)
if not r.ok:
return []
out = []
for it in data.get("results", [])[:limit]:
out.append({"title": it.get("title", ""), "url": it.get("url", ""), "content": it.get("content", "")[:400]})
for p in r.json().get("result", []):
pl = p.get("payload", {})
out.append({
"score": p.get("score", 0),
"text": pl.get("text", ""),
"status": pl.get("status", ""),
"action": pl.get("action", ""),
"confidence": pl.get("confidence", None),
"outcome_score": pl.get("outcome_score", None),
})
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",
}
def searx_news(symbol: str, limit: int = 12):
q = f"{symbol} stock news earnings guidance analyst macro risk"
params = {"q": q, "format": "json", "language": "en"}
try:
r = requests.post(f"{settings.ollama_url}/api/generate", json=payload, timeout=40)
r = requests.get(settings.searx_url, params=params, timeout=20)
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}
data = r.json()
return [{"title": it.get("title", ""), "url": it.get("url", ""), "content": (it.get("content", "") or "")[:700]} for it in data.get("results", [])[:limit]]
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",
}
return []
def extra_research(symbol: str, weak_points: list, limit: int = 6):
q = f"{symbol} {' '.join(weak_points[:3])} SEC filing guidance risks competition"
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()
return [{"title": it.get("title", ""), "url": it.get("url", ""), "content": (it.get("content", "") or "")[:700]} for it in data.get("results", [])[:limit]]
except Exception:
return []
def summarize_news_with_ollama(symbol: str, context_items: list):
prompt = {"task": "Summarize market-moving info into a concise brief", "symbol": symbol, "news": context_items}
fallback = " | ".join([(x.get("title") or "")[:90] for x in context_items[:3] if x.get("title")]) or f"No strong headlines for {symbol}"
try:
parsed = _ollama_generate(settings.ollama_curator_model, prompt)
s = parsed.get("summary")
return s if s else fallback
except Exception:
return fallback
def strategy_signals(symbol: str, context_items: list):
text_blob = " ".join((x.get("title", "") + " " + x.get("content", "")) for x in context_items).lower()
bullish = sum(k in text_blob for k in ["beat", "raise guidance", "upgrade", "buyback", "record revenue"])
bearish = sum(k in text_blob for k in ["miss", "downgrade", "lawsuit", "probe", "cut guidance", "recall"])
score = bullish - bearish
action = "buy" if score >= 2 else ("sell" if score <= -2 else "hold")
conf = min(0.85, 0.50 + abs(score) * 0.08)
return {"strategy": "event-momentum-v1", "score": score, "action": action, "confidence": conf, "signals": {"bullish": bullish, "bearish": bearish}}
def _normalize_decision(d: dict, strategy: dict):
action = str(d.get("action", strategy.get("action", "hold"))).lower()
if action not in {"buy", "sell", "hold"}:
action = strategy.get("action", "hold")
try:
confidence = max(0.0, min(1.0, float(d.get("confidence", strategy.get("confidence", 0.5)))))
except Exception:
confidence = min(strategy.get("confidence", 0.5), 0.55)
try:
order_usd = float(d.get("order_usd", settings.max_order_usd))
except Exception:
order_usd = settings.max_order_usd
order_usd = max(1.0, min(order_usd, settings.max_order_usd))
reason = str(d.get("reason", "normalized-decision"))[:500]
return {
"action": action,
"confidence": confidence,
"order_usd": order_usd,
"reason": reason,
"needs_more_research": bool(d.get("needs_more_research", False)),
"research_topics": d.get("research_topics", []) or [],
}
def llm_final_decision(symbol: str, context_items: list, strategy: dict, memory_hits: list):
prompt = {
"task": "Final trading decision. Return strict JSON.",
"symbol": symbol,
"constraints": {"actions": ["buy", "sell", "hold"], "max_order_usd": settings.max_order_usd, "fee_per_trade_usd": settings.fee_per_trade_usd, "slippage_bps": settings.slippage_bps, "avoid_overtrading": True},
"strategy_prior": strategy,
"memory_hits": memory_hits,
"news": context_items,
"output_schema": {"action": "buy|sell|hold", "confidence": "0-1", "order_usd": f"<= {settings.max_order_usd}", "reason": "short rationale", "needs_more_research": True, "research_topics": ["..."]},
}
# pass 1: strict json mode
try:
d = _ollama_generate(settings.ollama_decision_model, prompt, timeout=60)
return _normalize_decision(d, strategy)
except Exception:
pass
# pass 2: non-json constrained output, then parse heuristically
try:
text_prompt = (
f"Symbol: {symbol}\n"
f"Strategy prior: {strategy}\n"
f"Return 4 lines only:\n"
f"action: buy|sell|hold\nconfidence: 0-1\norder_usd: <= {settings.max_order_usd}\nreason: <short>\n"
)
r = requests.post(f"{settings.ollama_url}/api/generate", json={"model": settings.ollama_decision_model, "prompt": text_prompt, "stream": False}, timeout=45)
if r.ok:
raw = (r.json().get("response", "") or "").lower()
action = "buy" if "buy" in raw else ("sell" if "sell" in raw else "hold")
conf = 0.6 if "confidence" not in raw else strategy.get("confidence", 0.55)
parsed = {"action": action, "confidence": conf, "order_usd": settings.max_order_usd, "reason": raw[:300]}
return _normalize_decision(parsed, strategy)
except Exception:
pass
return {
"action": strategy.get("action", "hold"),
"confidence": min(strategy.get("confidence", 0.5), 0.55),
"order_usd": min(5.0, settings.max_order_usd),
"reason": "decision-fallback-strategy",
"needs_more_research": False,
"research_topics": [],
}
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
return {"APCA-API-KEY-ID": settings.alpaca_key, "APCA-API-SECRET-KEY": settings.alpaca_secret, "Content-Type": "application/json"}
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),
}
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 {}}
@@ -107,3 +318,67 @@ 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)
return r.json() if r.ok else []
except Exception:
return []
def market_open():
try:
r = requests.get(f"{settings.alpaca_base}/v2/clock", headers=alpaca_headers(), timeout=20)
return bool(r.json().get("is_open", False)) if r.ok else False
except Exception:
return False
def trilium_log(title: str, body: str):
if not settings.trilium_token:
return False
headers = {"Authorization": settings.trilium_token, "Content-Type": "application/json"}
payload = {"title": title[:120], "type": "text", "mime": "text/markdown", "content": body}
try:
# best-effort endpoints across Trilium variants
for ep in ["/etapi/create-note", "/etapi/notes"]:
r = requests.post(settings.trilium_url.rstrip("/") + ep, headers=headers, json=payload, timeout=15)
if r.ok:
return True
except Exception:
pass
return False
def qdrant_memory_health():
try:
# collection-specific
r = requests.get(f"{settings.qdrant_url}/collections/{settings.qdrant_collection}", timeout=10)
if r.ok:
j = r.json().get("result", {})
return {
"ok": True,
"collection": settings.qdrant_collection,
"points_count": j.get("points_count"),
"indexed_vectors_count": j.get("indexed_vectors_count"),
}
# fallback global health/list
r2 = requests.get(f"{settings.qdrant_url}/collections", timeout=10)
if r2.ok:
names=[c.get('name') for c in r2.json().get('result',{}).get('collections',[])]
return {"ok": True, "collection": settings.qdrant_collection, "exists": settings.qdrant_collection in names, "collections": names[:20]}
return {"ok": False, "status": r.status_code}
except Exception as e:
return {"ok": False, "error": str(e)[:160]}
def n8n_emit(event: dict):
if not settings.n8n_webhook:
return False
try:
r = requests.post(settings.n8n_webhook, json=event, timeout=10)
return r.ok
except Exception:
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

@@ -4,53 +4,150 @@
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>alpaca-llm-bot-v1</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<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)}
:root { --bg:#070b12; --card:#0f172a; --line:#1f2937; --text:#e5e7eb; --muted:#9ca3af; --buy:#22c55e; --sell:#ef4444; --hold:#94a3b8; --acc:#60a5fa; }
* { box-sizing: border-box; }
body{background:radial-gradient(1200px 800px at 20% -10%, #1a2340 0%, var(--bg) 40%);color:var(--text);font-family:Inter,system-ui,sans-serif;margin:0;padding:20px}
h2,h3{margin:8px 0 14px}
.sub{color:var(--muted);font-size:13px;margin-bottom:16px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}
.card{background:linear-gradient(180deg,#101b33 0%,var(--card) 100%);border:1px solid var(--line);border-radius:14px;padding:14px;box-shadow:0 8px 30px rgba(0,0,0,.25)}
.h{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em}
.v{font-size:26px;font-weight:700;margin-top:4px}
.layout{display:grid;grid-template-columns:2fr 1fr;gap:12px}
.charts{display:grid;grid-template-columns:1fr;gap:12px}
.chart-wrap{height:270px}
table{width:100%;border-collapse:collapse}
th,td{padding:8px;border-bottom:1px solid var(--line);text-align:left;font-size:13px}
.buy{color:var(--buy)} .sell{color:var(--sell)} .hold{color:var(--hold)}
.pill{display:inline-block;padding:3px 8px;border:1px solid var(--line);border-radius:999px;font-size:12px;color:var(--muted)}
.scroll{max-height:430px;overflow:auto}
</style>
</head>
<body>
<h2>alpaca-llm-bot-v1</h2>
<h2>alpaca-llm-bot-v1 · Mission Control</h2>
<div class="sub">Autonomous trading telemetry · updates every 60s · non-blocking UI reads</div>
<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 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="layout" style="margin-top:12px">
<div class="charts">
<div class="card">
<div class="h">Decision Flow (last 72h)</div>
<div class="chart-wrap"><canvas id="decisionChart"></canvas></div>
</div>
<div class="card">
<div class="h">Cumulative Notional Activity</div>
<div class="chart-wrap"><canvas id="activityChart"></canvas></div>
</div>
</div>
<div class="card">
<div class="h">Symbol Leaderboard</div>
<div id="leaderboard" class="scroll" style="margin-top:8px"></div>
</div>
</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 style="margin-top:12px" class="layout">
<div>
<h3>Recent Decisions <span class="pill">latest 120</span></h3>
<div class="card scroll">
<table>
<thead><tr><th>Time</th><th>Symbol</th><th>Action</th><th>Conf</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 Briefs <span class="pill">latest 30</span></h3>
<div class="card scroll">
{% for i in insights %}
<div style="margin-bottom:10px;padding-bottom:10px;border-bottom:1px solid var(--line)">
<div><b>{{ i.symbol }}</b> · <span class="h">{{ i.ts }}</span></div>
<div style="font-size:13px;line-height:1.45">{{ i.summary }}</div>
</div>
{% endfor %}
</div>
</div>
</div>
<script>
let decisionChart, activityChart;
function renderLeaderboard(rows){
const el = document.getElementById('leaderboard');
if(!rows || !rows.length){ el.innerHTML = '<div class="sub">No data yet.</div>'; return; }
el.innerHTML = rows.map((r,i)=>`<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #1f2937"><span>#${i+1} <b>${r.symbol}</b></span><span class="sub">exec ${r.executed} / dec ${r.decisions}</span></div>`).join('');
}
function upsertCharts(m){
const d = m.decisionSeries;
const a = m.activitySeries;
if(!decisionChart){
decisionChart = new Chart(document.getElementById('decisionChart'), {
type: 'line',
data: { labels: d.labels, datasets: [
{label:'buy', data:d.buy, borderColor:'#22c55e', tension:.25},
{label:'sell', data:d.sell, borderColor:'#ef4444', tension:.25},
{label:'hold', data:d.hold, borderColor:'#94a3b8', tension:.25},
{label:'executed', data:d.executed, borderColor:'#60a5fa', tension:.25},
]},
options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{labels:{color:'#e5e7eb'}}}, scales:{x:{ticks:{color:'#9ca3af'}},y:{ticks:{color:'#9ca3af'}}} }
});
} else {
decisionChart.data.labels = d.labels;
decisionChart.data.datasets[0].data = d.buy;
decisionChart.data.datasets[1].data = d.sell;
decisionChart.data.datasets[2].data = d.hold;
decisionChart.data.datasets[3].data = d.executed;
decisionChart.update();
}
if(!activityChart){
activityChart = new Chart(document.getElementById('activityChart'), {
type:'bar',
data:{ labels:a.labels, datasets:[{label:'cum notional', data:a.cumulativeNotional, backgroundColor:'#60a5fa66', borderColor:'#60a5fa'}] },
options:{ responsive:true, maintainAspectRatio:false, plugins:{legend:{labels:{color:'#e5e7eb'}}}, scales:{x:{ticks:{color:'#9ca3af'}},y:{ticks:{color:'#9ca3af'}}} }
});
} else {
activityChart.data.labels = a.labels;
activityChart.data.datasets[0].data = a.cumulativeNotional;
activityChart.update();
}
}
async function refreshMetrics(){
try{
const r = await fetch('/api/metrics?hours=72');
const m = await r.json();
if(!m.ok) return;
upsertCharts(m);
renderLeaderboard(m.leaderboard);
}catch(e){ }
}
refreshMetrics();
setInterval(refreshMetrics, 60000);
</script>
</body>
</html>