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).
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), ctx.williams_r(p) |
| 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.