Setting MODE=live alone no longer starts trading. check_live_ready requires an active risk limit, MAX_POSITION_PCT < 1.0, LIVE_CONFIRMATION exactly "I UNDERSTAND THE RISK", a minimum balance, and exchange credentials. Enforced in Runtime.start_many (both start paths) and surfaced at GET /api/live-check; the dashboard shows a banner and blocks the start buttons. Adds tests.
264 lines
12 KiB
Markdown
264 lines
12 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, trailing_stop_pct, 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.
|
||
- `trailing_stop_pct` (e.g. `0.02` for 2%) trails the stop with the price and
|
||
never loosens: for longs it ratchets up with the high, for shorts down with the
|
||
low. The engine and backtester both call `advance_trailing_stop`, which is the
|
||
only place that math lives.
|
||
- 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.
|
||
|
||
## Go-live gate
|
||
|
||
`app/engine/live_guard.py` (`check_live_ready`) is the single place that decides
|
||
whether live trading may start. It is enforced in `Runtime.start_many` (so both
|
||
the single and multi start paths are covered) and surfaced via
|
||
`GET /api/live-check`. Never bypass it: if you add a new way to start trading,
|
||
call `check_live_ready` first. `LIVE_CONFIRMATION` must equal
|
||
`"I UNDERSTAND THE RISK"` — keep it a phrase, not a boolean.
|
||
|
||
## 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.
|
||
|
||
`POST /api/sweep` runs a cartesian grid of params through the same backtester
|
||
(`app/backtest/sweep.py`) and returns the top N rows, ranked by
|
||
`return − max_drawdown` (see `_score`). The dashboard's **Parameter sweep** panel
|
||
drives it. Like backtests, sweeps share `app/engine/portfolio.py` with the live
|
||
engine, so a winning parameter set behaves the same live.
|
||
|
||
## 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.
|