Files
crypto_trading/README.md
T
matitos 0173e46cfb Go-live gate: refuse unsafe live trading
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.
2026-09-22 12:55:12 +00:00

250 lines
11 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.
# Crypto Trader
A small, hackable crypto trading bot with a web dashboard. Describe a strategy,
drop in one Python file, run it, and watch positions and P&L live.
- **Backend:** FastAPI, SQLAlchemy, SQLite (Postgres optional), pure-Python
indicators, `httpx` for market data.
- **Frontend:** Vue 3 + Vite + Tailwind dashboard with a live WebSocket feed.
- **Brokers:** `paper` (simulated fills, default) and `binance` (live spot).
- **Market data:** Binance public REST (no API key needed).
- **Backtesting:** replay historical candles through the same strategy interface,
with return/drawdown/win-rate/Sharpe metrics — so what you test is what runs.
- **Parameter sweeps:** grid-search a strategy's params and rank the results.
- **Trailing stops:** strategies can trail a stop with the price (never loosening),
enforced identically in backtest, paper and live.
> **Paper trading is the default.** Live mode places real orders and is opt-in.
> This is experimental software. Never trade more than you can afford to lose.
## How it works
```
Binance REST ──▶ MarketData ──▶ TradingEngine ──▶ Broker (paper | binance)
│
Strategy.on_candle(ctx)
│
REST /api + WebSocket /api/ws ──▶ Vue dashboard
```
The engine runs one strategy at a time. Each tick it fetches candles, updates
the mark price, enforces stop-loss/take-profit, calls the strategy, executes the
resulting intents, and persists orders, equity snapshots and events.
## Quickstart (local)
```bash
# 1. Config
cp .env.example .env
# 2. Backend
python3 -m venv .venv
.venv/bin/pip install -r backend/requirements-dev.txt
cd backend && ../.venv/bin/python -m uvicorn app.main:app --reload --port 8000
# 3. Frontend (new terminal)
cd frontend && npm install && npm run dev
```
Open http://localhost:5173. API docs are at http://localhost:8000/docs.
## Quickstart (Docker, local)
```bash
cp .env.example .env
docker compose -f docker-compose.local.yml up --build
# dashboard: http://localhost:8080 API docs: http://localhost:8000/docs
```
The default `docker-compose.yml` is the **deployment** stack (Traefik +
`docker_default`); use `docker-compose.local.yml` for a self-contained local run.
## Deployment
Merging to `main` triggers `.gitea/workflows/deploy.yml`, which tests, builds
both images, and rolls the stack out on the shared Traefik host:
- The **frontend** joins the external `docker_default` network and is published
at `crypto.${BASE_DOMAIN}` (default `crypto.matitos.org`) via Traefik, with a
Let's Encrypt cert (`certresolver=myresolvercd`).
- The **backend** stays on the internal network only; the frontend nginx proxies
`/api` (including the `/api/ws` WebSocket) to it, so API keys are never
directly exposed.
- The WebUI is protected by **fail-closed Basic auth**: the deploy aborts unless
`CRYPTO_FRONTEND_AUTH_USERS` is set (falling back to the shared
`SCA_FRONTEND_AUTH_USERS`). Generate a value with
`htpasswd -nbB user 'password'`.
- A new release is rolled back automatically if healthchecks fail.
Repo/org variables and secrets used by the deploy:
| Name | Kind | Purpose |
| --- | --- | --- |
| `CRYPTO_FRONTEND_AUTH_USERS` | var/secret | htpasswd string for the WebUI (required) |
| `BASE_DOMAIN` | secret | Base domain (default `matitos.org`) |
| `CRYPTO_MODE` | var | `paper` (default) or `live` |
| `CRYPTO_EXCHANGE` / `CRYPTO_SYMBOL` / `CRYPTO_TIMEFRAME` | var | Trading defaults |
| `CRYPTO_STARTING_BALANCE` / `CRYPTO_POLL_INTERVAL` | var | Paper account + tick rate |
| `CRYPTO_EXCHANGE_API_KEY` / `CRYPTO_EXCHANGE_API_SECRET` | secret | Required only for `live` |
| `CRYPTO_EXCHANGE_SANDBOX` | var | Use the Binance testnet |
| `CRYPTO_MAX_DAILY_LOSS` / `CRYPTO_MAX_DRAWDOWN_PCT` / `CRYPTO_MAX_POSITION_PCT` / `CRYPTO_MAX_TRADES_PER_DAY` | var | Risk limits |
| `CRYPTO_TELEGRAM_BOT_TOKEN` | secret | Telegram alerts |
| `CRYPTO_TELEGRAM_CHAT_ID` | var/secret | Telegram chat id |
| `CRYPTO_NOTIFY_WEBHOOK_URL` | secret | Generic webhook alerts |
| `CRYPTO_CORS_ORIGINS` | var | Defaults to `https://crypto.<BASE_DOMAIN>` |
| `CRYPTO_DATA_DIR` / `PERSISTENT_MOUNT` | var | Host path for the SQLite DB |
| `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` | secret | Optional; for authenticated pulls |
## Configuration
All settings are environment variables (see `.env.example`):
| Variable | Default | Purpose |
| --- | --- | --- |
| `MODE` | `paper` | `paper` or `live` |
| `EXCHANGE` | `binance` | Market data / execution venue |
| `SYMBOL` | `BTC/USDT` | Default trading pair |
| `TIMEFRAME` | `5m` | Candle interval |
| `POLL_INTERVAL` | `5` | Seconds between ticks |
| `STARTING_BALANCE` | `10000` | Paper account size |
| `FEE_BPS` / `SLIPPAGE_BPS` | `10` / `5` | Paper fill model |
| `DATABASE_URL` | `sqlite:///./data/trader.db` | Postgres: `postgresql+psycopg://...` |
| `EXCHANGE_API_KEY` / `EXCHANGE_API_SECRET` | — | Required only for live |
| `EXCHANGE_SANDBOX` | `false` | Use Binance testnet |
| `MAX_DAILY_LOSS` | `0` | Halt+flatten after this daily loss (quote ccy); 0 disables |
| `MAX_DRAWDOWN_PCT` | `0` | Halt+flatten after this % drop from peak; 0 disables |
| `MAX_POSITION_PCT` | `1.0` | Cap a single position at this fraction of equity |
| `MAX_TRADES_PER_DAY` | `0` | Max new entries per UTC day; 0 disables |
| `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | — | Telegram alerts (both required) |
| `NOTIFY_WEBHOOK_URL` | — | Generic webhook alerts |
| `API_TOKEN` / `API_TOKEN_HEADER` | — | Bearer token required on `/api` when set |
## Exchanges
Market data works out of the box on **Binance**, **Kraken** and **Coinbase**
(public REST, no API key). Set `EXCHANGE` to `binance`, `kraken` or `coinbase`.
Live execution (`MODE=live`) is implemented for **Binance** (spot, optional
testnet via `EXCHANGE_SANDBOX`) and **Coinbase** (Advanced Trade; set
`EXCHANGE_API_SECRET` to the CDP EC private key in PEM). Kraken is
market-data-only for now — `MODE=live` on Kraken raises a clear error.
## API token (defense-in-depth)
Set `API_TOKEN` to require a bearer token on all `/api` routes (health stays
open). The frontend nginx injects it server-side, so the browser never sees it
and the dashboard keeps working unchanged. This sits *behind* the Traefik Basic
auth, not instead of it.
`/api/ws` is special: browsers do not reliably replay Basic-auth credentials on
a WebSocket upgrade (which left the live feed stuck on "reconnecting"), so
Traefik serves that one path on a separate higher-priority router **without**
Basic auth. If `API_TOKEN` is set, the WebSocket still requires it (nginx
injects it on the handshake). If `API_TOKEN` is unset, `/api/ws` is as open as
the rest of `/api`, which is fine for a trusted/private deployment but is the
reason to set a token if the host is exposed.
## Going live
Setting `MODE=live` alone is **not** enough. `GET /api/live-check` (and the
dashboard banner) validate a preflight before any live order can be placed; live
trading is blocked unless *all* of these hold:
- at least one risk limit is active (`MAX_DAILY_LOSS`, `MAX_DRAWDOWN_PCT` or
`MAX_TRADES_PER_DAY`);
- `MAX_POSITION_PCT < 1.0` (a single position can never be the whole account);
- `LIVE_CONFIRMATION` is exactly `I UNDERSTAND THE RISK`;
- the starting balance is at least `MIN_LIVE_BALANCE`;
- exchange API credentials are set.
A blocked live start returns `400` with the reasons, and the dashboard shows a
banner listing them. Practise the kill switch in paper mode first.
## Multi-strategy
The **Strategies (multi)** panel runs several strategies at once, each with its
own capital slice. Set a **weight** per strategy; weights are normalised across
the run (`[1, 3]` gives 25% / 75%) and each strategy starts with
`starting_balance × allocation` as its own cash, so one strategy's losses cannot
drain another's. Risk limits apply per strategy, and **Kill all** flattens and
halts every one.
## Risk controls
The engine enforces limits on every tick and **halts + flattens** when one trips:
- **`MAX_DAILY_LOSS`** — absolute loss since the start of the UTC day.
- **`MAX_DRAWDOWN_PCT`** — percentage fall from peak equity.
- **`MAX_POSITION_PCT`** — position size cap as a fraction of equity.
- **`MAX_TRADES_PER_DAY`** — new-entry budget per UTC day.
A halt is sticky: the position is closed, the bot stops, a red banner appears in
the dashboard, and a notification is sent. Restarting the bot resets the limits.
The dashboard also has a manual **Kill switch** button (`POST /api/bot/kill`)
that flattens and halts immediately.
## Notifications
Set `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID` and/or `NOTIFY_WEBHOOK_URL` to
receive alerts on trades, stop-outs, errors and halts. Notifications are
best-effort: a failing channel never affects trading. For Telegram, create a bot
with [@BotFather](https://t.me/BotFather), message it once, then read the chat id
from `https://api.telegram.org/bot<token>/getUpdates`.
## Adding a strategy
Drop a file in `backend/app/engine/strategies/` — it is auto-discovered and
appears in the UI immediately. See **[AGENTS.md](AGENTS.md)** for the full
template and the `StrategyContext` API.
Bundled strategies: `sma_cross`, `ema_cross`, `macd_trend`,
`bollinger_reversion`, `donchian_breakout`, `momentum`, `rsi_reversion`.
## API
| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/api/status` | Bot status, equity, P&L |
| `GET` | `/api/strategies` | Available strategies + parameter specs |
| `POST` | `/api/bot/start` | Start a strategy |
| `POST` | `/api/bot/start-many` | Start several strategies with per-strategy allocations |
| `GET` | `/api/bot/status` | Per-strategy status for a multi-strategy run |
| `GET` | `/api/live-check` | Go-live preflight (blockers for live trading) |
| `POST` | `/api/bot/stop` | Stop the bot |
| `POST` | `/api/bot/kill` | Kill switch: flatten + halt |
| `POST` | `/api/backtest` | Backtest a strategy on historical candles |
| `POST` | `/api/sweep` | Grid-sweep strategy params, ranked |
| `GET` | `/api/positions` | Positions (with unrealized P&L when open) |
| `GET` | `/api/orders` | Order history |
| `GET` | `/api/events` | Activity log |
| `GET` | `/api/equity` | Equity curve points |
| `POST` | `/api/orders` | Manual market order |
| `POST` | `/api/positions/{id}/close` | Close the open position |
| `GET`/`POST` | `/api/presets` | List / save strategy presets |
| `DELETE` | `/api/presets/{id}` | Delete a preset |
| `WS` | `/api/ws` | Live status + event stream |
## Tests & lint
```bash
make test # backend pytest + frontend vitest
make lint # ruff
make build # frontend production build
```
## Project layout
```
backend/
app/
engine/ trading loop, indicators, strategies/
broker/ paper + binance execution
market/ binance + fake data feeds
api/routes.py REST + WebSocket
models.py positions, orders, equity, events
store.py persistence
tests/
frontend/
src/ Vue dashboard
```