- GET /api/metrics renders Prometheus text (equity, P&L, per-strategy equity/pnl/allocation/halted, info) with no client library. - In-memory ring log handler (app/logs.py) exposed at GET /api/logs and a dashboard "Server logs" panel; Runtime mirrors bot events into the logger. - docs/runbook.md covers deploys, the kill switch, resetting paper state and going live. Adds tests for metrics rendering and the log ring.
13 KiB
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
- Paper by default. Never switch the default
MODEtolive. Live trading requires explicit user action and API keys. - Strategies are pure and synchronous. Compute signals in
on_candleand callctx.enter_long/ctx.enter_short/ctx.close. Never place orders, touch the DB, or make network calls from a strategy. - One file per strategy in
backend/app/engine/strategies/. It is auto-discovered — no central registry to edit. - Keep dependencies light. The backend deliberately avoids
ccxt(and itsaiohttpbuild dependency). Market data and live orders usehttpx. - Run the checks before finishing (see Commands).
Branching & delivery
- Always work on a feature branch; never commit directly to
main. mainis the deployment branch: merging/pushing tomainruns.gitea/workflows/deploy.yml(Gitea Actions) which tests, builds images and deploys the stack. Doc-only changes (**.md) skip test/deploy viapaths-ignore.- After a merged PR, delete the obsolete branch (
git push origin --delete <branch>plus localgit branch -dandgit fetch --prune). Gitea does not auto-delete. The workflow'scleanupjob also prunes merged branches. - Never commit when not explicitly asked.
Deployment shape
docker-compose.ymlis the production stack. It joins the externaldocker_defaultnetwork and is published by the shared Traefik instance atcrypto.${BASE_DOMAIN}(defaultmatitos.org). The frontend carries the Traefik labels; the backend is internal-only and reached through the frontend nginx/apiproxy (which also carries the WebSocket).docker-compose.local.ymlis 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 sharedSCA_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 editingApp.vue. CRYPTO_MODEdefaults topaper. Never change the default tolive.
Adding a strategy (the main task)
Create backend/app/engine/strategies/<key>.py:
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) |
| Orders | ctx.enter_long(size, stop_loss, take_profit, trailing_stop_pct, reason), ctx.enter_short(...), ctx.close(reason) |
Notes:
sizeis a fraction of equity (1.0 = all-in). It is optional; defaults to 1.0.stop_loss/take_profitare absolute prices; the engine enforces them.trailing_stop_pct(e.g.0.02for 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 calladvance_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
returnearly, 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 withNoneduring 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
TradingEngineruns a single strategy on a symbol/timeframe. - Every
POLL_INTERVALseconds it fetchesCANDLE_LIMITcandles, updates the mark price, enforces stop/take levels, callson_candle, then executes the emitted intents. PaperBrokersimulates fills with configurableFEE_BPSandSLIPPAGE_BPS.BinanceBrokerplaces real market orders (requires keys, only inMODE=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.pyrenders Prometheus text atGET /api/metricsstraight from the manager status — no client library. Keep new series out of hot loops.app/logs.pykeeps a small in-memory ring of log records, exposed atGET /api/logsand shown in the dashboard.Runtime._log_eventmirrors bot events into the logger so the panel is useful (uvicorn's own loggers do not propagate to the root handler).docs/runbook.mdis 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/killand the UI Kill switch callengine.kill(). app/notify.pyfans out to Telegram and/or a webhook. The runtime subscribes totradeanderrorevents (seenotify_levels) and sends them in a background task. Notifications must never break trading — failures are logged and swallowed. UseRecordingNotifierin tests (seetests/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) behindMarketData; live brokers inapp/broker/(Binance, Coinbase) behindBroker. Add a venue by writing one adapter and registering it in the respective__init__.py. Timeframes differ per venue — each adapter validates and raisesValueErroron unsupported values. - Kraken is market-data-only;
MODE=liveon it raises. Don't silently fall back to paper for an unsupported live exchange. - Strategy presets use the
StrategyConfigtable via/api/presets; the dashboard's strategy form can load/save them. API_TOKENguards/apiwhen set (get_guarded_runtimedependency). The frontend nginx injects it via theAPI_TOKEN_HEADERheader on/api, and on the WebSocket upgrade.GET /api/healthis always open. When adding routes, depend onGuardedRuntimeDep, notRuntimeDep(which no longer exists)./api/wsskips Traefik Basic auth via a dedicated higher-priority router (crypto-trading-ws) because browsers don't reliably replay Basic creds on a WS upgrade. WhenAPI_TOKENis 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_TOKENis optional. Keep the/api/wsblock innginx.confseparate 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/):
python -m pytest # tests
ruff check . # lint
ruff format . # format
Frontend (run from frontend/):
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:
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.