From f2caca91758f510b3261fa80a40361970e9d2216 Mon Sep 17 00:00:00 2001 From: otto Date: Thu, 16 Apr 2026 22:15:08 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 15 +++++ docs/ARCHITECTURE.md | 121 +++++++++++++++++++++++++++++++++++++ docs/RUNBOOK.md | 139 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 275 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/RUNBOOK.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..43044ea --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..12b0a2e --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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/`. diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md new file mode 100644 index 0000000..7b296bd --- /dev/null +++ b/docs/RUNBOOK.md @@ -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 +```