Compare commits
34 Commits
cce199d73a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c42e701f68 | |||
| 7b2b765f29 | |||
| f5292c9631 | |||
| fb9c1fac79 | |||
| 229cd2bf03 | |||
| 4a7bac7265 | |||
| 2591d1ae68 | |||
| 42910d6209 | |||
| 50a7c5d839 | |||
| a439b1fe28 | |||
| d4f29308be | |||
| b609f9158a | |||
| 9078684753 | |||
| 7d64f9ef50 | |||
| 92e3b2990b | |||
| 4c8f31e5fc | |||
| 4ebaac5692 | |||
| 0a339ca2df | |||
| aa71e13662 | |||
| 2cc7dcb0d9 | |||
| 1ca35733cf | |||
| cd8f8430f9 | |||
| fee413a852 | |||
| 33556c583b | |||
| ef6e2f160c | |||
| 9956f421b0 | |||
| 4dcfc79635 | |||
| 1da0c04242 | |||
| dbf6bf4e73 | |||
| 94aba00967 | |||
| ddc42254fa | |||
| cb5edf694d | |||
| 080e66980f | |||
| 4d17bf8018 |
17
.editorconfig
Normal file
17
.editorconfig
Normal 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
|
||||||
32
.env.example
32
.env.example
@@ -1,23 +1,43 @@
|
|||||||
# Alpaca
|
|
||||||
ALPACA_API_KEY=REPLACE_ME
|
ALPACA_API_KEY=REPLACE_ME
|
||||||
ALPACA_API_SECRET=REPLACE_ME
|
ALPACA_API_SECRET=REPLACE_ME
|
||||||
ALPACA_BASE_URL=https://paper-api.alpaca.markets
|
ALPACA_BASE_URL=https://paper-api.alpaca.markets
|
||||||
|
|
||||||
# Runtime
|
|
||||||
PAPER_MODE=true
|
PAPER_MODE=true
|
||||||
|
STARTING_CAPITAL_USD=100
|
||||||
MAX_ORDER_USD=5
|
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_HOURS=2
|
||||||
|
TRADE_INTERVAL_MINUTES=0
|
||||||
|
CURATE_INTERVAL_MINUTES=30
|
||||||
TIMEZONE=America/Los_Angeles
|
TIMEZONE=America/Los_Angeles
|
||||||
|
FORCE_PAPER_TRADES=true
|
||||||
|
FORCE_PAPER_MIN_USD=10
|
||||||
|
|
||||||
# Ollama
|
|
||||||
OLLAMA_URL=http://10.30.20.110:11434
|
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
|
SEARX_URL=http://10.30.20.35:6969/search
|
||||||
SCRAPER_API_URL=http://10.30.20.115:24125
|
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_HOST=0.0.0.0
|
||||||
APP_PORT=8089
|
APP_PORT=8089
|
||||||
DB_PATH=sqlite:///./bot.db
|
DB_PATH=sqlite:///./bot.db
|
||||||
|
|||||||
27
.gitattributes
vendored
Normal file
27
.gitattributes
vendored
Normal 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
|
||||||
21
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
21
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal 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.
|
||||||
13
.gitea/ISSUE_TEMPLATE/docs_task.md
Normal file
13
.gitea/ISSUE_TEMPLATE/docs_task.md
Normal 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.
|
||||||
14
.gitea/ISSUE_TEMPLATE/release_checklist.md
Normal file
14
.gitea/ISSUE_TEMPLATE/release_checklist.md
Normal 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.
|
||||||
7
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
7
.gitea/PULL_REQUEST_TEMPLATE.md
Normal 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
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
bot.db
|
||||||
13
CHANGELOG.md
Normal file
13
CHANGELOG.md
Normal 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
1
CODEOWNERS
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* @drjones
|
||||||
20
CONTRIBUTING.md
Normal file
20
CONTRIBUTING.md
Normal 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
21
LICENSE
Normal 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
14
LICENSE_STATUS.md
Normal 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.
|
||||||
38
README.md
38
README.md
@@ -1,33 +1,37 @@
|
|||||||
# alpaca-llm-bot-v1
|
# alpaca-llm-bot-v1
|
||||||
|
|
||||||
Autonomous 2-hour trading loop powered by:
|
Autonomous LLM trading system (paper-first) powered by:
|
||||||
- Alpaca trading API
|
- Alpaca trading API
|
||||||
- Ollama local model inference
|
- Ollama (curator + decision models)
|
||||||
- Searx web data ingestion
|
- 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
|
- FastAPI dark dashboard
|
||||||
|
|
||||||
## Safety defaults
|
## v1.3 additions
|
||||||
- `PAPER_MODE=true`
|
- Retrieval-augmented decisions via Qdrant (`QDRANT_URL`)
|
||||||
- max order notional `$5`
|
- Auto-write trade logs to Trilium (`TRILIUM_URL`, `TRILIUM_TOKEN`)
|
||||||
- confidence gate `>= 0.55` before order placement
|
- 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
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# fill ALPACA_API_KEY / ALPACA_API_SECRET
|
# fill Alpaca keys + optional Trilium/N8N vars
|
||||||
chmod +x run.sh
|
python3 -m uvicorn app:app --host 0.0.0.0 --port 8089
|
||||||
./run.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open dashboard:
|
Dashboard:
|
||||||
- `http://<host>:8089/`
|
- `http://<host>:8089/`
|
||||||
|
|
||||||
Trigger immediate cycle:
|
Manual triggers:
|
||||||
```bash
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8089/curate-now
|
||||||
curl -X POST http://127.0.0.1:8089/run-now
|
curl -X POST http://127.0.0.1:8089/run-now
|
||||||
```
|
```
|
||||||
|
|
||||||
## Production notes
|
## Safety
|
||||||
- Put behind reverse proxy + auth
|
- Keep `PAPER_MODE=true` until stable
|
||||||
- Keep paper mode until behavior validated
|
- This is experimental software, not financial advice
|
||||||
- Add hard stop-loss and max daily drawdown before enabling live mode
|
|
||||||
|
|||||||
19
SECURITY.md
Normal file
19
SECURITY.md
Normal 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
90
app.py
@@ -3,9 +3,10 @@ from fastapi.responses import JSONResponse
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from db import init_db, SessionLocal, BotDecision, TradeExecution
|
import threading
|
||||||
from bot import start_scheduler, run_cycle
|
from db import init_db, SessionLocal, BotDecision, TradeExecution, CuratedInsight
|
||||||
from services import account_snapshot
|
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")
|
app = FastAPI(title="alpaca-llm-bot-v1")
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
@@ -21,13 +22,88 @@ def health():
|
|||||||
|
|
||||||
@app.post("/run-now")
|
@app.post("/run-now")
|
||||||
def run_now():
|
def run_now():
|
||||||
run_cycle()
|
threading.Thread(target=run_cycle, daemon=True).start()
|
||||||
return {"ok": True, "ran": True}
|
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")
|
@app.get("/api/account")
|
||||||
def api_account():
|
def api_account():
|
||||||
return JSONResponse(account_snapshot())
|
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("/")
|
@app.get("/")
|
||||||
def home(request: Request):
|
def home(request: Request):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
@@ -35,12 +111,14 @@ def home(request: Request):
|
|||||||
since = datetime.utcnow() - timedelta(hours=24)
|
since = datetime.utcnow() - timedelta(hours=24)
|
||||||
decisions = db.query(BotDecision).order_by(BotDecision.ts.desc()).limit(120).all()
|
decisions = db.query(BotDecision).order_by(BotDecision.ts.desc()).limit(120).all()
|
||||||
trades = db.query(TradeExecution).order_by(TradeExecution.ts.desc()).limit(120).all()
|
trades = db.query(TradeExecution).order_by(TradeExecution.ts.desc()).limit(120).all()
|
||||||
|
insights = db.query(CuratedInsight).order_by(CuratedInsight.ts.desc()).limit(30).all()
|
||||||
stats = {
|
stats = {
|
||||||
"decisions": db.query(func.count(BotDecision.id)).filter(BotDecision.ts >= since).scalar() or 0,
|
"decisions": db.query(func.count(BotDecision.id)).filter(BotDecision.ts >= since).scalar() or 0,
|
||||||
"trades": db.query(func.count(TradeExecution.id)).filter(TradeExecution.ts >= since).scalar() or 0,
|
"trades": db.query(func.count(TradeExecution.id)).filter(TradeExecution.ts >= since).scalar() or 0,
|
||||||
"executed": db.query(func.count(BotDecision.id)).filter(BotDecision.ts >= since, BotDecision.status == "executed").scalar() or 0,
|
"executed": db.query(func.count(BotDecision.id)).filter(BotDecision.ts >= since, BotDecision.status == "executed").scalar() or 0,
|
||||||
"failed": db.query(func.count(BotDecision.id)).filter(BotDecision.ts >= since, BotDecision.status == "failed").scalar() or 0,
|
"failed": db.query(func.count(BotDecision.id)).filter(BotDecision.ts >= since, BotDecision.status == "failed").scalar() or 0,
|
||||||
|
"insights": db.query(func.count(CuratedInsight.id)).filter(CuratedInsight.ts >= since).scalar() or 0,
|
||||||
}
|
}
|
||||||
return templates.TemplateResponse("index.html", {"request": request, "decisions": decisions, "trades": trades, "stats": stats})
|
return templates.TemplateResponse("index.html", {"request": request, "decisions": decisions, "trades": trades, "insights": insights, "stats": stats})
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
228
bot.py
228
bot.py
@@ -1,25 +1,125 @@
|
|||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
import json
|
import json
|
||||||
|
from sqlalchemy import func
|
||||||
from config import settings
|
from config import settings
|
||||||
from db import SessionLocal, BotDecision, TradeExecution
|
from db import SessionLocal, BotDecision, TradeExecution, CuratedInsight
|
||||||
from services import searx_news, ollama_decide, place_order
|
from services import (
|
||||||
|
searx_news,
|
||||||
|
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)
|
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()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
for symbol in settings.symbols:
|
for symbol in settings.symbols:
|
||||||
news = searx_news(symbol)
|
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(
|
drow = BotDecision(
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
action=decision["action"],
|
action=decision["action"],
|
||||||
confidence=decision["confidence"],
|
confidence=decision["confidence"],
|
||||||
reason=decision["reason"],
|
reason=f"{decision['reason']} | strat={strat['strategy']} score={strat['score']}",
|
||||||
market_context=json.dumps(news)[:60000],
|
market_context=json.dumps(news)[:60000],
|
||||||
order_usd=decision["order_usd"],
|
order_usd=decision["order_usd"],
|
||||||
status="planned",
|
status="planned",
|
||||||
@@ -27,29 +127,123 @@ def run_cycle():
|
|||||||
db.add(drow)
|
db.add(drow)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(drow)
|
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:
|
should_trade = decision["action"] in {"buy", "sell"} and decision["confidence"] >= settings.min_confidence
|
||||||
res = place_order(symbol, decision["action"], min(settings.max_order_usd, decision["order_usd"]))
|
|
||||||
|
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"))
|
ok = bool(res and res.get("ok"))
|
||||||
drow.status = "executed" if ok else "failed"
|
drow.status = "executed" if ok else "failed"
|
||||||
db.add(drow)
|
db.add(drow)
|
||||||
db.add(TradeExecution(
|
|
||||||
symbol=symbol,
|
if ok:
|
||||||
side=decision["action"],
|
trade = TradeExecution(
|
||||||
qty=float((res or {}).get("json", {}).get("qty", 0) or 0),
|
symbol=symbol,
|
||||||
notional=min(settings.max_order_usd, decision["order_usd"]),
|
side=decision["action"],
|
||||||
alpaca_order_id=(res or {}).get("json", {}).get("id", ""),
|
qty=float((res or {}).get("json", {}).get("qty", 0) or 0),
|
||||||
raw=json.dumps(res)[:60000],
|
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()
|
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:
|
else:
|
||||||
drow.status = "skipped"
|
drow.status = "skipped"
|
||||||
db.add(drow)
|
db.add(drow)
|
||||||
db.commit()
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def start_scheduler():
|
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()
|
scheduler.start()
|
||||||
|
|||||||
28
config.py
28
config.py
@@ -9,16 +9,42 @@ class Settings:
|
|||||||
alpaca_base = os.getenv("ALPACA_BASE_URL", "https://paper-api.alpaca.markets")
|
alpaca_base = os.getenv("ALPACA_BASE_URL", "https://paper-api.alpaca.markets")
|
||||||
paper_mode = os.getenv("PAPER_MODE", "true").lower() == "true"
|
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_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_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")
|
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_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")
|
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")
|
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")
|
db_path = os.getenv("DB_PATH", "sqlite:///./bot.db")
|
||||||
host = os.getenv("APP_HOST", "0.0.0.0")
|
host = os.getenv("APP_HOST", "0.0.0.0")
|
||||||
port = int(os.getenv("APP_PORT", "8089"))
|
port = int(os.getenv("APP_PORT", "8089"))
|
||||||
|
|||||||
8
db.py
8
db.py
@@ -30,6 +30,14 @@ class TradeExecution(Base):
|
|||||||
alpaca_order_id = Column(String(128))
|
alpaca_order_id = Column(String(128))
|
||||||
raw = Column(Text)
|
raw = Column(Text)
|
||||||
|
|
||||||
|
class CuratedInsight(Base):
|
||||||
|
__tablename__ = "insights"
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
ts = Column(DateTime, default=datetime.utcnow)
|
||||||
|
symbol = Column(String(16), index=True)
|
||||||
|
summary = Column(Text)
|
||||||
|
sources = Column(Text)
|
||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|||||||
23
docs/MAINTENANCE.md
Normal file
23
docs/MAINTENANCE.md
Normal 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
14
docs/PROJECT_HANDOFF.md
Normal 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.
|
||||||
12
docs/PROVENANCE_CHECKLIST.md
Normal file
12
docs/PROVENANCE_CHECKLIST.md
Normal 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
20
docs/RELEASE_PROCESS.md
Normal 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
20
docs/ROADMAP.md
Normal 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
14
docs/SECURITY_REVIEW.md
Normal 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.
|
||||||
@@ -6,3 +6,5 @@ apscheduler==3.10.4
|
|||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
sqlalchemy==2.0.36
|
sqlalchemy==2.0.36
|
||||||
pydantic==2.10.3
|
pydantic==2.10.3
|
||||||
|
redis==5.2.1
|
||||||
|
meilisearch-python-sdk==7.0.2
|
||||||
|
|||||||
140
scripts/flow_tests.py
Normal file
140
scripts/flow_tests.py
Normal 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
13
scripts/start_live.sh
Executable 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
29
scripts/watchdog.sh
Executable 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
|
||||||
415
services.py
415
services.py
@@ -1,98 +1,309 @@
|
|||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
import requests
|
import requests
|
||||||
from datetime import datetime
|
import redis
|
||||||
|
import meilisearch_python_sdk
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
|
_redis = None
|
||||||
|
_meili = None
|
||||||
|
|
||||||
def searx_news(symbol: str, limit: int = 8):
|
def meili_client():
|
||||||
q = f"{symbol} stock news earnings guidance analyst"
|
global _meili
|
||||||
params = {"q": q, "format": "json", "language": "en"}
|
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:
|
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()
|
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 = []
|
out = []
|
||||||
for it in data.get("results", [])[:limit]:
|
for p in r.json().get("result", []):
|
||||||
out.append({"title": it.get("title", ""), "url": it.get("url", ""), "content": it.get("content", "")[:400]})
|
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
|
return out
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def ollama_decide(symbol: str, context_items: list):
|
def searx_news(symbol: str, limit: int = 12):
|
||||||
prompt = {
|
q = f"{symbol} stock news earnings guidance analyst macro risk"
|
||||||
"task": "You are a strict trading policy engine. Return JSON only.",
|
params = {"q": q, "format": "json", "language": "en"}
|
||||||
"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",
|
|
||||||
}
|
|
||||||
try:
|
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()
|
r.raise_for_status()
|
||||||
resp = r.json().get("response", "{}")
|
data = r.json()
|
||||||
d = json.loads(resp)
|
return [{"title": it.get("title", ""), "url": it.get("url", ""), "content": (it.get("content", "") or "")[:700]} for it in data.get("results", [])[:limit]]
|
||||||
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}
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# resilient fallback to hold or tiny buy
|
return []
|
||||||
return {
|
|
||||||
"action": random.choice(["hold", "hold", "buy"]),
|
|
||||||
"confidence": 0.3,
|
def extra_research(symbol: str, weak_points: list, limit: int = 6):
|
||||||
"order_usd": min(1.0, settings.max_order_usd),
|
q = f"{symbol} {' '.join(weak_points[:3])} SEC filing guidance risks competition"
|
||||||
"reason": "fallback-mode",
|
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():
|
def alpaca_headers():
|
||||||
return {
|
return {"APCA-API-KEY-ID": settings.alpaca_key, "APCA-API-SECRET-KEY": settings.alpaca_secret, "Content-Type": "application/json"}
|
||||||
"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
|
|
||||||
|
|
||||||
|
|
||||||
def place_order(symbol: str, action: str, order_usd: float):
|
def place_order(symbol: str, action: str, order_usd: float):
|
||||||
if action not in {"buy", "sell"}:
|
if action not in {"buy", "sell"}:
|
||||||
return None
|
return None
|
||||||
payload = {
|
payload = {"symbol": symbol, "side": action, "type": "market", "time_in_force": "day", "notional": round(order_usd, 2)}
|
||||||
"symbol": symbol,
|
|
||||||
"side": action,
|
|
||||||
"type": "market",
|
|
||||||
"time_in_force": "day",
|
|
||||||
"notional": round(order_usd, 2),
|
|
||||||
}
|
|
||||||
try:
|
try:
|
||||||
r = requests.post(f"{settings.alpaca_base}/v2/orders", headers=alpaca_headers(), json=payload, timeout=20)
|
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 {}}
|
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()
|
return r.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def positions_snapshot():
|
||||||
|
try:
|
||||||
|
r = requests.get(f"{settings.alpaca_base}/v2/positions", headers=alpaca_headers(), timeout=20)
|
||||||
|
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
|
||||||
|
|||||||
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
|
||||||
@@ -4,53 +4,150 @@
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<title>alpaca-llm-bot-v1</title>
|
<title>alpaca-llm-bot-v1</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<style>
|
<style>
|
||||||
:root { --bg:#0b0f17; --card:#111827; --text:#e5e7eb; --muted:#9ca3af; --ok:#10b981; --bad:#ef4444; --acc:#60a5fa; }
|
:root { --bg:#070b12; --card:#0f172a; --line:#1f2937; --text:#e5e7eb; --muted:#9ca3af; --buy:#22c55e; --sell:#ef4444; --hold:#94a3b8; --acc:#60a5fa; }
|
||||||
body{background:var(--bg);color:var(--text);font-family:Inter,system-ui,sans-serif;margin:0;padding:24px}
|
* { box-sizing: border-box; }
|
||||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}
|
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}
|
||||||
.card{background:var(--card);border:1px solid #1f2937;border-radius:12px;padding:14px}
|
h2,h3{margin:8px 0 14px}
|
||||||
.h{font-size:13px;color:var(--muted)} .v{font-size:24px;font-weight:700}
|
.sub{color:var(--muted);font-size:13px;margin-bottom:16px}
|
||||||
table{width:100%;border-collapse:collapse} th,td{padding:8px;border-bottom:1px solid #1f2937;text-align:left;font-size:13px}
|
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}
|
||||||
.buy{color:var(--ok)} .sell{color:var(--bad)} .hold{color:var(--muted)}
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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="grid">
|
||||||
<div class="card"><div class="h">Decisions (24h)</div><div class="v">{{ stats.decisions }}</div></div>
|
<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">Trades (24h)</div><div class="v">{{ stats.trades }}</div></div>
|
||||||
<div class="card"><div class="h">Executed</div><div class="v">{{ stats.executed }}</div></div>
|
<div class="card"><div class="h">Executed</div><div class="v">{{ stats.executed }}</div></div>
|
||||||
<div class="card"><div class="h">Failed</div><div class="v">{{ stats.failed }}</div></div>
|
<div class="card"><div class="h">Failed</div><div class="v">{{ stats.failed }}</div></div>
|
||||||
|
<div class="card"><div class="h">Curated Insights</div><div class="v">{{ stats.insights }}</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Recent Decisions</h3>
|
<div class="layout" style="margin-top:12px">
|
||||||
<div class="card">
|
<div class="charts">
|
||||||
<table>
|
<div class="card">
|
||||||
<thead><tr><th>Time</th><th>Symbol</th><th>Action</th><th>Confidence</th><th>Status</th><th>Reason</th></tr></thead>
|
<div class="h">Decision Flow (last 72h)</div>
|
||||||
<tbody>
|
<div class="chart-wrap"><canvas id="decisionChart"></canvas></div>
|
||||||
{% for d in decisions %}
|
</div>
|
||||||
<tr>
|
<div class="card">
|
||||||
<td>{{ d.ts }}</td><td>{{ d.symbol }}</td>
|
<div class="h">Cumulative Notional Activity</div>
|
||||||
<td class="{{ d.action }}">{{ d.action }}</td>
|
<div class="chart-wrap"><canvas id="activityChart"></canvas></div>
|
||||||
<td>{{ '%.2f'|format(d.confidence or 0) }}</td>
|
</div>
|
||||||
<td>{{ d.status }}</td>
|
</div>
|
||||||
<td>{{ d.reason }}</td>
|
|
||||||
</tr>
|
<div class="card">
|
||||||
{% endfor %}
|
<div class="h">Symbol Leaderboard</div>
|
||||||
</tbody>
|
<div id="leaderboard" class="scroll" style="margin-top:8px"></div>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Recent Trades</h3>
|
<div style="margin-top:12px" class="layout">
|
||||||
<div class="card">
|
<div>
|
||||||
<table>
|
<h3>Recent Decisions <span class="pill">latest 120</span></h3>
|
||||||
<thead><tr><th>Time</th><th>Symbol</th><th>Side</th><th>Notional</th><th>Order ID</th></tr></thead>
|
<div class="card scroll">
|
||||||
<tbody>
|
<table>
|
||||||
{% for t in trades %}
|
<thead><tr><th>Time</th><th>Symbol</th><th>Action</th><th>Conf</th><th>Status</th><th>Reason</th></tr></thead>
|
||||||
<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>
|
<tbody>
|
||||||
{% endfor %}
|
{% for d in decisions %}
|
||||||
</tbody>
|
<tr>
|
||||||
</table>
|
<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>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user