Files
crypto_trading/AGENTS.md
T
matitos 9c3bee87ac
CI/CD / backend (push) Successful in 5m18s
CI/CD / frontend (push) Successful in 5m2s
CI/CD / build (push) Successful in 34s
CI/CD / cleanup (push) Successful in 6s
CI/CD / deploy (push) Successful in 22s
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.
2026-09-18 17:02:33 +00:00

8.1 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

  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:

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/):

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
    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:

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.