# 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 ` 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/.py`: ```python 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/`): ```bash python -m pytest # tests ruff check . # lint ruff format . # format ``` Frontend (run from `frontend/`): ```bash 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: ```bash 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.