Files
crypto_trading/AGENTS.md
T
matitos d1f66c95c4 Add Supertrend and VWAP strategies plus a short-side example
- New indicators: vwap (rolling/cumulative) and supertrend (line + direction),
  exposed on StrategyContext.
- New strategies: supertrend (flip-based, optional shorts), vwap_reversion
  (mean reversion below a rolling VWAP) and ema_cross_short (short-side
  example, mirroring the long EMA cross). 10 strategies total.
- Adds indicator and strategy tests; smoke-tested all three on live BTC/USDT
  5m data.
2026-09-22 14:14:12 +00:00

12 KiB
Raw Blame History

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

  • 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.
  • trailing_stop_pct (e.g. 0.02 for 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 call advance_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 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.

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.

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. When API_TOKEN is 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_TOKEN is optional. 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.

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.