docs: add ARCHITECTURE, RUNBOOK, CHANGELOG; reset .env.example to placeholders
ARCHITECTURE.md: system overview, data flow, SQLite schema, guard rails, risk gates, wheel flow, guide for adding new strategies. RUNBOOK.md: day-to-day ops, config reference, troubleshooting, safe shutdown, upgrade procedure. CHANGELOG.md: v0.1.0 + v0.1.1 (limit_price fix). .env.example: credentials removed, URL /v2 suffix stripped.
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [0.1.1] — 2026-04-16
|
||||||
|
### Fixed
|
||||||
|
- `executor.py`: round `limit_price` to 2 decimal places before Alpaca order submit.
|
||||||
|
Alpaca rejects prices with more than 2 decimal places (error code 42210000).
|
||||||
|
|
||||||
|
## [0.1.0] — 2026-04-16
|
||||||
|
### Added
|
||||||
|
- Python bot (`bot/`): alpaca-py 0.43, Wheel strategy (CSP + covered calls),
|
||||||
|
equity trailing stops, risk gates, SQLite state log, Typer CLI, Discord daily report.
|
||||||
|
- Next.js 14 dashboard (`dashboard/`): read-only view of bot state + Alpaca account.
|
||||||
|
- systemd user-unit templates: polling loop + daily report timer.
|
||||||
|
- `docs/STRATEGY.md`, `docs/ARCHITECTURE.md`, `docs/RUNBOOK.md`.
|
||||||
|
- Paper API defaults: `BOT_MODE=dry`, `ALPACA_ENV=paper` — nothing submitted until explicitly enabled.
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ systemd loop service │
|
||||||
|
│ alpaclaudia loop (every 900s, market hours only) │
|
||||||
|
└───────────────────────────┬─────────────────────────┘
|
||||||
|
│ tick()
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ scheduler.tick() │
|
||||||
|
│ │
|
||||||
|
│ 1. snapshot account / positions / orders ─────────┼──► Alpaca REST
|
||||||
|
│ 2. record_tick() ─────────────────────────────────┼──► SQLite ticks
|
||||||
|
│ 3. plan_wheel() ──────────────────────────────────┼──► OrderIntent[]
|
||||||
|
│ 4. plan_trailing_stops() ─────────────────────────┼──► OrderIntent[]
|
||||||
|
│ 5. risk.check_*() per intent │
|
||||||
|
│ blocked → logged, dropped │
|
||||||
|
│ 6. executor.submit_intent() ──────────────────────┼──► Alpaca REST
|
||||||
|
│ (no-op if BOT_MODE=dry) ──────────────────────┼──► SQLite order_intents
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Next.js dash │
|
||||||
|
│ :3030 │
|
||||||
|
│ │
|
||||||
|
│ reads SQLite ◄─┼── data/alpaclaudia.db
|
||||||
|
│ reads Alpaca ◄─┼── REST (read-only)
|
||||||
|
└─────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────┐
|
||||||
|
│ systemd timer │
|
||||||
|
│ 22:30 Mon–Fri │
|
||||||
|
│ alpaclaudia │
|
||||||
|
│ report │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ Discord webhook │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `bot/alpaclaudia/config.py` | Load + validate all config from `.env` |
|
||||||
|
| `bot/alpaclaudia/client.py` | Alpaca SDK wrappers (account, positions, orders, options chain) |
|
||||||
|
| `bot/alpaclaudia/state.py` | SQLite store: ticks, order_intents, events |
|
||||||
|
| `bot/alpaclaudia/risk.py` | Pre-submit risk gates (pure functions, easily testable) |
|
||||||
|
| `bot/alpaclaudia/scheduler.py` | Orchestration: tick loop, clock check, risk enforcement |
|
||||||
|
| `bot/alpaclaudia/executor.py` | Translate OrderIntent → Alpaca request; dry-run vs live |
|
||||||
|
| `bot/alpaclaudia/strategies/wheel.py` | Wheel strategy: CSP + CC selection logic |
|
||||||
|
| `bot/alpaclaudia/strategies/trailing_stop.py` | Trailing-stop planner for long equity |
|
||||||
|
| `bot/alpaclaudia/reporter.py` | Build + post Discord daily summary |
|
||||||
|
| `bot/alpaclaudia/__main__.py` | Typer CLI: tick / loop / status / report / dump-state |
|
||||||
|
|
||||||
|
## SQLite schema
|
||||||
|
|
||||||
|
**ticks** — portfolio snapshot per tick.
|
||||||
|
Fields: `id, ts, equity, cash, buying_power, mode, note`
|
||||||
|
|
||||||
|
**order_intents** — every order the bot considered, regardless of outcome.
|
||||||
|
Fields: `id, ts, strategy, symbol, side, qty, order_type, limit_price, stop_price, trail_percent, details_json, submitted, alpaca_order_id, status`
|
||||||
|
|
||||||
|
Status values: `dry_run` · `blocked` · `error` · `accepted` / Alpaca status string
|
||||||
|
|
||||||
|
**events** — tick metadata and heartbeats.
|
||||||
|
Fields: `id, ts, kind, payload_json`
|
||||||
|
|
||||||
|
## Guard rails (two-layer)
|
||||||
|
|
||||||
|
```
|
||||||
|
BOT_MODE=dry → executor.submit_intent() records intent to DB only, no network call
|
||||||
|
BOT_MODE=live → executor submits to Alpaca
|
||||||
|
|
||||||
|
ALPACA_ENV=paper → TradingClient(paper=True) → paper-api.alpaca.markets
|
||||||
|
ALPACA_ENV=live → TradingClient(paper=False) → api.alpaca.markets (real money!)
|
||||||
|
```
|
||||||
|
|
||||||
|
Both must be explicitly set. The defaults are `dry` + `paper`.
|
||||||
|
|
||||||
|
## Risk gates (bot/alpaclaudia/risk.py)
|
||||||
|
|
||||||
|
All gates are pure functions returning `CheckResult(ok, reason)`.
|
||||||
|
`scheduler.tick()` calls the appropriate gate per intent before passing to executor.
|
||||||
|
|
||||||
|
| Gate | Invariant |
|
||||||
|
|---|---|
|
||||||
|
| `check_csp` | Collateral ≤ free cash − buffer; ≤ max_position_pct of equity; ≤ max concurrent short puts |
|
||||||
|
| `check_covered_call` | Strike ≥ cost basis; ≥ qty×100 shares held |
|
||||||
|
| `check_trailing_stop` | Long position exists with ≥ qty shares |
|
||||||
|
|
||||||
|
## Strategy flow: Wheel
|
||||||
|
|
||||||
|
```
|
||||||
|
For each symbol in WHEEL_UNIVERSE:
|
||||||
|
shares_held = long stock qty
|
||||||
|
short_puts = count open short put positions
|
||||||
|
|
||||||
|
if shares_held >= 100:
|
||||||
|
uncovered_lots = shares_held // 100 − open short calls
|
||||||
|
if uncovered_lots > 0:
|
||||||
|
→ pick covered-call candidate (DTE window, target delta, above cost basis)
|
||||||
|
→ emit OrderIntent(strategy="wheel:covered_call", side="sell_to_open")
|
||||||
|
|
||||||
|
if short_puts < MAX_SHORT_PUTS_PER_SYMBOL:
|
||||||
|
→ list option contracts (DTE window)
|
||||||
|
→ get snapshots (greeks, quotes)
|
||||||
|
→ pick contract closest to TARGET_DELTA (fallback: closest to OTM_PCT below spot)
|
||||||
|
→ compute annualised yield; skip if < MIN_ANNUAL_YIELD
|
||||||
|
→ emit OrderIntent(strategy="wheel:cash_secured_put", side="sell_to_open")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding a new strategy
|
||||||
|
|
||||||
|
1. Create `bot/alpaclaudia/strategies/my_strategy.py` with a `plan_*()` function returning `list[OrderIntent]`.
|
||||||
|
2. Add a risk gate in `risk.py` if needed.
|
||||||
|
3. Wire it into `scheduler.tick()` — add `intents.extend(plan_my_strategy(...))` and a `_enforce_risk` branch.
|
||||||
|
4. Add tests in `bot/tests/`.
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
# Runbook
|
||||||
|
|
||||||
|
## Day-to-day
|
||||||
|
|
||||||
|
### Check if the bot is alive
|
||||||
|
```bash
|
||||||
|
systemctl --user status alpaclaudia-loop.service
|
||||||
|
journalctl --user -u alpaclaudia-loop.service -n 30
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual tick (one-shot, uses current BOT_MODE)
|
||||||
|
```bash
|
||||||
|
cd ~/finhacks && bot/.venv/bin/python -m alpaclaudia tick
|
||||||
|
```
|
||||||
|
|
||||||
|
### Account + position snapshot (CLI)
|
||||||
|
```bash
|
||||||
|
cd ~/finhacks && bot/.venv/bin/python -m alpaclaudia status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Post Discord report manually
|
||||||
|
```bash
|
||||||
|
cd ~/finhacks && bot/.venv/bin/python -m alpaclaudia report
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dump SQLite state (JSON, last 50 ticks/intents)
|
||||||
|
```bash
|
||||||
|
cd ~/finhacks && bot/.venv/bin/python -m alpaclaudia dump-state
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
```
|
||||||
|
http://192.168.0.93:3030/ (browser)
|
||||||
|
http://192.168.0.93:3030/api/refresh (raw JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Config changes (~/finhacks/.env)
|
||||||
|
|
||||||
|
| Change | Key | Action after |
|
||||||
|
|---|---|---|
|
||||||
|
| Add symbol to universe | `WHEEL_UNIVERSE` | Service auto-picks up on next tick (no restart needed — env file is reloaded each run) |
|
||||||
|
| Change DTE window | `WHEEL_PUT_DTE_MIN/MAX` | next tick |
|
||||||
|
| Change trail % | `TRAILING_STOP_PCT` | next tick |
|
||||||
|
| Disable trailing stops | `TRAILING_STOP_ENABLED=false` | next tick |
|
||||||
|
| Go live on paper | `BOT_MODE=live` | `systemctl --user restart alpaclaudia-loop` |
|
||||||
|
| Switch to real account | `ALPACA_ENV=live` + live key/secret | **Careful.** Restart service. |
|
||||||
|
|
||||||
|
Note: the bot reloads `.env` on every process start, but the systemd service runs as a single long-lived process. Config changes only apply after a restart unless the service is in loop mode and you run a manual tick with the new env.
|
||||||
|
|
||||||
|
To force an env reload: `systemctl --user restart alpaclaudia-loop.service`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "limit price must be limited to 2 decimal places"
|
||||||
|
Fixed in `executor.py`. Should not recur. If it does: check if a new order type path is missing `round(..., 2)`.
|
||||||
|
|
||||||
|
### "insufficient cash" block
|
||||||
|
The CSP collateral exceeds `cash − equity * MIN_CASH_BUFFER_PCT`. Either:
|
||||||
|
- The underlying's spot moved up and the closest strike now requires more cash.
|
||||||
|
- Other positions consumed cash (assignments).
|
||||||
|
- Increase `MIN_CASH_BUFFER_PCT` or reduce `MAX_POSITION_PCT` or pick cheaper underlyings.
|
||||||
|
|
||||||
|
### "collateral exceeds per-symbol cap"
|
||||||
|
Strike * 100 > `equity * MAX_POSITION_PCT`. Default is 25%. TSLA at ~$370 = $37k collateral; needs `MAX_POSITION_PCT >= 0.37`.
|
||||||
|
|
||||||
|
### "no long stock to cover"
|
||||||
|
Phase 2 (covered call) triggered but position was closed / not assigned yet. Normal — next tick it resolves.
|
||||||
|
|
||||||
|
### Options chain returns empty
|
||||||
|
Alpaca may not have an active options chain for that symbol, or DTE window yields no contracts. Check:
|
||||||
|
```bash
|
||||||
|
cd ~/finhacks && bot/.venv/bin/python -c "
|
||||||
|
from alpaclaudia.config import load_config
|
||||||
|
from alpaclaudia.client import build_clients, list_option_contracts
|
||||||
|
cfg = load_config()
|
||||||
|
from alpaclaudia.client import Clients
|
||||||
|
import os; from dotenv import load_dotenv; load_dotenv()
|
||||||
|
from alpaca.trading.client import TradingClient
|
||||||
|
from alpaca.data.historical.stock import StockHistoricalDataClient
|
||||||
|
from alpaca.data.historical.option import OptionHistoricalDataClient
|
||||||
|
tc = TradingClient(cfg.alpaca.key, cfg.alpaca.secret, paper=True)
|
||||||
|
sd = StockHistoricalDataClient(cfg.alpaca.key, cfg.alpaca.secret)
|
||||||
|
od = OptionHistoricalDataClient(cfg.alpaca.key, cfg.alpaca.secret)
|
||||||
|
clients = Clients(trading=tc, stock_data=sd, option_data=od)
|
||||||
|
contracts = list_option_contracts(clients, 'SOFI', option_type='put', dte_min=7, dte_max=45)
|
||||||
|
print(len(contracts), 'contracts')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service fails to start
|
||||||
|
```bash
|
||||||
|
journalctl --user -u alpaclaudia-loop.service -n 50 --no-pager
|
||||||
|
```
|
||||||
|
Common cause: `.env` missing or Alpaca credentials empty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stopping the loop safely
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user stop alpaclaudia-loop.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Open orders in Alpaca are **not** cancelled — they remain until filled or expire (DAY orders expire at market close). To cancel all open orders manually:
|
||||||
|
```bash
|
||||||
|
cd ~/finhacks && bot/.venv/bin/python -c "
|
||||||
|
from alpaclaudia.config import load_config
|
||||||
|
from alpaclaudia.client import build_clients
|
||||||
|
cfg = load_config()
|
||||||
|
clients = build_clients(cfg.alpaca)
|
||||||
|
clients.trading.cancel_orders()
|
||||||
|
print('all open orders cancelled')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extending the universe
|
||||||
|
|
||||||
|
Edit `WHEEL_UNIVERSE` in `~/finhacks/.env`:
|
||||||
|
```
|
||||||
|
WHEEL_UNIVERSE=TSLA,SOFI,PLTR,NIO,BAC,F,HOOD,RIVN,LCID
|
||||||
|
```
|
||||||
|
Good candidates: liquid, optionable, strike < $50 to stay within $5k collateral per lot at the default 25% cap on a $100k account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrading the bot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/finhacks
|
||||||
|
git pull
|
||||||
|
cd bot && .venv/bin/pip install -e ".[dev]"
|
||||||
|
systemctl --user restart alpaclaudia-loop.service
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user