Files
crypto_trading/AGENTS.md
T
matitos 9c3bee87ac
CI/CD / backend (push) Successful in 5m18s
CI/CD / frontend (push) Successful in 5m2s
CI/CD / build (push) Successful in 34s
CI/CD / cleanup (push) Successful in 6s
CI/CD / deploy (push) Successful in 22s
Crypto trading bot: engine, brokers, backtester, dashboard, deploy
FastAPI backend with a single-strategy async engine, paper and Binance
brokers, pure-python indicators, a backtester that shares the engine's
portfolio math, and a Vue dashboard with a live WebSocket feed. Adds the
Gitea Actions CI/CD workflow that deploys to Traefik via docker_default.
2026-09-18 17:02:33 +00:00

196 lines
8.1 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.
## 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
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 brokers
market/ binance + fake data feeds
models.py/store.py SQLAlchemy models and persistence
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.