The deploy job hard-failed without CRYPTO_API_TOKEN, and /api/ws (which skips Basic auth) relied on that token. Drop the requirement: the app now works with no token at all, matching the backend guard that was always a no-op when unset. When a token is configured it still protects /api and /api/ws; when it is not, /api/ws is as open as the rest of the API, which the Traefik Basic auth still guards for everything else. Adds tests for the tokenless API and WebSocket.
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. When `API_TOKEN` is set the WS requires it (header, injected by
|
|
nginx, or `?token=` for manual clients); when unset the path is open, matching
|
|
the rest of `/api`. `CRYPTO_API_TOKEN` is optional. 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.
|