Files
crypto_trading/AGENTS.md
T
matitos d92f152f9b Add the Williams %R strategy
New williams_r indicator (in [-100, 0]) exposed as ctx.williams_r(period), and a
long-only mean-reversion strategy: enter when %R is at/below the oversold level,
exit when it reaches the overbought level, with an optional ATR stop. 11
strategies total. Adds indicator and strategy tests; smoke-tested on live
BTC/USDT 5m (backtest + parameter sweep).
2026-09-26 18:44:00 +00:00

275 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)`, `ctx.macd()`, `ctx.roc(p)`, `ctx.vwap(p)`, `ctx.supertrend(p, mult)`, `ctx.williams_r(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.
## Observability
- `app/metrics.py` renders Prometheus text at `GET /api/metrics` straight from
the manager status — no client library. Keep new series out of hot loops.
- `app/logs.py` keeps a small in-memory ring of log records, exposed at
`GET /api/logs` and shown in the dashboard. `Runtime._log_event` mirrors bot
events into the logger so the panel is useful (uvicorn's own loggers do not
propagate to the root handler).
- `docs/runbook.md` is the operations guide; update it when deploy or
recovery steps change.
## 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.