Files
crypto_trading/AGENTS.md
T
matitos 3e19612cc0
CI/CD / backend (pull_request) Successful in 5m58s
CI/CD / frontend (pull_request) Successful in 5m3s
CI/CD / build (pull_request) Skipped
CI/CD / deploy (pull_request) Skipped
CI/CD / cleanup (pull_request) Skipped
Serve /api/ws without Basic auth so the live feed connects
Browsers don't reliably replay Basic-auth credentials on a WebSocket upgrade,
so the dashboard's live feed was stuck on "reconnecting" (only the 5s REST
poll worked). Give /api/ws a higher-priority Traefik router that skips Basic
auth; the backend still requires API_TOKEN, which nginx injects on the
handshake. The deploy now fails closed unless CRYPTO_API_TOKEN is set.

Also: split the nginx /api/ws block so upgrade headers are only set there, and
add reconnect backoff (2s -> 30s) so a failing socket stops hammering.
2026-09-19 12:52:06 +00:00

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

  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.

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/kill and the UI Kill switch call engine.kill().
  • app/notify.py fans out to Telegram and/or a webhook. The runtime subscribes to trade and error events (see notify_levels) and sends them in a background task. Notifications must never break trading — failures are logged and swallowed. Use RecordingNotifier in tests (see tests/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) behind MarketData; live brokers in app/broker/ (Binance, Coinbase) behind Broker. Add a venue by writing one adapter and registering it in the respective __init__.py. Timeframes differ per venue — each adapter validates and raises ValueError on unsupported values.
  • Kraken is market-data-only; MODE=live on it raises. Don't silently fall back to paper for an unsupported live exchange.
  • Strategy presets use the StrategyConfig table via /api/presets; the dashboard's strategy form can load/save them.
  • API_TOKEN guards /api when set (get_guarded_runtime dependency). The frontend nginx injects it via the API_TOKEN_HEADER header on /api, and on the WebSocket upgrade. GET /api/health is always open. When adding routes, depend on GuardedRuntimeDep, not RuntimeDep (which no longer exists).
  • /api/ws skips Traefik Basic auth via a dedicated higher-priority router (crypto-trading-ws) because browsers don't reliably replay Basic creds on a WS upgrade. The WS is gated by API_TOKEN instead (header, injected by nginx, or ?token= for manual clients) — see websocket_endpoint. The deploy job fails closed if CRYPTO_API_TOKEN is unset. Keep the /api/ws block in nginx.conf separate 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.