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.
This commit is contained in:
1 parent
98028248c2
commit
9c3bee87ac
73 files changed
+9759
-1
No files matched your search
@@ -0,0 +1,195 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user