The deploy job hard-failed without CRYPTO_API_TOKEN, and /api/ws (which skips Basic auth) relied on that token. Drop the requirement: the app now works with no token at all, matching the backend guard that was always a no-op when unset. When a token is configured it still protects /api and /api/ws; when it is not, /api/ws is as open as the rest of the API, which the Traefik Basic auth still guards for everything else. Adds tests for the tokenless API and WebSocket.
11 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) |
| Orders | ctx.enter_long(size, stop_loss, take_profit, 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.- 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.
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.
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.