Browsers don't reliably replay Basic-auth credentials on a WebSocket upgrade, so the dashboard's live feed was stuck on "reconnecting" (only the 5s REST poll worked). Give /api/ws a higher-priority Traefik router that skips Basic auth; the backend still requires API_TOKEN, which nginx injects on the handshake. The deploy now fails closed unless CRYPTO_API_TOKEN is set. Also: split the nginx /api/ws block so upgrade headers are only set there, and add reconnect backoff (2s -> 30s) so a failing socket stops hammering.
245 lines
11 KiB
Markdown
245 lines
11 KiB
Markdown
# AGENTS.md
|
|
|
|
Guidance for agents (and humans) working in this repository.
|
|
|
|
## What this is
|
|
|
|
A small, hackable crypto trading bot. A FastAPI backend runs one strategy at a
|
|
time against market data and executes orders through a paper or live broker. A
|
|
Vue dashboard shows status, positions, orders, P&L and a live activity feed.
|
|
|
|
The primary workflow is: **describe a strategy in plain English → add one small
|
|
Python file → run it from the UI.**
|
|
|
|
## Golden rules
|
|
|
|
1. **Paper by default.** Never switch the default `MODE` to `live`. Live trading
|
|
requires explicit user action and API keys.
|
|
2. **Strategies are pure and synchronous.** Compute signals in `on_candle` and
|
|
call `ctx.enter_long` / `ctx.enter_short` / `ctx.close`. Never place orders,
|
|
touch the DB, or make network calls from a strategy.
|
|
3. **One file per strategy** in `backend/app/engine/strategies/`. It is
|
|
auto-discovered — no central registry to edit.
|
|
4. **Keep dependencies light.** The backend deliberately avoids `ccxt` (and its
|
|
`aiohttp` build dependency). Market data and live orders use `httpx`.
|
|
5. **Run the checks before finishing** (see Commands).
|
|
|
|
## Branching & delivery
|
|
|
|
- Always work on a **feature branch**; never commit directly to `main`.
|
|
- `main` is the deployment branch: merging/pushing to `main` runs
|
|
`.gitea/workflows/deploy.yml` (Gitea Actions) which tests, builds images and
|
|
deploys the stack. Doc-only changes (`**.md`) skip test/deploy via
|
|
`paths-ignore`.
|
|
- **After a merged PR, delete the obsolete branch** (`git push origin --delete
|
|
<branch>` plus local `git branch -d` and `git fetch --prune`). Gitea does not
|
|
auto-delete. The workflow's `cleanup` job also prunes merged branches.
|
|
- Never commit when not explicitly asked.
|
|
|
|
### Deployment shape
|
|
|
|
- `docker-compose.yml` is the **production** stack. It joins the external
|
|
`docker_default` network and is published by the shared Traefik instance at
|
|
`crypto.${BASE_DOMAIN}` (default `matitos.org`). The **frontend** carries the
|
|
Traefik labels; the **backend** is internal-only and reached through the
|
|
frontend nginx `/api` proxy (which also carries the WebSocket).
|
|
- `docker-compose.local.yml` is the self-contained local stack (`make up`); it
|
|
needs no Traefik and publishes ports.
|
|
- The WebUI uses **fail-closed Basic auth** via the Traefik basicauth
|
|
middleware. The deploy job refuses to run unless `CRYPTO_FRONTEND_AUTH_USERS`
|
|
(or the shared `SCA_FRONTEND_AUTH_USERS`) is set. Because a JS WebSocket
|
|
cannot always replay Basic credentials, the dashboard also polls every 5s as a
|
|
fallback — keep that fallback when editing `App.vue`.
|
|
- `CRYPTO_MODE` defaults to `paper`. Never change the default to `live`.
|
|
|
|
## Adding a strategy (the main task)
|
|
|
|
Create `backend/app/engine/strategies/<key>.py`:
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
from typing import ClassVar
|
|
|
|
from app.engine.strategies.base import Strategy, StrategyContext, register
|
|
from app.models import PositionSide
|
|
|
|
|
|
@register
|
|
class MyStrategy(Strategy):
|
|
key: ClassVar[str] = "my_strategy" # snake_case, unique, used by the API
|
|
name: ClassVar[str] = "My Strategy" # shown in the UI
|
|
description: ClassVar[str] = "One sentence explaining the entry/exit logic."
|
|
|
|
# Parameters become form fields in the UI. Types are inferred from defaults.
|
|
default_params: ClassVar[dict] = {"period": 14, "size": 1.0}
|
|
param_help: ClassVar[dict] = {"period": "Lookback", "size": "Fraction of equity"}
|
|
param_bounds: ClassVar[dict] = {"period": (2, 200), "size": (0.01, 1.0)}
|
|
|
|
def on_candle(self, ctx: StrategyContext) -> None:
|
|
value = ctx.rsi(int(self.params["period"]))
|
|
if value is None:
|
|
return
|
|
if ctx.is_flat and value < 30:
|
|
ctx.enter_long(size=float(self.params["size"]), stop_loss=ctx.price * 0.98)
|
|
elif ctx.side == PositionSide.LONG and value > 70:
|
|
ctx.close()
|
|
```
|
|
|
|
`StrategyContext` gives you:
|
|
|
|
| Area | API |
|
|
| --- | --- |
|
|
| Price / candles | `ctx.price`, `ctx.candles`, `ctx.closes`, `ctx.high(n)`, `ctx.low(n)` |
|
|
| Portfolio | `ctx.is_flat`, `ctx.side`, `ctx.position`, `ctx.equity`, `ctx.cash` |
|
|
| Indicators | `ctx.sma(p)`, `ctx.ema(p)`, `ctx.rsi(p)`, `ctx.atr(p)`, `ctx.std(p)` |
|
|
| Orders | `ctx.enter_long(size, stop_loss, take_profit, reason)`, `ctx.enter_short(...)`, `ctx.close(reason)` |
|
|
|
|
Notes:
|
|
|
|
- `size` is a fraction of equity (1.0 = all-in). It is optional; defaults to 1.0.
|
|
- `stop_loss` / `take_profit` are absolute prices; the engine enforces them.
|
|
- Only one position is open at a time. Intents that conflict are ignored (logged
|
|
via the engine status, not raised).
|
|
- Raise nothing for "not enough data" — just `return` early, as above.
|
|
- Need a raw indicator series? Import from `app.engine.indicators`
|
|
(`sma`, `ema`, `rsi`, `atr`, `rolling_std`); each returns a list aligned to the
|
|
input with `None` during warmup.
|
|
|
|
### Verify the strategy
|
|
|
|
Add a focused test in `backend/tests/` that builds a `StrategyContext` with
|
|
candles you control and asserts the emitted intents. See
|
|
`tests/test_strategies.py` for the pattern. Then run the checks.
|
|
|
|
## Engine model (how execution works)
|
|
|
|
- One `TradingEngine` runs a single strategy on a symbol/timeframe.
|
|
- Every `POLL_INTERVAL` seconds it fetches `CANDLE_LIMIT` candles, updates the
|
|
mark price, enforces stop/take levels, calls `on_candle`, then executes the
|
|
emitted intents.
|
|
- `PaperBroker` simulates fills with configurable `FEE_BPS` and `SLIPPAGE_BPS`.
|
|
`BinanceBroker` places real market orders (requires keys, only in `MODE=live`).
|
|
- Portfolio invariant: `equity = cash ± qty * mark`, realized P&L accumulates on
|
|
close, and every order/equity point/event is persisted.
|
|
|
|
## Risk controls & notifications
|
|
|
|
- `app/engine/risk.py` (`RiskManager`) tracks daily loss, drawdown, position cap
|
|
and trades/day. `update(equity)` only *detects* a breach and returns a reason;
|
|
the engine's `_halt()` makes it sticky, flattens, and stops. Keep that split —
|
|
it is what lets the engine close the position before halting.
|
|
- Halt is sticky until the next `start()`. `POST /api/bot/kill` and the UI Kill
|
|
switch call `engine.kill()`.
|
|
- `app/notify.py` fans out to Telegram and/or a webhook. The runtime subscribes
|
|
to `trade` and `error` events (see `notify_levels`) and sends them in a
|
|
background task. Notifications must never break trading — failures are logged
|
|
and swallowed. Use `RecordingNotifier` in tests (see `tests/test_notify.py`).
|
|
- Status exposes `halted`, `halt_reason`, `trades_today`, `day_pnl`,
|
|
`drawdown_pct`; the dashboard renders a red banner and risk cards.
|
|
|
|
## Schema changes
|
|
|
|
`Base.metadata.create_all` creates missing tables but never alters existing ones,
|
|
so deployed SQLite databases would break on a new column. When you add a
|
|
nullable/defaulted column to an existing table, add it to `_ADDITIVE_COLUMNS` in
|
|
`app/db.py` as `(table, column, DDL)`. `init_db()` applies these on startup
|
|
(idempotent). Anything beyond additive changes (renames, type changes) needs a
|
|
real migration step.
|
|
|
|
## Exchanges, presets and API token
|
|
|
|
- Market data adapters live in `app/market/` (Binance, Kraken, Coinbase) behind
|
|
`MarketData`; live brokers in `app/broker/` (Binance, Coinbase) behind
|
|
`Broker`. Add a venue by writing one adapter and registering it in the
|
|
respective `__init__.py`. Timeframes differ per venue — each adapter validates
|
|
and raises `ValueError` on unsupported values.
|
|
- Kraken is market-data-only; `MODE=live` on it raises. Don't silently fall back
|
|
to paper for an unsupported live exchange.
|
|
- Strategy presets use the `StrategyConfig` table via `/api/presets`; the
|
|
dashboard's strategy form can load/save them.
|
|
- `API_TOKEN` guards `/api` when set (`get_guarded_runtime` dependency). The
|
|
frontend nginx injects it via the `API_TOKEN_HEADER` header on `/api`, and on
|
|
the WebSocket upgrade. `GET /api/health` is always open. When adding routes,
|
|
depend on `GuardedRuntimeDep`, not `RuntimeDep` (which no longer exists).
|
|
- `/api/ws` skips Traefik Basic auth via a dedicated higher-priority router
|
|
(`crypto-trading-ws`) because browsers don't reliably replay Basic creds on a
|
|
WS upgrade. The WS is gated by `API_TOKEN` instead (header, injected by nginx,
|
|
or `?token=` for manual clients) — see `websocket_endpoint`. The deploy job
|
|
fails closed if `CRYPTO_API_TOKEN` is unset. Keep the `/api/ws` block in
|
|
`nginx.conf` separate from `/api/` so upgrade headers are only set there.
|
|
|
|
## Backtesting
|
|
|
|
`app/backtest/` replays historical candles through the *same* strategy
|
|
interface. Crucially, it shares `app/engine/portfolio.py` (cash deltas, realized/
|
|
unrealized P&L, position sizing, stop/take exits) with the live engine, so a
|
|
strategy behaves identically in backtest, paper and live. When you change
|
|
portfolio math, change it **only** in `app/engine/portfolio.py`; both paths use
|
|
it and `tests/test_portfolio.py` covers it.
|
|
|
|
`POST /api/backtest` fetches candles from the configured feed and returns
|
|
metrics (total return, buy & hold, max drawdown, win rate, profit factor,
|
|
Sharpe), the equity curve and the trade list. The dashboard's **Backtest** panel
|
|
renders all of it. Add a strategy-focused test in `tests/test_backtest.py` if the
|
|
behaviour is non-obvious.
|
|
|
|
## Commands
|
|
|
|
Backend (run from `backend/`):
|
|
|
|
```bash
|
|
python -m pytest # tests
|
|
ruff check . # lint
|
|
ruff format . # format
|
|
```
|
|
|
|
Frontend (run from `frontend/`):
|
|
|
|
```bash
|
|
npm test # vitest
|
|
npm run build # production build
|
|
```
|
|
|
|
Whole repo: `make test`, `make lint`, `make fmt`, `make build`.
|
|
|
|
## Layout
|
|
|
|
```
|
|
backend/app/
|
|
main.py FastAPI app factory + lifespan
|
|
api/routes.py REST + WebSocket endpoints
|
|
runtime.py active engine + pub/sub fan-out to sockets
|
|
engine/
|
|
engine.py trading loop, portfolio, risk
|
|
risk.py RiskManager: limits, halt/kill
|
|
portfolio.py shared cash/P&L/sizing/risk math (engine + backtest)
|
|
context.py -> base.py (StrategyContext) lives in strategies/base.py
|
|
indicators.py pure-python indicators
|
|
strategies/ <-- add strategies here (auto-discovered)
|
|
backtest/ historical replay + metrics
|
|
broker/ paper + binance + coinbase brokers
|
|
market/ binance + kraken + coinbase + fake feeds
|
|
notify.py Telegram + webhook alerting
|
|
models.py/store.py SQLAlchemy models and persistence
|
|
db.py engine + additive column migrations
|
|
frontend/src/ Vue dashboard (App.vue + components/)
|
|
docker-compose.yml production (Traefik + docker_default)
|
|
docker-compose.local.yml local, self-contained
|
|
.gitea/workflows/ CI/CD (deploy on main)
|
|
```
|
|
|
|
## Local environment notes
|
|
|
|
This checkout was bootstrapped in a minimal Alpine sandbox. If `python` or
|
|
`node` are not found, the toolchain lives here:
|
|
|
|
```bash
|
|
export LD_LIBRARY_PATH=/home/opencode/.local/apkroot/usr/lib
|
|
export PATH=/home/opencode/.local/apkroot/usr/bin:$PATH
|
|
export NODE_ICU_DATA=/home/opencode/.local/apkroot/usr/share/icu/78.1/icudt78l.dat
|
|
```
|
|
|
|
The backend virtualenv is `.venv/` at the repo root. Postgres is optional —
|
|
SQLite works out of the box.
|