Crypto trading bot: engine, brokers, backtester, dashboard, deploy
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

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.
This commit is contained in:
matitos committed 2026-09-18 17:02:33 +00:00
1 parent 98028248c2
commit 9c3bee87ac
73 files changed
+9759 -1

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
# --- Core ---
APP_NAME=Crypto Trader
ENV=dev
# API host/port
HOST=0.0.0.0
PORT=8000
# --- Persistence ---
# SQLite by default. Point at Postgres with e.g.
# postgresql+psycopg://user:pass@localhost:5432/trader
DATABASE_URL=sqlite:///./data/trader.db
# --- Trading defaults ---
# paper = simulated fills against live market data (safe default)
# live = real orders via the exchange
MODE=paper
EXCHANGE=binance
SYMBOL=BTC/USDT
TIMEFRAME=1m
# How often the engine polls for new candles, in seconds
POLL_INTERVAL=5
# Candles fetched per tick and kept for indicators
CANDLE_LIMIT=200
# Paper broker
STARTING_BALANCE=10000
FEE_BPS=10
SLIPPAGE_BPS=5
# --- Exchange credentials (only needed for MODE=live) ---
EXCHANGE_API_KEY=
EXCHANGE_API_SECRET=
# Set true for testnet/sandbox where supported
EXCHANGE_SANDBOX=false
# --- Web ---
# Comma separated list, or * in dev
CORS_ORIGINS=*
+229
View File
@@ -0,0 +1,229 @@
name: CI/CD
on:
workflow_dispatch:
pull_request:
branches: [main]
paths-ignore:
- '**.md'
push:
branches: [main]
paths-ignore:
- '**.md'
# Never let two deploys interleave (build/up/rollback flapping).
concurrency:
group: crypto-trading-deploy
cancel-in-progress: false
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: backend/requirements.txt
- name: Install dependencies
run: pip install -r backend/requirements-dev.txt
- name: Lint (ruff)
run: |
cd backend
ruff check .
ruff format --check .
- name: Run tests
run: cd backend && python -m pytest -q
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: cd frontend && npm ci
- name: Test
run: cd frontend && npm test
- name: Build
run: cd frontend && npm run build
build:
runs-on: ubuntu-latest
needs: [backend, frontend]
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
steps:
- uses: actions/checkout@v4
- name: Log in to Docker Hub (optional)
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: |
if [ -n "$DOCKERHUB_USERNAME" ] && [ -n "$DOCKERHUB_TOKEN" ]; then
echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
else
echo "Docker Hub credentials not set; using anonymous pulls"
fi
- name: Build backend
run: |
for i in 1 2 3; do
docker build -t crypto_trading-backend ./backend && exit 0
echo "backend build failed (attempt $i); retrying in 30s"; sleep 30
done
exit 1
- name: Build frontend
run: |
for i in 1 2 3; do
docker build -t crypto_trading-frontend ./frontend && exit 0
echo "frontend build failed (attempt $i); retrying in 30s"; sleep 30
done
exit 1
deploy:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
env:
BASE_DOMAIN: ${{ secrets.BASE_DOMAIN || 'matitos.org' }}
# htpasswd string for the WebUI. Prefer the crypto-trading specific
# variable/secret, falling back to the shared org-level SCA users. The
# compose basicauth middleware is fail-closed, so it must be present for
# every `docker compose` command in this job to interpolate the file.
CRYPTO_FRONTEND_AUTH_USERS: ${{ vars.CRYPTO_FRONTEND_AUTH_USERS || secrets.CRYPTO_FRONTEND_AUTH_USERS || vars.SCA_FRONTEND_AUTH_USERS || secrets.SCA_FRONTEND_AUTH_USERS }}
CRYPTO_MODE: ${{ vars.CRYPTO_MODE || 'paper' }}
CRYPTO_EXCHANGE: ${{ vars.CRYPTO_EXCHANGE || 'binance' }}
CRYPTO_SYMBOL: ${{ vars.CRYPTO_SYMBOL || 'BTC/USDT' }}
CRYPTO_TIMEFRAME: ${{ vars.CRYPTO_TIMEFRAME || '1m' }}
CRYPTO_POLL_INTERVAL: ${{ vars.CRYPTO_POLL_INTERVAL || '5' }}
CRYPTO_STARTING_BALANCE: ${{ vars.CRYPTO_STARTING_BALANCE || '10000' }}
CRYPTO_CORS_ORIGINS: ${{ vars.CRYPTO_CORS_ORIGINS || format('https://crypto.{0}', secrets.BASE_DOMAIN || 'matitos.org') }}
CRYPTO_EXCHANGE_API_KEY: ${{ secrets.CRYPTO_EXCHANGE_API_KEY }}
CRYPTO_EXCHANGE_API_SECRET: ${{ secrets.CRYPTO_EXCHANGE_API_SECRET }}
CRYPTO_EXCHANGE_SANDBOX: ${{ vars.CRYPTO_EXCHANGE_SANDBOX || 'false' }}
CRYPTO_AUTH_RATE_LIMIT_AVERAGE: ${{ vars.CRYPTO_AUTH_RATE_LIMIT_AVERAGE || '10' }}
CRYPTO_AUTH_RATE_LIMIT_BURST: ${{ vars.CRYPTO_AUTH_RATE_LIMIT_BURST || '5' }}
# Persistent data root: repo var wins, else derive from the org
# PERSISTENT_MOUNT (absolute HOST path), else the workspace fallback.
CRYPTO_DATA_DIR: ${{ vars.CRYPTO_DATA_DIR || (vars.PERSISTENT_MOUNT && format('{0}/crypto_trading', vars.PERSISTENT_MOUNT)) || './data' }}
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Require CRYPTO_FRONTEND_AUTH_USERS
run: |
# The compose basicauth middleware is fail-closed, so refuse early
# with a clear message instead of starting an unauthenticated UI that
# can control a trading bot.
if [ -z "$CRYPTO_FRONTEND_AUTH_USERS" ]; then
echo "::error::CRYPTO_FRONTEND_AUTH_USERS is not set (repo/org variable or secret); refusing to deploy"
exit 1
fi
- name: Log in to Docker Hub (optional)
run: |
if [ -n "$DOCKERHUB_USERNAME" ] && [ -n "$DOCKERHUB_TOKEN" ]; then
echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
else
echo "Docker Hub credentials not set; using anonymous pulls"
fi
- name: Deploy with Docker Compose
run: |
echo "Deploying to crypto.${BASE_DOMAIN} (mode=${CRYPTO_MODE})"
echo "Data dir: ${CRYPTO_DATA_DIR}"
# Keep the currently-running images so we can roll back on failure.
for img in crypto_trading-backend crypto_trading-frontend; do
if docker image inspect "$img" >/dev/null 2>&1; then
docker tag "$img" "${img}:prev"
fi
done
built=0
for i in 1 2 3 4 5; do
if docker compose build; then built=1; break; fi
echo "docker compose build failed (attempt $i); retrying in 30s"
sleep 30
done
[ "$built" = "1" ] || { echo "::error::compose build failed after retries"; exit 1; }
# Bring the new stack up and wait for healthchecks. No pre-`down`, so
# the old containers keep serving until the new ones are healthy.
if ! docker compose up -d --remove-orphans --wait --wait-timeout 300; then
echo "::error::New stack is unhealthy; rolling back to previous images"
docker compose down --remove-orphans || true
for img in crypto_trading-backend crypto_trading-frontend; do
if docker image inspect "${img}:prev" >/dev/null 2>&1; then
docker tag "${img}:prev" "$img"
fi
done
docker compose up -d --remove-orphans --wait --wait-timeout 300 || true
exit 1
fi
- name: Services and recent logs
run: |
docker compose ps
docker compose logs --tail=80
- name: Health check (HTTP)
run: |
probe_backend() {
docker compose exec -T backend python -c \
"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=5).status==200 else 1)"
}
probe_frontend() {
docker compose exec -T frontend wget -q --spider http://127.0.0.1:80/
}
for i in 1 2 3 4 5 6; do
echo "Attempt $i/6..."
if probe_backend && probe_frontend; then
echo "All services healthy"
exit 0
fi
sleep 15
done
echo "::error::Health checks failed; rolling back"
docker compose down --remove-orphans || true
for img in crypto_trading-backend crypto_trading-frontend; do
if docker image inspect "${img}:prev" >/dev/null 2>&1; then
docker tag "${img}:prev" "$img"
fi
done
docker compose up -d --remove-orphans --wait --wait-timeout 300 || true
exit 1
cleanup:
runs-on: ubuntu-latest
needs: [backend, frontend]
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Delete branches merged into main
run: |
git fetch --all --prune
git branch -r --merged origin/main \
| grep -vE 'origin/(main|HEAD)([[:space:]]|$)' \
| sed -E 's|^[[:space:]]*origin/||' \
| while read -r branch; do
[ -z "$branch" ] && continue
echo "Deleting merged branch: $branch"
git push origin --delete "$branch" || echo " (could not delete $branch)"
done
+28
View File
@@ -0,0 +1,28 @@
# Python
__pycache__/
*.py[cod]
.venv/
venv/
*.egg-info/
.pytest_cache/
.ruff_cache/
.mypy_cache/
# Data / runtime
data/
*.db
*.sqlite3
*.log
# Env / secrets
.env
# Frontend
node_modules/
frontend/dist/
frontend/.vite/
# Editors / OS
.vscode/
.idea/
.DS_Store
+195
View File
@@ -0,0 +1,195 @@
# 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`:
```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.
+45
View File
@@ -0,0 +1,45 @@
PY := .venv/bin/python
BACKEND := backend
FRONTEND := frontend
.PHONY: install dev-backend dev-frontend test test-backend test-frontend lint fmt build up down deploy
install:
python3 -m venv .venv
$(PY) -m pip install -r $(BACKEND)/requirements-dev.txt
cd $(FRONTEND) && npm install
dev-backend:
cd $(BACKEND) && ../$(PY) -m uvicorn app.main:app --reload --port 8000
dev-frontend:
cd $(FRONTEND) && npm run dev
test: test-backend test-frontend
test-backend:
cd $(BACKEND) && ../$(PY) -m pytest
test-frontend:
cd $(FRONTEND) && npm test
lint:
cd $(BACKEND) && ../.venv/bin/ruff check .
cd $(BACKEND) && ../.venv/bin/ruff format --check .
fmt:
cd $(BACKEND) && ../.venv/bin/ruff format .
cd $(BACKEND) && ../.venv/bin/ruff check --fix .
build:
cd $(FRONTEND) && npm run build
up:
docker compose -f docker-compose.local.yml up --build
down:
docker compose -f docker-compose.local.yml down
# Production stack (requires the shared docker_default network + Traefik).
deploy:
docker compose up -d --build
+153 -1
View File
@@ -1,2 +1,154 @@
# crypto_trading # Crypto Trader
A small, hackable crypto trading bot with a web dashboard. Describe a strategy,
drop in one Python file, run it, and watch positions and P&L live.
- **Backend:** FastAPI, SQLAlchemy, SQLite (Postgres optional), pure-Python
indicators, `httpx` for market data.
- **Frontend:** Vue 3 + Vite + Tailwind dashboard with a live WebSocket feed.
- **Brokers:** `paper` (simulated fills, default) and `binance` (live spot).
- **Market data:** Binance public REST (no API key needed).
- **Backtesting:** replay historical candles through the same strategy interface,
with return/drawdown/win-rate/Sharpe metrics — so what you test is what runs.
> **Paper trading is the default.** Live mode places real orders and is opt-in.
> This is experimental software. Never trade more than you can afford to lose.
## How it works
```
Binance REST ──▶ MarketData ──▶ TradingEngine ──▶ Broker (paper | binance)
│
Strategy.on_candle(ctx)
│
REST /api + WebSocket /api/ws ──▶ Vue dashboard
```
The engine runs one strategy at a time. Each tick it fetches candles, updates
the mark price, enforces stop-loss/take-profit, calls the strategy, executes the
resulting intents, and persists orders, equity snapshots and events.
## Quickstart (local)
```bash
# 1. Config
cp .env.example .env
# 2. Backend
python3 -m venv .venv
.venv/bin/pip install -r backend/requirements-dev.txt
cd backend && ../.venv/bin/python -m uvicorn app.main:app --reload --port 8000
# 3. Frontend (new terminal)
cd frontend && npm install && npm run dev
```
Open http://localhost:5173. API docs are at http://localhost:8000/docs.
## Quickstart (Docker, local)
```bash
cp .env.example .env
docker compose -f docker-compose.local.yml up --build
# dashboard: http://localhost:8080 API docs: http://localhost:8000/docs
```
The default `docker-compose.yml` is the **deployment** stack (Traefik +
`docker_default`); use `docker-compose.local.yml` for a self-contained local run.
## Deployment
Merging to `main` triggers `.gitea/workflows/deploy.yml`, which tests, builds
both images, and rolls the stack out on the shared Traefik host:
- The **frontend** joins the external `docker_default` network and is published
at `crypto.${BASE_DOMAIN}` (default `crypto.matitos.org`) via Traefik, with a
Let's Encrypt cert (`certresolver=myresolvercd`).
- The **backend** stays on the internal network only; the frontend nginx proxies
`/api` (including the `/api/ws` WebSocket) to it, so API keys are never
directly exposed.
- The WebUI is protected by **fail-closed Basic auth**: the deploy aborts unless
`CRYPTO_FRONTEND_AUTH_USERS` is set (falling back to the shared
`SCA_FRONTEND_AUTH_USERS`). Generate a value with
`htpasswd -nbB user 'password'`.
- A new release is rolled back automatically if healthchecks fail.
Repo/org variables and secrets used by the deploy:
| Name | Kind | Purpose |
| --- | --- | --- |
| `CRYPTO_FRONTEND_AUTH_USERS` | var/secret | htpasswd string for the WebUI (required) |
| `BASE_DOMAIN` | secret | Base domain (default `matitos.org`) |
| `CRYPTO_MODE` | var | `paper` (default) or `live` |
| `CRYPTO_EXCHANGE` / `CRYPTO_SYMBOL` / `CRYPTO_TIMEFRAME` | var | Trading defaults |
| `CRYPTO_STARTING_BALANCE` / `CRYPTO_POLL_INTERVAL` | var | Paper account + tick rate |
| `CRYPTO_EXCHANGE_API_KEY` / `CRYPTO_EXCHANGE_API_SECRET` | secret | Required only for `live` |
| `CRYPTO_EXCHANGE_SANDBOX` | var | Use the Binance testnet |
| `CRYPTO_CORS_ORIGINS` | var | Defaults to `https://crypto.<BASE_DOMAIN>` |
| `CRYPTO_DATA_DIR` / `PERSISTENT_MOUNT` | var | Host path for the SQLite DB |
| `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` | secret | Optional; for authenticated pulls |
## Configuration
All settings are environment variables (see `.env.example`):
| Variable | Default | Purpose |
| --- | --- | --- |
| `MODE` | `paper` | `paper` or `live` |
| `EXCHANGE` | `binance` | Market data / execution venue |
| `SYMBOL` | `BTC/USDT` | Default trading pair |
| `TIMEFRAME` | `1m` | Candle interval |
| `POLL_INTERVAL` | `5` | Seconds between ticks |
| `STARTING_BALANCE` | `10000` | Paper account size |
| `FEE_BPS` / `SLIPPAGE_BPS` | `10` / `5` | Paper fill model |
| `DATABASE_URL` | `sqlite:///./data/trader.db` | Postgres: `postgresql+psycopg://...` |
| `EXCHANGE_API_KEY` / `EXCHANGE_API_SECRET` | — | Required only for live |
| `EXCHANGE_SANDBOX` | `false` | Use Binance testnet |
## Adding a strategy
Drop a file in `backend/app/engine/strategies/` — it is auto-discovered and
appears in the UI immediately. See **[AGENTS.md](AGENTS.md)** for the full
template and the `StrategyContext` API. Two examples ship in the repo:
`sma_cross` and `rsi_reversion`.
## API
| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/api/status` | Bot status, equity, P&L |
| `GET` | `/api/strategies` | Available strategies + parameter specs |
| `POST` | `/api/bot/start` | Start a strategy |
| `POST` | `/api/bot/stop` | Stop the bot |
| `POST` | `/api/backtest` | Backtest a strategy on historical candles |
| `GET` | `/api/positions` | Positions (with unrealized P&L when open) |
| `GET` | `/api/orders` | Order history |
| `GET` | `/api/events` | Activity log |
| `GET` | `/api/equity` | Equity curve points |
| `POST` | `/api/orders` | Manual market order |
| `POST` | `/api/positions/{id}/close` | Close the open position |
| `WS` | `/api/ws` | Live status + event stream |
## Tests & lint
```bash
make test # backend pytest + frontend vitest
make lint # ruff
make build # frontend production build
```
## Project layout
```
backend/
app/
engine/ trading loop, indicators, strategies/
broker/ paper + binance execution
market/ binance + fake data feeds
api/routes.py REST + WebSocket
models.py positions, orders, equity, events
store.py persistence
tests/
frontend/
src/ Vue dashboard
```
+11
View File
@@ -0,0 +1,11 @@
.venv/
tests/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
data/
*.db
*.sqlite3
.env
.git/
+17
View File
@@ -0,0 +1,17 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
RUN mkdir -p /data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
import asyncio
from typing import Annotated
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from app.engine.strategies import list_strategies, strategy_spec
from app.models import OrderSide, Position, PositionStatus
from app.runtime import Runtime
from app.schemas import (
BacktestRequest,
BacktestResult,
ClosePositionRequest,
EquityPointOut,
EventOut,
ManualOrderRequest,
OrderOut,
PositionOut,
StartRequest,
StatusOut,
StrategyOut,
)
router = APIRouter(prefix="/api")
def get_runtime(request: Request) -> Runtime:
return request.app.state.runtime
RuntimeDep = Annotated[Runtime, Depends(get_runtime)]
@router.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@router.get("/strategies", response_model=list[StrategyOut])
def strategies() -> list[StrategyOut]:
return [strategy_spec(cls) for cls in list_strategies()]
@router.get("/status", response_model=StatusOut)
def status(runtime: RuntimeDep) -> StatusOut:
return runtime.status()
@router.post("/bot/start", response_model=StatusOut)
async def start_bot(payload: StartRequest, runtime: RuntimeDep) -> StatusOut:
try:
return await runtime.start(
payload.strategy_key,
payload.params,
symbol=payload.symbol,
timeframe=payload.timeframe,
starting_balance=payload.starting_balance,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ValueError, RuntimeError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/bot/stop", response_model=StatusOut)
async def stop_bot(runtime: RuntimeDep) -> StatusOut:
return await runtime.stop()
@router.post("/backtest", response_model=BacktestResult)
async def run_backtest(payload: BacktestRequest, runtime: RuntimeDep) -> BacktestResult:
try:
return await runtime.run_backtest(payload)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"market data error: {exc}") from exc
def _position_out(position: Position, price: float | None) -> PositionOut:
out = PositionOut.model_validate(position)
if position.status == PositionStatus.OPEN and price is not None:
sign = 1 if position.side.value == "long" else -1
out.unrealized_pnl = sign * (price - position.entry_price) * position.qty
return out
@router.get("/positions", response_model=list[PositionOut])
def positions(runtime: RuntimeDep, limit: int = 50) -> list[PositionOut]:
price = runtime.engine.last_price if runtime.engine else None
return [_position_out(position, price) for position in runtime.store.list_positions(limit)]
@router.get("/orders", response_model=list[OrderOut])
def orders(runtime: RuntimeDep, limit: int = 50) -> list[OrderOut]:
return [OrderOut.model_validate(order) for order in runtime.store.list_orders(limit)]
@router.get("/events", response_model=list[EventOut])
def events(runtime: RuntimeDep, limit: int = 50) -> list[EventOut]:
return [EventOut.model_validate(event) for event in runtime.store.list_events(limit)]
@router.get("/equity", response_model=list[EquityPointOut])
def equity(runtime: RuntimeDep, limit: int = 500) -> list[EquityPointOut]:
return [EquityPointOut.model_validate(point) for point in runtime.store.list_equity(limit)]
@router.post("/orders", response_model=StatusOut)
async def manual_order(payload: ManualOrderRequest, runtime: RuntimeDep) -> StatusOut:
engine = runtime.engine
if engine is None or not engine.running:
raise HTTPException(status_code=409, detail="engine is not running")
side = OrderSide.BUY if payload.side == "buy" else OrderSide.SELL
await engine.manual_order(side, payload.qty, payload.quote_amount, payload.reason)
return engine.status()
@router.post("/positions/{position_id}/close", response_model=StatusOut)
async def close_position(
position_id: int, payload: ClosePositionRequest, runtime: RuntimeDep
) -> StatusOut:
engine = runtime.engine
if engine is None or not engine.running:
raise HTTPException(status_code=409, detail="engine is not running")
if engine.position is None or engine.position.id != position_id:
raise HTTPException(status_code=404, detail="no open position with that id")
await engine.close_current_position(payload.reason)
return engine.status()
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
runtime: Runtime = websocket.app.state.runtime
await websocket.accept()
queue = runtime.subscribe()
try:
await websocket.send_json(
{"type": "status", "data": runtime.status().model_dump(mode="json")}
)
while True:
try:
payload = await asyncio.wait_for(queue.get(), timeout=15.0)
except TimeoutError:
await websocket.send_json({"type": "ping"})
continue
await websocket.send_json(payload)
except WebSocketDisconnect:
pass
finally:
runtime.unsubscribe(queue)
+6
View File
@@ -0,0 +1,6 @@
from __future__ import annotations
from app.backtest.backtester import Backtester
from app.backtest.metrics import compute_metrics, max_drawdown_pct, sharpe_ratio
__all__ = ["Backtester", "compute_metrics", "max_drawdown_pct", "sharpe_ratio"]
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
from collections.abc import Sequence
from app.backtest.metrics import compute_metrics
from app.broker.paper import PaperBroker
from app.engine.portfolio import (
OpenPosition,
account_equity,
apply_close,
apply_open,
order_qty,
realized_pnl,
risk_exit,
)
from app.engine.strategies.base import Strategy, StrategyContext
from app.models import OrderSide, PositionSide
from app.schemas import BacktestPointOut, BacktestResult, BacktestTradeOut, Candle
def _open_side(side: PositionSide) -> OrderSide:
return OrderSide.BUY if side == PositionSide.LONG else OrderSide.SELL
def _close_side(side: PositionSide) -> OrderSide:
return OrderSide.SELL if side == PositionSide.LONG else OrderSide.BUY
class Backtester:
"""Runs a strategy bar-by-bar over historical candles.
Reuses the very same ``Strategy``/``StrategyContext`` interface and the same
portfolio math as the live engine, so results carry over to paper/live.
"""
def __init__(
self,
*,
fee_bps: float = 10.0,
slippage_bps: float = 5.0,
starting_balance: float = 10_000.0,
) -> None:
self.broker = PaperBroker(fee_bps=fee_bps, slippage_bps=slippage_bps)
self.starting_balance = starting_balance
def run(
self,
candles: Sequence[Candle],
strategy: Strategy,
symbol: str,
timeframe: str,
) -> BacktestResult:
starting = self.starting_balance
cash = starting
position: OpenPosition | None = None
trades: list[BacktestTradeOut] = []
curve: list[BacktestPointOut] = []
error: str | None = None
first_close = candles[0].close if candles else 0.0
def close_at(level: float, ts: int, reason: str) -> None:
nonlocal cash, position
assert position is not None
fill = self.broker.fill(symbol, _close_side(position.side), position.qty, level)
cash = apply_close(cash, position.side, fill.qty, fill.price, fill.fee)
pnl = realized_pnl(
position.side,
position.entry_price,
fill.price,
position.qty,
position.entry_fee,
fill.fee,
)
cost = position.entry_price * position.qty
trades.append(
BacktestTradeOut(
side=position.side.value,
entry_ts=position.entry_ts,
exit_ts=ts,
entry_price=position.entry_price,
exit_price=fill.price,
qty=position.qty,
pnl=pnl,
return_pct=(pnl / cost * 100) if cost else 0.0,
reason=reason,
)
)
position = None
try:
for i, candle in enumerate(candles):
mark = candle.close
if position is not None:
hit = risk_exit(
position.side,
position.stop_loss,
position.take_profit,
candle.high,
candle.low,
)
if hit is not None:
close_at(hit[1], candle.ts, hit[0])
equity = (
account_equity(cash, position.side, position.qty, mark) if position else cash
)
ctx = StrategyContext(candles[: i + 1], position, equity, cash)
strategy.on_candle(ctx)
for intent in ctx.take_intents():
if intent.action == "close":
if position is not None:
close_at(mark, candle.ts, intent.reason or "strategy close")
elif intent.action in {"enter_long", "enter_short"} and position is None:
side = (
PositionSide.LONG
if intent.action == "enter_long"
else PositionSide.SHORT
)
size = intent.size if intent.size is not None else 1.0
qty = order_qty(cash, size, side, cash, mark)
if qty <= 0:
continue
fill = self.broker.fill(symbol, _open_side(side), qty, mark)
cash = apply_open(cash, side, fill.qty, fill.price, fill.fee)
position = OpenPosition(
side=side,
qty=fill.qty,
entry_price=fill.price,
entry_fee=fill.fee,
stop_loss=intent.stop_loss,
take_profit=intent.take_profit,
entry_ts=candle.ts,
)
equity = (
account_equity(cash, position.side, position.qty, mark) if position else cash
)
curve.append(
BacktestPointOut(
ts=candle.ts,
equity=equity,
buy_hold=starting * (mark / first_close) if first_close else starting,
price=mark,
)
)
except Exception as exc: # surface strategy/config errors in the result
error = f"{type(exc).__name__}: {exc}"
# Force-close any open position at the last close so trades are realized.
if position is not None and candles and error is None:
last = candles[-1]
close_at(last.close, last.ts, "end of data")
if curve:
curve[-1] = BacktestPointOut(
ts=last.ts,
equity=cash,
buy_hold=curve[-1].buy_hold,
price=last.close,
)
return BacktestResult(
strategy_key=strategy.key,
strategy_name=strategy.name or strategy.key,
symbol=symbol,
timeframe=timeframe,
starting_balance=starting,
metrics=compute_metrics(curve, trades, starting, timeframe),
equity_curve=curve,
trades=trades,
error=error,
)
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from collections.abc import Sequence
from app.schemas import BacktestMetricsOut, BacktestPointOut, BacktestTradeOut
# Bars per year for annualising the Sharpe ratio. Unknown timeframes -> None.
BARS_PER_YEAR: dict[str, float] = {
"1m": 525_600,
"3m": 175_200,
"5m": 105_120,
"15m": 35_040,
"30m": 17_520,
"1h": 8_760,
"2h": 4_380,
"4h": 2_190,
"6h": 1_460,
"12h": 730,
"1d": 365,
"3d": 121,
"1w": 52,
}
def max_drawdown_pct(equities: Sequence[float]) -> float:
peak = equities[0] if equities else 0.0
worst = 0.0
for value in equities:
peak = max(peak, value)
if peak > 0:
worst = max(worst, (peak - value) / peak)
return worst * 100
def sharpe_ratio(equities: Sequence[float], timeframe: str) -> float | None:
if len(equities) < 3:
return None
returns = [
equities[i] / equities[i - 1] - 1 for i in range(1, len(equities)) if equities[i - 1] > 0
]
if len(returns) < 2:
return None
mean = sum(returns) / len(returns)
variance = sum((r - mean) ** 2 for r in returns) / (len(returns) - 1)
std = variance**0.5
periods = BARS_PER_YEAR.get(timeframe)
if std == 0 or not periods:
return None
return mean / std * (periods**0.5)
def compute_metrics(
curve: Sequence[BacktestPointOut],
trades: Sequence[BacktestTradeOut],
starting_balance: float,
timeframe: str,
) -> BacktestMetricsOut:
equities = [point.equity for point in curve] or [starting_balance]
final_equity = equities[-1]
total_return = (final_equity / starting_balance - 1) * 100 if starting_balance else 0.0
buy_hold = (
(curve[-1].buy_hold / starting_balance - 1) * 100 if curve and starting_balance else 0.0
)
returns = [trade.return_pct for trade in trades]
gross_profit = sum(trade.pnl for trade in trades if trade.pnl > 0)
gross_loss = -sum(trade.pnl for trade in trades if trade.pnl < 0)
wins = sum(1 for trade in trades if trade.pnl > 0)
return BacktestMetricsOut(
initial_equity=starting_balance,
final_equity=final_equity,
total_return_pct=total_return,
buy_hold_return_pct=buy_hold,
max_drawdown_pct=max_drawdown_pct(equities),
num_trades=len(trades),
win_rate_pct=(wins / len(trades) * 100) if trades else 0.0,
profit_factor=(gross_profit / gross_loss) if gross_loss > 0 else None,
avg_trade_pct=(sum(returns) / len(returns)) if returns else 0.0,
best_trade_pct=max(returns) if returns else 0.0,
worst_trade_pct=min(returns) if returns else 0.0,
sharpe=sharpe_ratio(equities, timeframe),
)
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from app.broker.base import Broker, Fill
from app.broker.binance import TESTNET_URL, BinanceBroker
from app.broker.paper import PaperBroker
from app.config import Settings
from app.market.binance import BASE_URL
def build_broker(settings: Settings) -> Broker:
if settings.mode == "live":
return BinanceBroker(
api_key=settings.exchange_api_key,
api_secret=settings.exchange_api_secret,
base_url=TESTNET_URL if settings.exchange_sandbox else BASE_URL,
)
return PaperBroker(fee_bps=settings.fee_bps, slippage_bps=settings.slippage_bps)
__all__ = ["Broker", "Fill", "PaperBroker", "build_broker"]
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from app.models import OrderSide
@dataclass(slots=True)
class Fill:
symbol: str
side: OrderSide
qty: float
price: float
fee: float
ts: datetime
@property
def notional(self) -> float:
return self.qty * self.price
class Broker(ABC):
"""Executes market orders. Paper and live brokers share this interface."""
@abstractmethod
async def buy(self, symbol: str, qty: float, ref_price: float) -> Fill:
raise NotImplementedError
@abstractmethod
async def sell(self, symbol: str, qty: float, ref_price: float) -> Fill:
raise NotImplementedError
async def close(self) -> None:
return None
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
import hashlib
import hmac
import time
from urllib.parse import urlencode
import httpx
from app.broker.base import Broker, Fill
from app.market.binance import BASE_URL, to_exchange_symbol
from app.models import OrderSide, utcnow
TESTNET_URL = "https://testnet.binance.vision"
class BinanceBroker(Broker):
"""Live Binance spot market-order execution (requires API credentials)."""
def __init__(
self,
api_key: str,
api_secret: str,
base_url: str = BASE_URL,
timeout: float = 10.0,
) -> None:
if not api_key or not api_secret:
raise ValueError("Binance broker requires EXCHANGE_API_KEY and EXCHANGE_API_SECRET")
self.api_key = api_key
self.api_secret = api_secret.encode()
self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout)
def _sign(self, params: dict) -> dict:
params = {**params, "timestamp": int(time.time() * 1000), "recvWindow": 5000}
query = urlencode(params)
signature = hmac.new(self.api_secret, query.encode(), hashlib.sha256).hexdigest()
return {**params, "signature": signature}
async def _market_order(self, symbol: str, side: OrderSide, qty: float) -> Fill:
params = self._sign(
{
"symbol": to_exchange_symbol(symbol),
"side": side.value.upper(),
"type": "MARKET",
"quantity": f"{qty:.8f}".rstrip("0").rstrip("."),
}
)
response = await self._client.post(
"/api/v3/order",
params=params,
headers={"X-MBX-APIKEY": self.api_key},
)
response.raise_for_status()
data = response.json()
executed = float(data.get("executedQty", qty))
quote = float(data.get("cummulativeQuoteQty", 0.0))
price = quote / executed if executed else 0.0
fee = sum(float(fill.get("commission", 0.0)) for fill in data.get("fills", []))
return Fill(
symbol=symbol,
side=side,
qty=executed,
price=price,
fee=fee,
ts=utcnow(),
)
async def buy(self, symbol: str, qty: float, ref_price: float) -> Fill:
return await self._market_order(symbol, OrderSide.BUY, qty)
async def sell(self, symbol: str, qty: float, ref_price: float) -> Fill:
return await self._market_order(symbol, OrderSide.SELL, qty)
async def close(self) -> None:
await self._client.aclose()
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from app.broker.base import Broker, Fill
from app.models import OrderSide, utcnow
class PaperBroker(Broker):
"""Simulated fills against a reference price, with fees and slippage.
Buys fill slightly above and sells slightly below the reference price to
approximate adverse slippage.
"""
def __init__(self, fee_bps: float = 10.0, slippage_bps: float = 5.0) -> None:
self.fee_bps = fee_bps
self.slippage_bps = slippage_bps
def _fee(self, notional: float) -> float:
return abs(notional) * self.fee_bps / 10_000
def fill(self, symbol: str, side: OrderSide, qty: float, ref_price: float) -> Fill:
"""Synchronous fill used by the backtester (same model as buy/sell)."""
direction = 1 if side == OrderSide.BUY else -1
price = ref_price * (1 + direction * self.slippage_bps / 10_000)
return Fill(
symbol=symbol,
side=side,
qty=qty,
price=price,
fee=self._fee(qty * price),
ts=utcnow(),
)
async def buy(self, symbol: str, qty: float, ref_price: float) -> Fill:
return self.fill(symbol, OrderSide.BUY, qty, ref_price)
async def sell(self, symbol: str, qty: float, ref_price: float) -> Fill:
return self.fill(symbol, OrderSide.SELL, qty, ref_price)
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
REPO_ROOT = Path(__file__).resolve().parents[2]
DATA_DIR = REPO_ROOT / "data"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(REPO_ROOT / ".env", ".env"),
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = "Crypto Trader"
env: str = "dev"
host: str = "0.0.0.0"
port: int = 8000
database_url: str = "sqlite:///./data/trader.db"
mode: str = "paper"
exchange: str = "binance"
symbol: str = "BTC/USDT"
timeframe: str = "1m"
poll_interval: float = 5.0
candle_limit: int = 200
starting_balance: float = 10_000.0
fee_bps: float = 10.0
slippage_bps: float = 5.0
exchange_api_key: str = ""
exchange_api_secret: str = ""
exchange_sandbox: bool = False
cors_origins: str = "*"
@field_validator("mode")
@classmethod
def _validate_mode(cls, value: str) -> str:
value = value.lower().strip()
if value not in {"paper", "live"}:
raise ValueError("mode must be 'paper' or 'live'")
return value
@property
def cors_origin_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@property
def is_live(self) -> bool:
return self.mode == "live"
@lru_cache
def get_settings() -> Settings:
settings = Settings()
DATA_DIR.mkdir(parents=True, exist_ok=True)
return settings
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from collections.abc import Iterator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.config import REPO_ROOT, get_settings
from app.models import Base
_settings = get_settings()
def _normalize_url(url: str) -> tuple[str, dict]:
connect_args: dict = {}
if url.startswith("sqlite"):
prefix, _, path = url.partition("sqlite:///")
if path and not path.startswith("/"):
url = f"{prefix}sqlite:///{REPO_ROOT / path}"
connect_args = {"check_same_thread": False}
return url, connect_args
_url, _connect_args = _normalize_url(_settings.database_url)
engine = create_engine(_url, connect_args=_connect_args, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def init_db() -> None:
Base.metadata.create_all(bind=engine)
def get_session() -> Iterator[Session]:
session = SessionLocal()
try:
yield session
finally:
session.close()
View File
Whitespace-only changes.
+390
View File
@@ -0,0 +1,390 @@
from __future__ import annotations
import asyncio
import contextlib
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
from app.broker.base import Broker
from app.config import Settings
from app.engine.portfolio import (
account_equity,
apply_close,
apply_open,
order_qty,
realized_pnl,
risk_exit,
unrealized_pnl,
)
from app.engine.strategies import Strategy, get_strategy
from app.engine.strategies.base import StrategyContext
from app.market.base import MarketData
from app.models import OrderSide, Position, PositionSide
from app.schemas import Candle, EventOut, StatusOut
from app.store import Store
Publish = Callable[[dict[str, Any]], None]
class TradingEngine:
"""Single-strategy trading loop.
The engine owns portfolio state (cash, open position), calls the strategy
once per candle, turns emitted intents into orders, and enforces stop-loss /
take-profit levels.
"""
def __init__(
self,
*,
feed: MarketData,
broker: Broker,
store: Store,
settings: Settings,
) -> None:
self.feed = feed
self.broker = broker
self.store = store
self.settings = settings
self.running = False
self.error: str | None = None
self.last_tick: datetime | None = None
self.last_price: float | None = None
self.cash: float = settings.starting_balance
self.starting_balance: float = settings.starting_balance
self.realized_pnl: float = 0.0
self.position: Position | None = None
self.strategy: Strategy | None = None
self.strategy_key: str | None = None
self.symbol: str = settings.symbol
self.timeframe: str = settings.timeframe
self._task: asyncio.Task | None = None
self._publish_cb: Publish | None = None
# --- lifecycle ----------------------------------------------------
def set_publisher(self, publish: Publish | None) -> None:
self._publish_cb = publish
async def start(
self,
strategy_key: str,
params: dict[str, Any] | None = None,
*,
symbol: str | None = None,
timeframe: str | None = None,
starting_balance: float | None = None,
) -> None:
if self.running:
raise RuntimeError("engine already running")
strategy_cls = get_strategy(strategy_key)
self.strategy = strategy_cls(params)
self.strategy_key = strategy_key
self.symbol = symbol or self.settings.symbol
self.timeframe = timeframe or self.settings.timeframe
self.starting_balance = float(starting_balance or self.settings.starting_balance)
self.store.abandon_open_positions(strategy_key, reason="reset on engine start")
self.position = None
self.cash = self.starting_balance
self.realized_pnl = 0.0
self.last_price = None
self.error = None
self.last_tick = None
self.running = True
self._emit("info", f"Started {strategy_cls.name or strategy_key} on {self.symbol}")
self._broadcast_status()
self._task = asyncio.create_task(self._run())
async def stop(self) -> None:
if not self.running:
return
self.running = False
if self._task:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
self._emit("info", "Engine stopped")
self._broadcast_status()
async def shutdown(self) -> None:
await self.stop()
await self.feed.close()
await self.broker.close()
# --- loop ---------------------------------------------------------
async def _run(self) -> None:
while self.running:
try:
await self.tick()
except asyncio.CancelledError:
raise
except Exception as exc: # keep the loop alive on transient errors
self.error = f"{type(exc).__name__}: {exc}"
self._emit("error", f"tick failed: {self.error}")
await asyncio.sleep(self.settings.poll_interval)
async def tick(self) -> None:
candles = await self.feed.fetch_candles(
self.symbol, self.timeframe, self.settings.candle_limit
)
if not candles:
return
self.last_price = candles[-1].close
self.last_tick = datetime.now(UTC)
self.error = None
if self.position is not None:
await self._check_risk(candles[-1])
if self.strategy is not None:
ctx = StrategyContext(
candles=candles,
position=self.position,
equity=self.equity,
cash=self.cash,
)
try:
self.strategy.on_candle(ctx)
except Exception as exc:
self.error = f"strategy error: {type(exc).__name__}: {exc}"
self._emit("error", self.error)
await self._process_intents(ctx)
self.store.record_equity(self.equity, self.cash, self.last_price)
self._broadcast_status()
# --- portfolio helpers -------------------------------------------
@property
def equity(self) -> float:
if self.position is None:
return self.cash
return account_equity(
self.cash, self.position.side, self.position.qty, self.last_price or 0.0
)
@property
def unrealized_pnl(self) -> float:
if self.position is None or self.last_price is None:
return 0.0
return unrealized_pnl(
self.position.side, self.position.entry_price, self.last_price, self.position.qty
)
async def _process_intents(self, ctx: StrategyContext) -> None:
for intent in ctx.take_intents():
if intent.action == "close":
if self.position is not None:
await self._close_position(intent.reason or "strategy close")
elif intent.action in {"enter_long", "enter_short"}:
if self.position is not None:
continue
side = PositionSide.LONG if intent.action == "enter_long" else PositionSide.SHORT
await self._open_position(
side,
size=intent.size if intent.size is not None else 1.0,
stop_loss=intent.stop_loss,
take_profit=intent.take_profit,
reason=intent.reason or intent.action,
)
async def _open_position(
self,
side: PositionSide,
*,
size: float,
stop_loss: float | None,
take_profit: float | None,
reason: str,
qty_override: float | None = None,
) -> None:
price = self.last_price
if not price or price <= 0:
return
if qty_override is not None:
qty = qty_override
else:
qty = order_qty(self.equity, size, side, self.cash, price)
if qty <= 0:
return
fill = (
await self.broker.buy(self.symbol, qty, price)
if side == PositionSide.LONG
else await self.broker.sell(self.symbol, qty, price)
)
self.cash = apply_open(self.cash, side, fill.qty, fill.price, fill.fee)
self.position = self.store.create_position(
strategy_key=self.strategy_key or "manual",
symbol=self.symbol,
side=side,
qty=fill.qty,
entry_price=fill.price,
entry_fee=fill.fee,
stop_loss=stop_loss,
take_profit=take_profit,
)
self.store.record_order(
position_id=self.position.id,
strategy_key=self.position.strategy_key,
symbol=self.symbol,
side=fill.side,
qty=fill.qty,
price=fill.price,
fee=fill.fee,
reason=reason,
)
self._emit(
"trade",
f"Opened {side.value} {fill.qty:.6f} {self.symbol} @ {fill.price:.4f} ({reason})",
)
async def _close_position(self, reason: str) -> None:
position = self.position
price = self.last_price
if position is None or not price:
return
fill = (
await self.broker.sell(self.symbol, position.qty, price)
if position.side == PositionSide.LONG
else await self.broker.buy(self.symbol, position.qty, price)
)
self.cash = apply_close(self.cash, position.side, fill.qty, fill.price, fill.fee)
realized = realized_pnl(
position.side,
position.entry_price,
fill.price,
position.qty,
position.entry_fee,
fill.fee,
)
self.realized_pnl += realized
self.store.close_position(
position,
exit_price=fill.price,
exit_fee=fill.fee,
realized_pnl=realized,
)
self.store.record_order(
position_id=position.id,
strategy_key=position.strategy_key,
symbol=self.symbol,
side=fill.side,
qty=fill.qty,
price=fill.price,
fee=fill.fee,
reason=reason,
)
self.position = None
self._emit(
"trade",
f"Closed {position.side.value} {position.qty:.6f} {self.symbol} "
f"@ {fill.price:.4f} pnl={realized:.2f} ({reason})",
)
async def _check_risk(self, candle: Candle) -> None:
position = self.position
if position is None:
return
hit = risk_exit(
position.side,
position.stop_loss,
position.take_profit,
candle.high,
candle.low,
)
if hit is not None:
reason, level = hit
self.last_price = level
await self._close_position(reason)
# --- manual controls ----------------------------------------------
async def manual_order(
self, side: OrderSide, qty: float | None, quote_amount: float | None, reason: str
) -> None:
price = self.last_price or await self.feed.fetch_price(self.symbol)
self.last_price = price
if qty is None:
notional = quote_amount if quote_amount is not None else self.cash * 0.99
qty = notional / price
if qty <= 0:
return
if side == OrderSide.BUY:
if self.position is not None:
return
await self._open_position(
PositionSide.LONG,
size=0.0,
stop_loss=None,
take_profit=None,
reason=reason,
qty_override=qty,
)
else:
if self.position is not None:
await self._close_position(reason or "manual close")
else:
await self._open_position(
PositionSide.SHORT,
size=0.0,
stop_loss=None,
take_profit=None,
reason=reason,
qty_override=qty,
)
async def close_current_position(self, reason: str = "manual") -> None:
if self.position is not None:
if self.last_price is None:
self.last_price = await self.feed.fetch_price(self.symbol)
await self._close_position(reason)
# --- status / events ----------------------------------------------
def status(self) -> StatusOut:
strategy_name = None
if self.strategy is not None:
strategy_name = self.strategy.name or self.strategy.key
return StatusOut(
running=self.running,
mode=self.settings.mode,
exchange=self.settings.exchange,
symbol=self.symbol,
timeframe=self.timeframe,
strategy_key=self.strategy_key,
strategy_name=strategy_name,
params=dict(self.strategy.params) if self.strategy else {},
price=self.last_price,
cash=self.cash,
equity=self.equity,
starting_balance=self.starting_balance,
realized_pnl=self.realized_pnl,
unrealized_pnl=self.unrealized_pnl,
total_pnl=self.equity - self.starting_balance,
open_positions=1 if self.position else 0,
last_tick=self.last_tick,
error=self.error,
)
def _emit(self, level: str, message: str) -> None:
event = self.store.record_event(level, message, self.strategy_key)
self._publish(
{"type": "event", "data": EventOut.model_validate(event).model_dump(mode="json")}
)
def _broadcast_status(self) -> None:
self._publish({"type": "status", "data": self.status().model_dump(mode="json")})
def _publish(self, payload: dict[str, Any]) -> None:
if self._publish_cb is not None:
self._publish_cb(payload)
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
from collections.abc import Sequence
from app.schemas import Candle
Number = float | None
def sma(values: Sequence[float], period: int) -> list[Number]:
if period <= 0:
raise ValueError("period must be positive")
out: list[Number] = [None] * len(values)
window_sum = 0.0
for i, value in enumerate(values):
window_sum += value
if i >= period:
window_sum -= values[i - period]
if i >= period - 1:
out[i] = window_sum / period
return out
def ema(values: Sequence[float], period: int) -> list[Number]:
if period <= 0:
raise ValueError("period must be positive")
out: list[Number] = [None] * len(values)
if len(values) < period:
return out
multiplier = 2 / (period + 1)
seed = sum(values[:period]) / period
out[period - 1] = seed
prev = seed
for i in range(period, len(values)):
prev = (values[i] - prev) * multiplier + prev
out[i] = prev
return out
def rsi(values: Sequence[float], period: int = 14) -> list[Number]:
if period <= 0:
raise ValueError("period must be positive")
out: list[Number] = [None] * len(values)
if len(values) <= period:
return out
gains = 0.0
losses = 0.0
for i in range(1, period + 1):
change = values[i] - values[i - 1]
gains += max(change, 0.0)
losses += max(-change, 0.0)
avg_gain = gains / period
avg_loss = losses / period
out[period] = _rsi_value(avg_gain, avg_loss)
for i in range(period + 1, len(values)):
change = values[i] - values[i - 1]
gain = max(change, 0.0)
loss = max(-change, 0.0)
avg_gain = (avg_gain * (period - 1) + gain) / period
avg_loss = (avg_loss * (period - 1) + loss) / period
out[i] = _rsi_value(avg_gain, avg_loss)
return out
def _rsi_value(avg_gain: float, avg_loss: float) -> float:
if avg_loss == 0:
return 100.0
rs = avg_gain / avg_loss
return 100 - (100 / (1 + rs))
def true_range(candles: Sequence[Candle]) -> list[float]:
out: list[float] = []
for i, candle in enumerate(candles):
if i == 0:
out.append(candle.high - candle.low)
continue
prev_close = candles[i - 1].close
out.append(
max(
candle.high - candle.low,
abs(candle.high - prev_close),
abs(candle.low - prev_close),
)
)
return out
def atr(candles: Sequence[Candle], period: int = 14) -> list[Number]:
if period <= 0:
raise ValueError("period must be positive")
tr = true_range(candles)
out: list[Number] = [None] * len(tr)
if len(tr) < period:
return out
prev = sum(tr[:period]) / period
out[period - 1] = prev
for i in range(period, len(tr)):
prev = (prev * (period - 1) + tr[i]) / period
out[i] = prev
return out
def rolling_std(values: Sequence[float], period: int) -> list[Number]:
if period <= 0:
raise ValueError("period must be positive")
out: list[Number] = [None] * len(values)
for i in range(period - 1, len(values)):
window = values[i - period + 1 : i + 1]
mean = sum(window) / period
variance = sum((x - mean) ** 2 for x in window) / period
out[i] = variance**0.5
return out
def last(series: Sequence[Number]) -> Number:
for value in reversed(series):
if value is not None:
return value
return None
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
from dataclasses import dataclass
from app.models import PositionSide
# All functions here are pure and shared by the live engine and the backtester
# so both compute cash, P&L and risk exits identically.
@dataclass
class OpenPosition:
"""Lightweight position used by the backtester (the engine uses a DB row)."""
side: PositionSide
qty: float
entry_price: float
entry_fee: float = 0.0
stop_loss: float | None = None
take_profit: float | None = None
entry_ts: int = 0
def apply_open(cash: float, side: PositionSide, qty: float, price: float, fee: float) -> float:
"""Cash after opening a position (buying long / selling short)."""
notional = qty * price
if side == PositionSide.LONG:
return cash - notional - fee
return cash + notional - fee
def apply_close(cash: float, side: PositionSide, qty: float, price: float, fee: float) -> float:
"""Cash after closing a position (selling long / buying back short)."""
notional = qty * price
if side == PositionSide.LONG:
return cash + notional - fee
return cash - notional - fee
def realized_pnl(
side: PositionSide,
entry_price: float,
exit_price: float,
qty: float,
entry_fee: float,
exit_fee: float,
) -> float:
gross = (
(exit_price - entry_price) * qty
if side == PositionSide.LONG
else (entry_price - exit_price) * qty
)
return gross - entry_fee - exit_fee
def unrealized_pnl(side: PositionSide, entry_price: float, mark: float, qty: float) -> float:
if side == PositionSide.LONG:
return (mark - entry_price) * qty
return (entry_price - mark) * qty
def position_value(side: PositionSide, qty: float, mark: float) -> float:
"""Mark-to-market value of the position as an asset (negative for shorts)."""
return qty * mark if side == PositionSide.LONG else -qty * mark
def account_equity(cash: float, side: PositionSide, qty: float, mark: float) -> float:
return cash + position_value(side, qty, mark)
def order_qty(equity: float, size: float, side: PositionSide, cash: float, price: float) -> float:
"""Position size as a fraction of equity, capped by available cash for longs."""
if price <= 0:
return 0.0
size = max(0.0, min(size, 1.0))
notional = equity * size
if side == PositionSide.LONG:
notional = min(notional, cash * 0.99)
return notional / price
def risk_exit(
side: PositionSide,
stop_loss: float | None,
take_profit: float | None,
high: float,
low: float,
) -> tuple[str, float] | None:
"""Return (reason, exit_price) if a stop/take level was hit within the bar."""
if side == PositionSide.LONG:
if stop_loss is not None and low <= stop_loss:
return "stop loss", stop_loss
if take_profit is not None and high >= take_profit:
return "take profit", take_profit
else:
if stop_loss is not None and high >= stop_loss:
return "stop loss", stop_loss
if take_profit is not None and low <= take_profit:
return "take profit", take_profit
return None
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
import importlib
import pkgutil
from app.engine.strategies.base import (
Strategy,
StrategyContext,
get_strategy,
list_strategies,
param_specs,
register,
strategy_spec,
)
def discover() -> None:
"""Import every module in this package so its strategies self-register."""
for module_info in pkgutil.iter_modules(__path__):
if module_info.name.startswith("_") or module_info.name in {"base"}:
continue
importlib.import_module(f"{__name__}.{module_info.name}")
discover()
__all__ = [
"Strategy",
"StrategyContext",
"get_strategy",
"list_strategies",
"param_specs",
"register",
"strategy_spec",
]
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, ClassVar, Literal, Protocol
from app.engine.indicators import atr, ema, rolling_std, rsi, sma
from app.models import PositionSide
from app.schemas import Candle, ParamSpec, StrategyOut
class PositionView(Protocol):
"""Minimal position shape a strategy can see (DB row or backtest position)."""
side: PositionSide
qty: float
entry_price: float
@dataclass(slots=True)
class Intent:
"""An order request emitted by a strategy. Executed by the engine."""
action: Literal["enter_long", "enter_short", "close"]
size: float | None = None
stop_loss: float | None = None
take_profit: float | None = None
reason: str = ""
class StrategyContext:
"""Read-only market/portfolio view plus order intent helpers.
A fresh context is created for every candle. Strategy code stays synchronous
and side-effect free: it calls ``enter_long`` / ``enter_short`` / ``close``
and the engine executes the resulting intent after ``on_candle`` returns.
"""
def __init__(
self,
candles: Sequence[Candle],
position: PositionView | None,
equity: float,
cash: float,
) -> None:
self._candles = list(candles)
self._position = position
self._equity = equity
self._cash = cash
self._intents: list[Intent] = []
# --- market state -------------------------------------------------
@property
def candles(self) -> list[Candle]:
return self._candles
@property
def price(self) -> float:
return self._candles[-1].close
@property
def closes(self) -> list[float]:
return [c.close for c in self._candles]
def high(self, lookback: int) -> float:
return max(c.high for c in self._candles[-lookback:])
def low(self, lookback: int) -> float:
return min(c.low for c in self._candles[-lookback:])
# --- portfolio state ----------------------------------------------
@property
def position(self) -> PositionView | None:
return self._position
@property
def is_flat(self) -> bool:
return self._position is None
@property
def side(self) -> PositionSide | None:
return self._position.side if self._position else None
@property
def equity(self) -> float:
return self._equity
@property
def cash(self) -> float:
return self._cash
# --- indicators ----------------------------------------------------
def sma(self, period: int) -> float | None:
return sma(self.closes, period)[-1]
def ema(self, period: int) -> float | None:
return ema(self.closes, period)[-1]
def rsi(self, period: int = 14) -> float | None:
return rsi(self.closes, period)[-1]
def atr(self, period: int = 14) -> float | None:
return atr(self._candles, period)[-1]
def std(self, period: int = 20) -> float | None:
return rolling_std(self.closes, period)[-1]
# --- order intents ------------------------------------------------
def enter_long(
self,
size: float | None = None,
stop_loss: float | None = None,
take_profit: float | None = None,
reason: str = "",
) -> None:
self._intents.append(Intent("enter_long", size, stop_loss, take_profit, reason))
def enter_short(
self,
size: float | None = None,
stop_loss: float | None = None,
take_profit: float | None = None,
reason: str = "",
) -> None:
self._intents.append(Intent("enter_short", size, stop_loss, take_profit, reason))
def close(self, reason: str = "") -> None:
self._intents.append(Intent("close", reason=reason))
def take_intents(self) -> list[Intent]:
intents, self._intents = self._intents, []
return intents
class Strategy(ABC):
"""Base class for trading strategies.
Subclass it, set ``key``/``name``/``default_params`` and implement
``on_candle``. Save the file under ``app/engine/strategies/`` and it is
auto-discovered.
"""
key: ClassVar[str] = ""
name: ClassVar[str] = ""
description: ClassVar[str] = ""
default_params: ClassVar[dict[str, Any]] = {}
param_help: ClassVar[dict[str, str]] = {}
param_bounds: ClassVar[dict[str, tuple[float, float]]] = {}
def __init__(self, params: dict[str, Any] | None = None) -> None:
unknown = set(params or {}) - set(self.default_params)
if unknown:
raise ValueError(f"unknown params for {self.key}: {sorted(unknown)}")
self.params: dict[str, Any] = {**self.default_params, **(params or {})}
@abstractmethod
def on_candle(self, ctx: StrategyContext) -> None:
raise NotImplementedError
def _infer_type(value: Any) -> Literal["int", "float", "bool", "str"]:
if isinstance(value, bool):
return "bool"
if isinstance(value, int):
return "int"
if isinstance(value, float):
return "float"
return "str"
def param_specs(strategy_cls: type[Strategy]) -> list[ParamSpec]:
specs: list[ParamSpec] = []
for name, default in strategy_cls.default_params.items():
bounds = strategy_cls.param_bounds.get(name)
specs.append(
ParamSpec(
name=name,
type=_infer_type(default),
default=default,
description=strategy_cls.param_help.get(name, ""),
min=bounds[0] if bounds else None,
max=bounds[1] if bounds else None,
)
)
return specs
_REGISTRY: dict[str, type[Strategy]] = {}
def register(strategy_cls: type[Strategy]) -> type[Strategy]:
"""Class decorator that makes a strategy discoverable by key."""
if not strategy_cls.key:
raise ValueError(f"{strategy_cls.__name__} must define a non-empty 'key'")
_REGISTRY[strategy_cls.key] = strategy_cls
return strategy_cls
def get_strategy(key: str) -> type[Strategy]:
try:
return _REGISTRY[key]
except KeyError:
raise KeyError(f"unknown strategy '{key}'. available: {sorted(_REGISTRY)}") from None
def list_strategies() -> list[type[Strategy]]:
return sorted(_REGISTRY.values(), key=lambda cls: cls.name or cls.key)
def strategy_spec(strategy_cls: type[Strategy]) -> StrategyOut:
return StrategyOut(
key=strategy_cls.key,
name=strategy_cls.name or strategy_cls.key,
description=strategy_cls.description,
params=param_specs(strategy_cls),
)
@@ -0,0 +1,65 @@
from __future__ import annotations
from typing import ClassVar
from app.engine.strategies.base import Strategy, StrategyContext, register
from app.models import PositionSide
@register
class RsiReversion(Strategy):
"""Buy oversold RSI, exit on overbought, with an ATR-based stop."""
key: ClassVar[str] = "rsi_reversion"
name: ClassVar[str] = "RSI Mean Reversion"
description: ClassVar[str] = (
"Enter long when RSI drops below the oversold level and exit when it rises "
"above the overbought level. Places an ATR-based stop loss and take profit."
)
default_params: ClassVar[dict] = {
"period": 14,
"oversold": 30.0,
"overbought": 70.0,
"atr_period": 14,
"atr_stop_mult": 2.0,
"atr_target_mult": 3.0,
"size": 1.0,
}
param_help: ClassVar[dict] = {
"period": "RSI lookback period",
"oversold": "RSI level below which to buy",
"overbought": "RSI level above which to exit",
"atr_period": "ATR lookback for stop sizing",
"atr_stop_mult": "Stop distance as a multiple of ATR",
"atr_target_mult": "Take-profit distance as a multiple of ATR",
"size": "Fraction of equity to allocate per trade",
}
param_bounds: ClassVar[dict] = {
"period": (2, 100),
"oversold": (1, 50),
"overbought": (50, 99),
"atr_stop_mult": (0.5, 10.0),
"atr_target_mult": (0.5, 20.0),
"size": (0.01, 1.0),
}
def on_candle(self, ctx: StrategyContext) -> None:
period = int(self.params["period"])
current = ctx.rsi(period)
if current is None:
return
if ctx.is_flat and current < float(self.params["oversold"]):
stop = target = None
atr_value = ctx.atr(int(self.params["atr_period"]))
if atr_value:
stop = ctx.price - float(self.params["atr_stop_mult"]) * atr_value
target = ctx.price + float(self.params["atr_target_mult"]) * atr_value
ctx.enter_long(
size=float(self.params["size"]),
stop_loss=stop,
take_profit=target,
reason=f"RSI {current:.1f} below oversold",
)
elif ctx.side == PositionSide.LONG and current > float(self.params["overbought"]):
ctx.close(reason=f"RSI {current:.1f} above overbought")
@@ -0,0 +1,48 @@
from __future__ import annotations
from typing import ClassVar
from app.engine.indicators import sma
from app.engine.strategies.base import Strategy, StrategyContext, register
from app.models import PositionSide
@register
class SmaCross(Strategy):
"""Classic moving-average crossover, long-only."""
key: ClassVar[str] = "sma_cross"
name: ClassVar[str] = "SMA Crossover"
description: ClassVar[str] = (
"Open a long position when the fast SMA crosses above the slow SMA, "
"close it when the fast SMA crosses back below."
)
default_params: ClassVar[dict] = {"fast": 10, "slow": 30, "size": 1.0}
param_help: ClassVar[dict] = {
"fast": "Fast SMA period",
"slow": "Slow SMA period",
"size": "Fraction of equity to allocate per trade",
}
param_bounds: ClassVar[dict] = {"fast": (2, 200), "slow": (3, 400), "size": (0.01, 1.0)}
def on_candle(self, ctx: StrategyContext) -> None:
fast_period = int(self.params["fast"])
slow_period = int(self.params["slow"])
closes = ctx.closes
if len(closes) < slow_period + 2:
return
fast = sma(closes, fast_period)
slow = sma(closes, slow_period)
f_prev, f_now = fast[-2], fast[-1]
s_prev, s_now = slow[-2], slow[-1]
if None in (f_prev, f_now, s_prev, s_now):
return
crossed_up = f_prev <= s_prev and f_now > s_now
crossed_down = f_prev >= s_prev and f_now < s_now
if ctx.is_flat and crossed_up:
ctx.enter_long(size=float(self.params["size"]), reason="fast SMA crossed above slow")
elif ctx.side == PositionSide.LONG and crossed_down:
ctx.close(reason="fast SMA crossed below slow")
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import router
from app.config import Settings, get_settings
from app.db import init_db
from app.runtime import Runtime
def create_app(
settings: Settings | None = None,
runtime: Runtime | None = None,
) -> FastAPI:
settings = settings or get_settings()
runtime = runtime or Runtime(settings)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
init_db()
app.state.runtime = runtime
yield
await runtime.shutdown()
app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router)
@app.get("/")
def root() -> dict[str, str]:
return {"name": settings.app_name, "docs": "/docs"}
return app
app = create_app()
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from app.config import Settings
from app.market.base import MarketData
from app.market.binance import BinanceMarketData
from app.market.fake import FakeMarketData
def build_market_data(settings: Settings) -> MarketData:
exchange = settings.exchange.lower()
if exchange in {"binance", "binanceus"}:
return BinanceMarketData()
raise ValueError(f"unsupported exchange: {settings.exchange}")
__all__ = ["FakeMarketData", "MarketData", "build_market_data"]
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from app.schemas import Candle
class MarketData(ABC):
"""Read-only market data source (candles + last price)."""
@abstractmethod
async def fetch_candles(self, symbol: str, timeframe: str, limit: int) -> list[Candle]:
raise NotImplementedError
@abstractmethod
async def fetch_price(self, symbol: str) -> float:
raise NotImplementedError
async def close(self) -> None:
return None
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
import httpx
from app.market.base import MarketData
from app.schemas import Candle
BASE_URL = "https://api.binance.com"
def to_exchange_symbol(symbol: str) -> str:
return symbol.replace("/", "").upper()
class BinanceMarketData(MarketData):
"""Public Binance spot market data over REST (no API key required)."""
def __init__(self, base_url: str = BASE_URL, timeout: float = 10.0) -> None:
self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout)
async def fetch_candles(self, symbol: str, timeframe: str, limit: int) -> list[Candle]:
response = await self._client.get(
"/api/v3/klines",
params={
"symbol": to_exchange_symbol(symbol),
"interval": timeframe,
"limit": limit,
},
)
response.raise_for_status()
return [
Candle(
ts=int(row[0]),
open=float(row[1]),
high=float(row[2]),
low=float(row[3]),
close=float(row[4]),
volume=float(row[5]),
)
for row in response.json()
]
async def fetch_price(self, symbol: str) -> float:
response = await self._client.get(
"/api/v3/ticker/price",
params={"symbol": to_exchange_symbol(symbol)},
)
response.raise_for_status()
return float(response.json()["price"])
async def close(self) -> None:
await self._client.aclose()
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import math
from collections.abc import Sequence
from app.market.base import MarketData
from app.schemas import Candle
class FakeMarketData(MarketData):
"""In-memory market data for tests and offline demos."""
def __init__(self, candles: Sequence[Candle] | None = None) -> None:
self.candles: list[Candle] = list(candles or [])
@classmethod
def from_closes(cls, closes: Sequence[float]) -> FakeMarketData:
candles = [
Candle(
ts=1_700_000_000_000 + i * 60_000,
open=close,
high=close,
low=close,
close=close,
volume=1.0,
)
for i, close in enumerate(closes)
]
return cls(candles)
@classmethod
def sine_wave(cls, n: int = 200, amplitude: float = 100.0, period: int = 20) -> FakeMarketData:
base = 1000.0
closes = [base + amplitude * math.sin(2 * math.pi * i / period) for i in range(n)]
return cls.from_closes(closes)
def append(self, candle: Candle) -> None:
self.candles.append(candle)
async def fetch_candles(self, symbol: str, timeframe: str, limit: int) -> list[Candle]:
return self.candles[-limit:]
async def fetch_price(self, symbol: str) -> float:
if not self.candles:
raise ValueError("no candles available")
return self.candles[-1].close
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any
from sqlalchemy import (
JSON,
DateTime,
Float,
ForeignKey,
Integer,
String,
)
from sqlalchemy import (
Enum as SAEnum,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
def utcnow() -> datetime:
return datetime.now(UTC)
class Base(DeclarativeBase):
pass
class PositionSide(StrEnum):
LONG = "long"
SHORT = "short"
class PositionStatus(StrEnum):
OPEN = "open"
CLOSED = "closed"
class OrderSide(StrEnum):
BUY = "buy"
SELL = "sell"
class StrategyConfig(Base):
__tablename__ = "strategy_configs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
strategy_key: Mapped[str] = mapped_column(String(64), index=True)
symbol: Mapped[str] = mapped_column(String(32))
timeframe: Mapped[str] = mapped_column(String(16))
params: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class Position(Base):
__tablename__ = "positions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
strategy_key: Mapped[str] = mapped_column(String(64), index=True)
symbol: Mapped[str] = mapped_column(String(32), index=True)
side: Mapped[PositionSide] = mapped_column(SAEnum(PositionSide))
qty: Mapped[float] = mapped_column(Float)
entry_price: Mapped[float] = mapped_column(Float)
status: Mapped[PositionStatus] = mapped_column(
SAEnum(PositionStatus), default=PositionStatus.OPEN, index=True
)
stop_loss: Mapped[float | None] = mapped_column(Float, nullable=True)
take_profit: Mapped[float | None] = mapped_column(Float, nullable=True)
entry_fee: Mapped[float] = mapped_column(Float, default=0.0)
exit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
exit_fee: Mapped[float] = mapped_column(Float, default=0.0)
realized_pnl: Mapped[float | None] = mapped_column(Float, nullable=True)
opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
orders: Mapped[list[Order]] = relationship(back_populates="position")
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
position_id: Mapped[int | None] = mapped_column(
ForeignKey("positions.id"), nullable=True, index=True
)
strategy_key: Mapped[str] = mapped_column(String(64), index=True)
symbol: Mapped[str] = mapped_column(String(32))
side: Mapped[OrderSide] = mapped_column(SAEnum(OrderSide))
qty: Mapped[float] = mapped_column(Float)
price: Mapped[float] = mapped_column(Float)
fee: Mapped[float] = mapped_column(Float, default=0.0)
reason: Mapped[str] = mapped_column(String(255), default="")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, index=True
)
position: Mapped[Position | None] = relationship(back_populates="orders")
class EquitySnapshot(Base):
__tablename__ = "equity_snapshots"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
equity: Mapped[float] = mapped_column(Float)
cash: Mapped[float] = mapped_column(Float)
price: Mapped[float] = mapped_column(Float)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, index=True
)
class Event(Base):
__tablename__ = "events"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
level: Mapped[str] = mapped_column(String(16), default="info")
message: Mapped[str] = mapped_column(String(512))
strategy_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, index=True
)
+140
View File
@@ -0,0 +1,140 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from contextlib import suppress
from typing import Any
from app.backtest import Backtester
from app.broker import build_broker
from app.broker.base import Broker
from app.config import Settings
from app.engine.engine import TradingEngine
from app.engine.strategies import get_strategy
from app.market import build_market_data
from app.market.base import MarketData
from app.schemas import BacktestRequest, BacktestResult, StatusOut
from app.store import Store
FeedFactory = Callable[[Settings], MarketData]
BrokerFactory = Callable[[Settings], Broker]
class Runtime:
"""Owns the active engine and fans out live updates to WebSocket clients."""
def __init__(
self,
settings: Settings,
store: Store | None = None,
*,
feed_factory: FeedFactory = build_market_data,
broker_factory: BrokerFactory = build_broker,
) -> None:
self.settings = settings
self.store = store or Store()
self._feed_factory = feed_factory
self._broker_factory = broker_factory
self.engine: TradingEngine | None = None
self._subscribers: set[asyncio.Queue] = set()
# --- pub/sub ------------------------------------------------------
def subscribe(self) -> asyncio.Queue:
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
self._subscribers.add(queue)
return queue
def unsubscribe(self, queue: asyncio.Queue) -> None:
self._subscribers.discard(queue)
def _broadcast(self, payload: dict[str, Any]) -> None:
for queue in list(self._subscribers):
if queue.full():
with suppress(asyncio.QueueEmpty):
queue.get_nowait()
with suppress(asyncio.QueueFull):
queue.put_nowait(payload)
# --- lifecycle ----------------------------------------------------
async def start(
self,
strategy_key: str,
params: dict[str, Any] | None = None,
*,
symbol: str | None = None,
timeframe: str | None = None,
starting_balance: float | None = None,
) -> StatusOut:
if self.engine is not None and self.engine.running:
await self.stop()
engine = TradingEngine(
feed=self._feed_factory(self.settings),
broker=self._broker_factory(self.settings),
store=self.store,
settings=self.settings,
)
engine.set_publisher(self._broadcast)
self.engine = engine
await engine.start(
strategy_key,
params,
symbol=symbol,
timeframe=timeframe,
starting_balance=starting_balance,
)
return engine.status()
async def stop(self) -> StatusOut:
if self.engine is not None:
await self.engine.stop()
return self.engine.status()
return self._idle_status()
async def run_backtest(self, request: BacktestRequest) -> BacktestResult:
symbol = request.symbol or self.settings.symbol
timeframe = request.timeframe or self.settings.timeframe
strategy_cls = get_strategy(request.strategy_key)
strategy = strategy_cls(request.params)
feed = self._feed_factory(self.settings)
try:
candles = await feed.fetch_candles(symbol, timeframe, request.limit)
finally:
await feed.close()
backtester = Backtester(
fee_bps=self.settings.fee_bps,
slippage_bps=self.settings.slippage_bps,
starting_balance=request.starting_balance or self.settings.starting_balance,
)
return backtester.run(candles, strategy, symbol, timeframe)
async def shutdown(self) -> None:
if self.engine is not None:
await self.engine.shutdown()
def status(self) -> StatusOut:
if self.engine is not None:
return self.engine.status()
return self._idle_status()
def _idle_status(self) -> StatusOut:
return StatusOut(
running=False,
mode=self.settings.mode,
exchange=self.settings.exchange,
symbol=self.settings.symbol,
timeframe=self.settings.timeframe,
strategy_key=None,
strategy_name=None,
params={},
price=None,
cash=self.settings.starting_balance,
equity=self.settings.starting_balance,
starting_balance=self.settings.starting_balance,
realized_pnl=0.0,
unrealized_pnl=0.0,
total_pnl=0.0,
open_positions=0,
last_tick=None,
)
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class Candle(BaseModel):
ts: int
open: float
high: float
low: float
close: float
volume: float
class ParamSpec(BaseModel):
name: str
type: Literal["int", "float", "bool", "str"]
default: Any
description: str = ""
min: float | None = None
max: float | None = None
class StrategyOut(BaseModel):
key: str
name: str
description: str
params: list[ParamSpec]
class PositionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
strategy_key: str
symbol: str
side: str
qty: float
entry_price: float
status: str
stop_loss: float | None = None
take_profit: float | None = None
exit_price: float | None = None
realized_pnl: float | None = None
unrealized_pnl: float | None = None
opened_at: datetime
closed_at: datetime | None = None
class OrderOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
position_id: int | None
strategy_key: str
symbol: str
side: str
qty: float
price: float
fee: float
reason: str
created_at: datetime
class EventOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
level: str
message: str
strategy_key: str | None
created_at: datetime
class EquityPointOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
equity: float
cash: float
price: float
created_at: datetime
class StatusOut(BaseModel):
running: bool
mode: str
exchange: str
symbol: str
timeframe: str
strategy_key: str | None
strategy_name: str | None
params: dict[str, Any] = Field(default_factory=dict)
price: float | None
cash: float
equity: float
starting_balance: float
realized_pnl: float
unrealized_pnl: float
total_pnl: float
open_positions: int
last_tick: datetime | None
error: str | None = None
class StartRequest(BaseModel):
strategy_key: str
symbol: str | None = None
timeframe: str | None = None
params: dict[str, Any] = Field(default_factory=dict)
starting_balance: float | None = None
class ManualOrderRequest(BaseModel):
side: Literal["buy", "sell"]
qty: float | None = None
quote_amount: float | None = None
reason: str = "manual"
class ClosePositionRequest(BaseModel):
reason: str = "manual"
class BacktestRequest(BaseModel):
strategy_key: str
params: dict[str, Any] = Field(default_factory=dict)
symbol: str | None = None
timeframe: str | None = None
limit: int = Field(default=500, ge=20, le=1000)
starting_balance: float | None = None
class BacktestTradeOut(BaseModel):
side: str
entry_ts: int
exit_ts: int
entry_price: float
exit_price: float
qty: float
pnl: float
return_pct: float
reason: str
class BacktestPointOut(BaseModel):
ts: int
equity: float
buy_hold: float
price: float
class BacktestMetricsOut(BaseModel):
initial_equity: float
final_equity: float
total_return_pct: float
buy_hold_return_pct: float
max_drawdown_pct: float
num_trades: int
win_rate_pct: float
profit_factor: float | None
avg_trade_pct: float
best_trade_pct: float
worst_trade_pct: float
sharpe: float | None
class BacktestResult(BaseModel):
strategy_key: str
strategy_name: str
symbol: str
timeframe: str
starting_balance: float
metrics: BacktestMetricsOut
equity_curve: list[BacktestPointOut]
trades: list[BacktestTradeOut]
error: str | None = None
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from app.db import SessionLocal
from app.models import (
EquitySnapshot,
Event,
Order,
OrderSide,
Position,
PositionSide,
PositionStatus,
StrategyConfig,
)
class Store:
"""Thin persistence layer around the trading tables."""
def __init__(self, session_factory: sessionmaker = SessionLocal) -> None:
self._session_factory: sessionmaker = session_factory
def create_position(
self,
*,
strategy_key: str,
symbol: str,
side: PositionSide,
qty: float,
entry_price: float,
entry_fee: float,
stop_loss: float | None = None,
take_profit: float | None = None,
) -> Position:
with self._session_factory() as session:
position = Position(
strategy_key=strategy_key,
symbol=symbol,
side=side,
qty=qty,
entry_price=entry_price,
entry_fee=entry_fee,
stop_loss=stop_loss,
take_profit=take_profit,
status=PositionStatus.OPEN,
)
session.add(position)
session.commit()
return position
def close_position(
self, position: Position, *, exit_price: float, exit_fee: float, realized_pnl: float
) -> Position:
from app.models import utcnow
with self._session_factory() as session:
merged = session.merge(position)
merged.status = PositionStatus.CLOSED
merged.exit_price = exit_price
merged.exit_fee = exit_fee
merged.realized_pnl = realized_pnl
merged.closed_at = utcnow()
session.commit()
return merged
def abandon_open_positions(self, strategy_key: str, reason: str = "reset") -> int:
"""Mark lingering open positions closed at entry price (used on restart)."""
from app.models import utcnow
with self._session_factory() as session:
positions = list(
session.scalars(
select(Position).where(
Position.strategy_key == strategy_key,
Position.status == PositionStatus.OPEN,
)
).all()
)
for position in positions:
position.status = PositionStatus.CLOSED
position.exit_price = position.entry_price
position.exit_fee = 0.0
position.realized_pnl = -position.entry_fee
position.closed_at = utcnow()
session.add_all(positions)
session.commit()
return len(positions)
def open_position_for(self, strategy_key: str) -> Position | None:
with self._session_factory() as session:
return session.scalars(
select(Position)
.where(
Position.strategy_key == strategy_key,
Position.status == PositionStatus.OPEN,
)
.order_by(Position.id.desc())
).first()
def record_order(
self,
*,
position_id: int | None,
strategy_key: str,
symbol: str,
side: OrderSide,
qty: float,
price: float,
fee: float,
reason: str,
) -> Order:
with self._session_factory() as session:
order = Order(
position_id=position_id,
strategy_key=strategy_key,
symbol=symbol,
side=side,
qty=qty,
price=price,
fee=fee,
reason=reason,
)
session.add(order)
session.commit()
return order
def record_event(self, level: str, message: str, strategy_key: str | None = None) -> Event:
with self._session_factory() as session:
event = Event(level=level, message=message, strategy_key=strategy_key)
session.add(event)
session.commit()
return event
def record_equity(self, equity: float, cash: float, price: float) -> None:
with self._session_factory() as session:
session.add(EquitySnapshot(equity=equity, cash=cash, price=price))
session.commit()
def list_positions(self, limit: int = 50) -> list[Position]:
with self._session_factory() as session:
return list(
session.scalars(select(Position).order_by(Position.id.desc()).limit(limit)).all()
)
def list_orders(self, limit: int = 50) -> list[Order]:
with self._session_factory() as session:
return list(session.scalars(select(Order).order_by(Order.id.desc()).limit(limit)).all())
def list_events(self, limit: int = 50) -> list[Event]:
with self._session_factory() as session:
return list(session.scalars(select(Event).order_by(Event.id.desc()).limit(limit)).all())
def list_equity(self, limit: int = 500) -> list[EquitySnapshot]:
with self._session_factory() as session:
rows = list(
session.scalars(
select(EquitySnapshot).order_by(EquitySnapshot.id.desc()).limit(limit)
).all()
)
return list(reversed(rows))
def save_strategy_config(
self, strategy_key: str, symbol: str, timeframe: str, params: dict
) -> StrategyConfig:
with self._session_factory() as session:
config = StrategyConfig(
strategy_key=strategy_key, symbol=symbol, timeframe=timeframe, params=params
)
session.add(config)
session.commit()
return config
+4
View File
@@ -0,0 +1,4 @@
[pytest]
asyncio_mode = auto
testpaths = tests
addopts = -q
+5
View File
@@ -0,0 +1,5 @@
-r requirements.txt
pytest>=8.3
pytest-asyncio>=0.25
anyio>=4.7
ruff>=0.8
+11
View File
@@ -0,0 +1,11 @@
# Core web stack
fastapi>=0.115
uvicorn>=0.34
pydantic>=2.9
pydantic-settings>=2.7
# Persistence (sync SQLAlchemy; SQLite by default, Postgres optional)
SQLAlchemy>=2.0.36
# HTTP client used for market data + live exchange REST calls
httpx>=0.28
# WebSocket protocol support for uvicorn / starlette
websockets>=14.0
+9
View File
@@ -0,0 +1,9 @@
line-length = 100
target-version = "py311"
[lint]
select = ["E", "F", "I", "UP", "B", "C4", "SIM"]
ignore = ["B008"]
[format]
quote-style = "double"
+63
View File
@@ -0,0 +1,63 @@
# ruff: noqa: E402, I001
from __future__ import annotations
import os
os.environ["DATABASE_URL"] = "sqlite:///./data/test_trader.db"
os.environ["FEE_BPS"] = "0"
os.environ["SLIPPAGE_BPS"] = "0"
os.environ["POLL_INTERVAL"] = "3600"
os.environ["STARTING_BALANCE"] = "10000"
from collections.abc import Iterator # noqa: E402
import pytest # noqa: E402
from app.broker.paper import PaperBroker # noqa: E402
from app.config import Settings # noqa: E402
from app.market.fake import FakeMarketData # noqa: E402
from app.models import Base # noqa: E402
from app.db import engine as db_engine # noqa: E402
from app.runtime import Runtime # noqa: E402
from app.store import Store # noqa: E402
@pytest.fixture
def settings() -> Settings:
return Settings(
mode="paper",
symbol="BTC/USDT",
timeframe="1m",
poll_interval=3600,
starting_balance=10_000,
fee_bps=0,
slippage_bps=0,
)
@pytest.fixture(autouse=True)
def clean_db() -> Iterator[None]:
Base.metadata.drop_all(bind=db_engine)
Base.metadata.create_all(bind=db_engine)
yield
Base.metadata.drop_all(bind=db_engine)
@pytest.fixture
def store() -> Store:
return Store()
@pytest.fixture
def market() -> FakeMarketData:
return FakeMarketData.from_closes([100.0, 101.0, 102.0])
@pytest.fixture
def runtime(settings: Settings, store: Store, market: FakeMarketData) -> Runtime:
return Runtime(
settings,
store,
feed_factory=lambda _settings: market,
broker_factory=lambda _settings: PaperBroker(fee_bps=0, slippage_bps=0),
)
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from app.main import create_app
from app.market.fake import FakeMarketData
from app.runtime import Runtime
def test_health_and_strategies(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
assert client.get("/api/health").json() == {"status": "ok"}
keys = {s["key"] for s in client.get("/api/strategies").json()}
assert {"sma_cross", "rsi_reversion"} <= keys
def test_start_status_stop_flow(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
status = client.get("/api/status").json()
assert status["running"] is False
started = client.post(
"/api/bot/start",
json={"strategy_key": "sma_cross", "params": {"fast": 2, "slow": 4}},
)
assert started.status_code == 200
assert started.json()["running"] is True
assert client.get("/api/positions").status_code == 200
assert client.get("/api/orders").status_code == 200
assert client.get("/api/events").status_code == 200
stopped = client.post("/api/bot/stop").json()
assert stopped["running"] is False
def test_unknown_strategy_returns_404(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
response = client.post("/api/bot/start", json={"strategy_key": "does_not_exist"})
assert response.status_code == 404
def test_backtest_endpoint(runtime: Runtime, market: FakeMarketData) -> None:
market.candles = FakeMarketData.sine_wave(200).candles
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
response = client.post(
"/api/backtest",
json={"strategy_key": "sma_cross", "params": {"fast": 5, "slow": 20}, "limit": 200},
)
assert response.status_code == 200
data = response.json()
assert data["strategy_key"] == "sma_cross"
assert data["error"] is None
assert len(data["equity_curve"]) == 200
assert "total_return_pct" in data["metrics"]
assert data["metrics"]["num_trades"] >= 1
def test_backtest_unknown_strategy_404(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
response = client.post("/api/backtest", json={"strategy_key": "nope"})
assert response.status_code == 404
def test_backtest_limit_validation(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
assert (
client.post("/api/backtest", json={"strategy_key": "sma_cross", "limit": 5}).status_code
== 422
)
def test_websocket_sends_initial_status(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client, client.websocket_connect("/api/ws") as websocket:
message = websocket.receive_json()
assert message["type"] == "status"
assert message["data"]["running"] is False
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
from typing import ClassVar
import pytest
from app.backtest import Backtester
from app.backtest.metrics import max_drawdown_pct, sharpe_ratio
from app.engine.strategies.base import Strategy, StrategyContext
from app.schemas import BacktestPointOut, BacktestTradeOut, Candle
class HoldStrategy(Strategy):
"""Enter long on the first candle and stay in (closed at end of data)."""
key: ClassVar[str] = "test_hold"
name: ClassVar[str] = "Test Hold"
default_params: ClassVar[dict] = {}
def on_candle(self, ctx: StrategyContext) -> None:
if ctx.is_flat:
ctx.enter_long(size=1.0)
class NeverStrategy(Strategy):
key: ClassVar[str] = "test_never"
name: ClassVar[str] = "Test Never"
default_params: ClassVar[dict] = {}
def on_candle(self, ctx: StrategyContext) -> None:
return None
class StopStrategy(Strategy):
key: ClassVar[str] = "test_stop"
name: ClassVar[str] = "Test Stop"
default_params: ClassVar[dict] = {}
def on_candle(self, ctx: StrategyContext) -> None:
if ctx.is_flat and not getattr(self, "_entered", False):
self._entered = True
ctx.enter_long(size=1.0, stop_loss=ctx.price * 0.9)
def candles_from_closes(closes: list[float]) -> list[Candle]:
return [Candle(ts=i, open=c, high=c, low=c, close=c, volume=1.0) for i, c in enumerate(closes)]
def test_hold_backtest_math() -> None:
backtester = Backtester(fee_bps=0, slippage_bps=0, starting_balance=10_000)
result = backtester.run(candles_from_closes([100, 110, 120]), HoldStrategy(), "BTC/USDT", "1h")
assert result.error is None
assert result.metrics.num_trades == 1
# 99 units bought at 100 (cash capped at 99%), closed at 120.
assert result.metrics.final_equity == pytest.approx(11_980.0)
assert result.trades[0].pnl == pytest.approx(1_980.0)
assert result.trades[0].return_pct == pytest.approx(20.0)
assert result.trades[0].reason == "end of data"
assert len(result.equity_curve) == 3
def test_no_trades_leaves_balance_untouched() -> None:
backtester = Backtester(fee_bps=10, slippage_bps=5, starting_balance=5_000)
result = backtester.run(
candles_from_closes([100, 101, 102, 103]), NeverStrategy(), "BTC/USDT", "1m"
)
assert result.metrics.num_trades == 0
assert result.metrics.final_equity == pytest.approx(5_000.0)
assert result.metrics.total_return_pct == pytest.approx(0.0)
assert result.metrics.max_drawdown_pct == pytest.approx(0.0)
def test_stop_loss_is_honoured() -> None:
backtester = Backtester(fee_bps=0, slippage_bps=0, starting_balance=10_000)
candles = candles_from_closes([100, 100, 80])
candles[2] = Candle(ts=2, open=100, high=100, low=80, close=80, volume=1.0)
result = backtester.run(candles, StopStrategy(), "BTC/USDT", "1h")
assert result.metrics.num_trades == 1
assert "stop loss" in result.trades[0].reason
assert result.metrics.final_equity < 10_000
def test_strategy_error_is_surfaced() -> None:
class Boom(Strategy):
key = "test_boom"
name = "Boom"
default_params: ClassVar[dict] = {}
def on_candle(self, ctx: StrategyContext) -> None:
raise RuntimeError("kaboom")
result = Backtester().run(candles_from_closes([1, 2, 3]), Boom(), "BTC/USDT", "1m")
assert result.error is not None and "kaboom" in result.error
def test_max_drawdown() -> None:
assert max_drawdown_pct([100, 120, 90, 130]) == pytest.approx(25.0)
assert max_drawdown_pct([100, 100, 100]) == pytest.approx(0.0)
def test_sharpe_none_for_unknown_timeframe() -> None:
assert sharpe_ratio([100, 101, 102, 103], "nonsense") is None
def _points(equities: list[float]) -> list[BacktestPointOut]:
return [BacktestPointOut(ts=i, equity=e, buy_hold=e, price=e) for i, e in enumerate(equities)]
def test_metrics_profit_factor_and_win_rate() -> None:
from app.backtest.metrics import compute_metrics
trades = [
BacktestTradeOut(
side="long",
entry_ts=0,
exit_ts=1,
entry_price=100,
exit_price=110,
qty=1,
pnl=10,
return_pct=10,
reason="x",
),
BacktestTradeOut(
side="long",
entry_ts=2,
exit_ts=3,
entry_price=100,
exit_price=95,
qty=1,
pnl=-5,
return_pct=-5,
reason="x",
),
]
metrics = compute_metrics(_points([10_000, 10_010, 10_005]), trades, 10_000, "1h")
assert metrics.win_rate_pct == pytest.approx(50.0)
assert metrics.profit_factor == pytest.approx(2.0)
assert metrics.best_trade_pct == pytest.approx(10.0)
assert metrics.worst_trade_pct == pytest.approx(-5.0)
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
from typing import ClassVar
import pytest
from app.broker.paper import PaperBroker
from app.config import Settings
from app.engine.engine import TradingEngine
from app.engine.strategies.base import Strategy, StrategyContext
from app.market.fake import FakeMarketData
from app.models import OrderSide, PositionSide
from app.schemas import Candle
from app.store import Store
class ToggleStrategy(Strategy):
key: ClassVar[str] = "test_toggle"
name: ClassVar[str] = "Toggle"
default_params: ClassVar[dict] = {}
def on_candle(self, ctx: StrategyContext) -> None:
if ctx.is_flat:
ctx.enter_long(size=0.5)
else:
ctx.close()
def build_engine(settings: Settings, store: Store, market: FakeMarketData) -> TradingEngine:
return TradingEngine(
feed=market,
broker=PaperBroker(fee_bps=0, slippage_bps=0),
store=store,
settings=settings,
)
async def test_manual_open_and_close_pnl(settings: Settings, store: Store) -> None:
market = FakeMarketData.from_closes([100.0])
engine = build_engine(settings, store, market)
engine.symbol = "BTC/USDT"
engine.last_price = 100.0
await engine.manual_order(OrderSide.BUY, qty=1.0, quote_amount=None, reason="test")
assert engine.position is not None
assert engine.cash == pytest.approx(9_900.0)
assert engine.equity == pytest.approx(10_000.0)
engine.last_price = 110.0
assert engine.equity == pytest.approx(10_010.0)
assert engine.unrealized_pnl == pytest.approx(10.0)
await engine.close_current_position()
assert engine.position is None
assert engine.realized_pnl == pytest.approx(10.0)
assert engine.cash == pytest.approx(10_010.0)
async def test_stop_loss_exits_at_level(settings: Settings, store: Store) -> None:
market = FakeMarketData.from_closes([100.0])
engine = build_engine(settings, store, market)
engine.symbol = "BTC/USDT"
engine.last_price = 100.0
await engine._open_position(
PositionSide.LONG,
size=0.0,
stop_loss=95.0,
take_profit=120.0,
reason="entry",
qty_override=1.0,
)
await engine._check_risk(Candle(ts=0, open=100, high=101, low=94, close=96, volume=1))
assert engine.position is None
assert engine.realized_pnl == pytest.approx(-5.0)
async def test_take_profit_exits_at_level(settings: Settings, store: Store) -> None:
market = FakeMarketData.from_closes([100.0])
engine = build_engine(settings, store, market)
engine.symbol = "BTC/USDT"
engine.last_price = 100.0
await engine._open_position(
PositionSide.LONG,
size=0.0,
stop_loss=95.0,
take_profit=110.0,
reason="entry",
qty_override=1.0,
)
await engine._check_risk(Candle(ts=0, open=100, high=111, low=100, close=109, volume=1))
assert engine.position is None
assert engine.realized_pnl == pytest.approx(10.0)
async def test_tick_runs_strategy_and_opens_position(settings: Settings, store: Store) -> None:
market = FakeMarketData.from_closes([100.0, 100.0])
engine = build_engine(settings, store, market)
engine.strategy = ToggleStrategy()
engine.strategy_key = "test_toggle"
engine.symbol = "BTC/USDT"
await engine.tick()
assert engine.position is not None
assert engine.position.side == PositionSide.LONG
market.append(Candle(ts=99, open=110, high=110, low=110, close=110, volume=1))
await engine.tick()
assert engine.position is None
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
import pytest
from app.engine.indicators import atr, ema, rolling_std, rsi, sma
from app.schemas import Candle
def candle(high: float, low: float, close: float, open_: float | None = None) -> Candle:
return Candle(
ts=0,
open=open_ if open_ is not None else close,
high=high,
low=low,
close=close,
volume=1,
)
def test_sma() -> None:
assert sma([1, 2, 3, 4, 5], 3) == [None, None, 2.0, 3.0, 4.0]
def test_ema() -> None:
assert ema([1, 2, 3, 4, 5], 3) == [None, None, 2.0, 3.0, 4.0]
def test_rsi_monotonic_is_100() -> None:
values = [float(i) for i in range(1, 20)]
assert rsi(values, 14)[-1] == pytest.approx(100.0)
def test_atr_flat_market_is_zero() -> None:
candles = [candle(100, 100, 100) for _ in range(20)]
assert atr(candles, 5)[-1] == pytest.approx(0.0)
def test_rolling_std() -> None:
result = rolling_std([1, 1, 1, 1], 2)
assert result[-1] == pytest.approx(0.0)
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
import pytest
from app.engine.portfolio import (
account_equity,
apply_close,
apply_open,
order_qty,
realized_pnl,
risk_exit,
unrealized_pnl,
)
from app.models import PositionSide
def test_apply_open_long_and_short() -> None:
assert apply_open(1000.0, PositionSide.LONG, 1.0, 100.0, 1.0) == pytest.approx(899.0)
assert apply_open(1000.0, PositionSide.SHORT, 1.0, 100.0, 1.0) == pytest.approx(1099.0)
def test_apply_close_long_and_short() -> None:
assert apply_close(100.0, PositionSide.LONG, 1.0, 120.0, 1.0) == pytest.approx(219.0)
assert apply_close(2100.0, PositionSide.SHORT, 1.0, 90.0, 1.0) == pytest.approx(2009.0)
def test_realized_pnl_includes_fees() -> None:
assert realized_pnl(PositionSide.LONG, 100, 110, 2, 1, 1) == pytest.approx(18.0)
assert realized_pnl(PositionSide.SHORT, 100, 90, 2, 1, 1) == pytest.approx(18.0)
assert realized_pnl(PositionSide.LONG, 100, 90, 2, 1, 1) == pytest.approx(-22.0)
def test_unrealized_pnl() -> None:
assert unrealized_pnl(PositionSide.LONG, 100, 110, 3) == pytest.approx(30.0)
assert unrealized_pnl(PositionSide.SHORT, 100, 110, 3) == pytest.approx(-30.0)
def test_account_equity_long_and_short() -> None:
assert account_equity(100, PositionSide.LONG, 2, 150) == pytest.approx(400.0)
assert account_equity(300, PositionSide.SHORT, 2, 80) == pytest.approx(140.0)
def test_order_qty_caps_longs_by_cash_and_scales_with_size() -> None:
assert order_qty(10_000, 1.0, PositionSide.LONG, 10_000, 100) == pytest.approx(99.0)
assert order_qty(10_000, 0.5, PositionSide.SHORT, 10_000, 100) == pytest.approx(50.0)
assert order_qty(10_000, 1.0, PositionSide.SHORT, 0, 100) == pytest.approx(100.0)
assert order_qty(10_000, 1.0, PositionSide.LONG, 10_000, 0) == 0.0
def test_risk_exit_long() -> None:
assert risk_exit(PositionSide.LONG, 95, 120, high=101, low=94) == ("stop loss", 95)
assert risk_exit(PositionSide.LONG, 95, 120, high=121, low=100) == ("take profit", 120)
assert risk_exit(PositionSide.LONG, 95, 120, high=110, low=100) is None
def test_risk_exit_short() -> None:
assert risk_exit(PositionSide.SHORT, 110, 90, high=111, low=100) == ("stop loss", 110)
assert risk_exit(PositionSide.SHORT, 110, 90, high=100, low=89) == ("take profit", 90)
assert risk_exit(PositionSide.SHORT, None, None, high=100, low=95) is None
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
from app.engine.strategies import get_strategy, list_strategies, strategy_spec
from app.engine.strategies.base import StrategyContext
from app.models import Position, PositionSide
from app.schemas import Candle
def make_candles(closes: list[float]) -> list[Candle]:
return [Candle(ts=i, open=c, high=c, low=c, close=c, volume=1.0) for i, c in enumerate(closes)]
def test_examples_are_registered() -> None:
keys = {cls.key for cls in list_strategies()}
assert {"sma_cross", "rsi_reversion"} <= keys
def test_param_specs_expose_defaults() -> None:
spec = strategy_spec(get_strategy("sma_cross"))
names = {param.name for param in spec.params}
assert {"fast", "slow", "size"} == names
fast = next(param for param in spec.params if param.name == "fast")
assert fast.default == 10
assert fast.type == "int"
def test_unknown_params_rejected() -> None:
cls = get_strategy("sma_cross")
import pytest
with pytest.raises(ValueError):
cls({"nope": 1})
def test_sma_cross_emits_enter_long() -> None:
strategy = get_strategy("sma_cross")({"fast": 2, "slow": 4, "size": 0.5})
ctx = StrategyContext(
candles=make_candles([10, 10, 10, 10, 10, 11]),
position=None,
equity=1000,
cash=1000,
)
strategy.on_candle(ctx)
intents = ctx.take_intents()
assert len(intents) == 1
assert intents[0].action == "enter_long"
assert intents[0].size == 0.5
def test_sma_cross_emits_close_on_cross_down() -> None:
strategy = get_strategy("sma_cross")({"fast": 2, "slow": 4, "size": 0.5})
position = Position(
strategy_key="sma_cross",
symbol="BTC/USDT",
side=PositionSide.LONG,
qty=1.0,
entry_price=10.0,
)
ctx = StrategyContext(
candles=make_candles([10, 10, 10, 10, 10, 11, 8]),
position=position,
equity=1000,
cash=1000,
)
strategy.on_candle(ctx)
intents = ctx.take_intents()
assert len(intents) == 1
assert intents[0].action == "close"
+37
View File
@@ -0,0 +1,37 @@
# Local / self-contained stack: no Traefik, publishes ports directly.
# Usage: docker compose -f docker-compose.local.yml up --build
# Dashboard: http://localhost:8080 API docs: http://localhost:8000/docs
name: crypto_trading_local
services:
backend:
build: ./backend
container_name: crypto-trading-backend-local
restart: unless-stopped
environment:
- MODE=${MODE:-paper}
- EXCHANGE=${EXCHANGE:-binance}
- SYMBOL=${SYMBOL:-BTC/USDT}
- TIMEFRAME=${TIMEFRAME:-1m}
- POLL_INTERVAL=${POLL_INTERVAL:-5}
- STARTING_BALANCE=${STARTING_BALANCE:-10000}
- FEE_BPS=${FEE_BPS:-10}
- SLIPPAGE_BPS=${SLIPPAGE_BPS:-5}
- DATABASE_URL=sqlite:////data/trader.db
- CORS_ORIGINS=*
- EXCHANGE_API_KEY=${EXCHANGE_API_KEY:-}
- EXCHANGE_API_SECRET=${EXCHANGE_API_SECRET:-}
- EXCHANGE_SANDBOX=${EXCHANGE_SANDBOX:-false}
volumes:
- ./data:/data
ports:
- "8000:8000"
frontend:
build: ./frontend
container_name: crypto-trading-frontend-local
restart: unless-stopped
depends_on:
- backend
ports:
- "8080:80"
+100
View File
@@ -0,0 +1,100 @@
name: crypto_trading
services:
backend:
build: ./backend
image: crypto_trading-backend
container_name: crypto-trading-backend
restart: unless-stopped
mem_limit: ${CRYPTO_MEM_LIMIT:-1g}
cpus: ${CRYPTO_CPUS:-2}
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
security_opt:
- no-new-privileges:true
# Reachable only on the internal network; the frontend nginx proxies to it.
expose:
- "8000"
environment:
- MODE=${CRYPTO_MODE:-paper}
- EXCHANGE=${CRYPTO_EXCHANGE:-binance}
- SYMBOL=${CRYPTO_SYMBOL:-BTC/USDT}
- TIMEFRAME=${CRYPTO_TIMEFRAME:-1m}
- POLL_INTERVAL=${CRYPTO_POLL_INTERVAL:-5}
- STARTING_BALANCE=${CRYPTO_STARTING_BALANCE:-10000}
- FEE_BPS=${CRYPTO_FEE_BPS:-10}
- SLIPPAGE_BPS=${CRYPTO_SLIPPAGE_BPS:-5}
- DATABASE_URL=sqlite:////data/trader.db
- CORS_ORIGINS=${CRYPTO_CORS_ORIGINS:-*}
- EXCHANGE_API_KEY=${CRYPTO_EXCHANGE_API_KEY:-}
- EXCHANGE_API_SECRET=${CRYPTO_EXCHANGE_API_SECRET:-}
- EXCHANGE_SANDBOX=${CRYPTO_EXCHANGE_SANDBOX:-false}
volumes:
# Persist the SQLite database across releases. Prefer an absolute host
# path (CRYPTO_DATA_DIR / PERSISTENT_MOUNT); the repo-relative default
# can be cleaned by runner maintenance.
- ${CRYPTO_DATA_DIR:-./data}:/data
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=5).status==200 else 1)\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
networks:
- internal
frontend:
build: ./frontend
image: crypto_trading-frontend
container_name: crypto-trading-frontend
restart: unless-stopped
mem_limit: ${CRYPTO_MEM_LIMIT:-1g}
cpus: ${CRYPTO_CPUS:-2}
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
security_opt:
- no-new-privileges:true
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:80/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- internal
- docker_default
labels:
- "traefik.enable=true"
- "traefik.docker.network=docker_default"
- "traefik.http.routers.crypto-trading.rule=Host(`crypto.${BASE_DOMAIN:-matitos.org}`)"
- "traefik.http.routers.crypto-trading.entrypoints=websecure"
- "traefik.http.routers.crypto-trading.tls.certresolver=myresolvercd"
- "traefik.http.services.crypto-trading.loadbalancer.server.port=80"
# Basic auth on the WebUI (htpasswd), required: the stack refuses to
# start when CRYPTO_FRONTEND_AUTH_USERS is unset (no admin/admin
# fallback). A per-source rate limit reduces credential brute-forcing.
- "traefik.http.routers.crypto-trading.middlewares=crypto-trading-auth@docker,crypto-trading-sec@docker,crypto-trading-authlimit@docker"
- "traefik.http.middlewares.crypto-trading-auth.basicauth.users=${CRYPTO_FRONTEND_AUTH_USERS:?CRYPTO_FRONTEND_AUTH_USERS is required (htpasswd string, e.g. htpasswd -nbB user pass)}"
- "traefik.http.middlewares.crypto-trading-authlimit.ratelimit.average=${CRYPTO_AUTH_RATE_LIMIT_AVERAGE:-10}"
- "traefik.http.middlewares.crypto-trading-authlimit.ratelimit.burst=${CRYPTO_AUTH_RATE_LIMIT_BURST:-5}"
- "traefik.http.middlewares.crypto-trading-sec.headers.stsSeconds=31536000"
- "traefik.http.middlewares.crypto-trading-sec.headers.stsIncludeSubdomains=true"
- "traefik.http.middlewares.crypto-trading-sec.headers.forceSTSHeader=true"
- "traefik.http.middlewares.crypto-trading-sec.headers.contentTypeNosniff=true"
- "traefik.http.middlewares.crypto-trading-sec.headers.browserXssFilter=true"
- "traefik.http.middlewares.crypto-trading-sec.headers.frameDeny=true"
- "traefik.http.middlewares.crypto-trading-sec.headers.referrerPolicy=strict-origin-when-cross-origin"
networks:
internal:
docker_default:
external: true
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.vite/
*.log
.DS_Store
.git/
+12
View File
@@ -0,0 +1,12 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Crypto Trader</title>
</head>
<body class="bg-surface text-slate-100">
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s;
}
}
+4906
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "crypto-trading-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"axios": "^1.7.9",
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.20",
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.7",
"vitest": "^2.1.9"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+164
View File
@@ -0,0 +1,164 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import * as api from './api'
import BacktestPanel from './components/BacktestPanel.vue'
import EquityChart from './components/EquityChart.vue'
import EventLog from './components/EventLog.vue'
import OrdersTable from './components/OrdersTable.vue'
import PositionsTable from './components/PositionsTable.vue'
import StatusCards from './components/StatusCards.vue'
import StrategyPanel from './components/StrategyPanel.vue'
const status = ref({
running: false,
mode: 'paper',
exchange: '',
symbol: '',
timeframe: '',
strategy_name: null,
price: null,
cash: 0,
equity: 0,
starting_balance: 0,
realized_pnl: 0,
unrealized_pnl: 0,
total_pnl: 0,
open_positions: 0,
error: null,
})
const strategies = ref([])
const positions = ref([])
const orders = ref([])
const events = ref([])
const equityPoints = ref([])
const busy = ref(false)
const connected = ref(false)
let socket
let pollTimer
async function refreshAll() {
const [p, o, e, q] = await Promise.all([
api.getPositions(),
api.getOrders(),
api.getEvents(),
api.getEquity(),
])
positions.value = p
orders.value = o
events.value = e
equityPoints.value = q
}
async function loadStrategies() {
strategies.value = await api.getStrategies()
}
async function handleStart(payload) {
busy.value = true
try {
status.value = await api.startBot(payload)
await refreshAll()
} catch (error) {
status.value = { ...status.value, error: error.response?.data?.detail || error.message }
} finally {
busy.value = false
}
}
async function handleStop() {
busy.value = true
try {
status.value = await api.stopBot()
await refreshAll()
} finally {
busy.value = false
}
}
async function handleClose(id) {
try {
status.value = await api.closePosition(id)
await refreshAll()
} catch (error) {
status.value = { ...status.value, error: error.response?.data?.detail || error.message }
}
}
function handleMessage(message) {
if (message.type === 'status') {
status.value = message.data
} else if (message.type === 'event') {
events.value = [message.data, ...events.value].slice(0, 50)
if (message.data.level === 'trade' || message.data.level === 'error') refreshAll()
}
}
onMounted(async () => {
await Promise.all([loadStrategies(), api.getStatus().then((s) => (status.value = s)), refreshAll()])
socket = api.openSocket({
onMessage: handleMessage,
onOpen: () => (connected.value = true),
onClose: () => (connected.value = false),
})
pollTimer = setInterval(refreshAll, 5000)
})
onUnmounted(() => {
socket?.close()
clearInterval(pollTimer)
})
</script>
<template>
<div class="mx-auto max-w-6xl space-y-4 p-4">
<header class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<h1 class="text-xl font-bold">Crypto Trader</h1>
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold uppercase"
:class="status.mode === 'live' ? 'bg-rose-500/20 text-rose-300' : 'bg-amber-500/20 text-amber-300'"
>
{{ status.mode }}
</span>
<span class="flex items-center gap-1.5 text-xs text-slate-400">
<span
class="h-2 w-2 rounded-full"
:class="connected ? 'bg-emerald-400' : 'bg-slate-500'"
></span>
{{ connected ? 'live' : 'reconnecting' }}
</span>
</div>
<div class="text-xs text-slate-400">
<span v-if="status.strategy_name">Running: {{ status.strategy_name }}</span>
<span v-else>Idle</span>
<span v-if="status.symbol"> · {{ status.symbol }} {{ status.timeframe }}</span>
</div>
</header>
<div v-if="status.error" class="rounded-lg border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">
{{ status.error }}
</div>
<StatusCards :status="status" />
<StrategyPanel
:strategies="strategies"
:status="status"
:busy="busy"
@start="handleStart"
@stop="handleStop"
/>
<BacktestPanel :strategies="strategies" :status="status" />
<div class="grid gap-4 lg:grid-cols-2">
<EquityChart :points="equityPoints" />
<EventLog :events="events" />
</div>
<PositionsTable :positions="positions" @close="handleClose" />
<OrdersTable :orders="orders" />
</div>
</template>
+45
View File
@@ -0,0 +1,45 @@
import axios from 'axios'
const http = axios.create({ baseURL: '/api', timeout: 10000 })
export const getStatus = () => http.get('/status').then((r) => r.data)
export const getStrategies = () => http.get('/strategies').then((r) => r.data)
export const getPositions = () => http.get('/positions').then((r) => r.data)
export const getOrders = () => http.get('/orders').then((r) => r.data)
export const getEvents = () => http.get('/events').then((r) => r.data)
export const getEquity = () => http.get('/equity').then((r) => r.data)
export const startBot = (payload) => http.post('/bot/start', payload).then((r) => r.data)
export const stopBot = () => http.post('/bot/stop').then((r) => r.data)
export const closePosition = (id, reason = 'manual') =>
http.post(`/positions/${id}/close`, { reason }).then((r) => r.data)
export const placeOrder = (payload) => http.post('/orders', payload).then((r) => r.data)
export const runBacktest = (payload) =>
http.post('/backtest', payload, { timeout: 60000 }).then((r) => r.data)
export function openSocket({ onMessage, onOpen, onClose }) {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
let socket
let retryTimer
let closed = false
const connect = () => {
socket = new WebSocket(`${proto}://${window.location.host}/api/ws`)
socket.onopen = () => onOpen?.()
socket.onmessage = (event) => onMessage?.(JSON.parse(event.data))
socket.onclose = () => {
onClose?.()
if (!closed) retryTimer = setTimeout(connect, 2000)
}
socket.onerror = () => socket.close()
}
connect()
return {
close() {
closed = true
clearTimeout(retryTimer)
socket?.close()
},
}
}
+140
View File
@@ -0,0 +1,140 @@
<script setup>
import { ref } from 'vue'
import * as api from '../api'
import { fmtMoney, fmtPrice, pnlClass, signed } from '../format'
import EquityChart from './EquityChart.vue'
import StrategyForm from './StrategyForm.vue'
defineProps({
strategies: { type: Array, required: true },
status: { type: Object, required: true },
})
const running = ref(false)
const error = ref(null)
const result = ref(null)
const limit = ref(500)
async function run(payload) {
running.value = true
error.value = null
try {
result.value = await api.runBacktest({ ...payload, limit: Number(limit.value) })
if (result.value.error) error.value = result.value.error
} catch (e) {
error.value = e.response?.data?.detail || e.message
} finally {
running.value = false
}
}
</script>
<template>
<div class="card space-y-3">
<div class="flex items-center justify-between">
<h2 class="font-semibold">Backtest</h2>
<span v-if="result" class="text-xs text-slate-400">
{{ result.symbol }} {{ result.timeframe }} · {{ result.equity_curve.length }} candles
</span>
</div>
<StrategyForm
:strategies="strategies"
:busy="running"
:symbol="status.symbol"
:timeframe="status.timeframe"
submit-label="Run backtest"
@submit="run"
/>
<label class="block max-w-[12rem] space-y-1">
<span class="label">Historical candles</span>
<input v-model="limit" type="number" min="20" max="1000" class="input" />
</label>
<p v-if="error" class="rounded-lg border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">
{{ error }}
</p>
<template v-if="result && !result.error">
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
<div class="rounded-lg border border-edge/60 p-3">
<div class="label">Total return</div>
<div class="text-lg font-semibold tabular-nums" :class="pnlClass(result.metrics.total_return_pct)">
{{ signed(result.metrics.total_return_pct) }}%
</div>
</div>
<div class="rounded-lg border border-edge/60 p-3">
<div class="label">Buy &amp; hold</div>
<div class="text-lg font-semibold tabular-nums" :class="pnlClass(result.metrics.buy_hold_return_pct)">
{{ signed(result.metrics.buy_hold_return_pct) }}%
</div>
</div>
<div class="rounded-lg border border-edge/60 p-3">
<div class="label">Max drawdown</div>
<div class="text-lg font-semibold tabular-nums text-amber-300">
-{{ fmtMoney(result.metrics.max_drawdown_pct) }}%
</div>
</div>
<div class="rounded-lg border border-edge/60 p-3">
<div class="label">Final equity</div>
<div class="text-lg font-semibold tabular-nums">{{ fmtMoney(result.metrics.final_equity) }}</div>
</div>
<div class="rounded-lg border border-edge/60 p-3">
<div class="label">Trades</div>
<div class="text-lg font-semibold tabular-nums">{{ result.metrics.num_trades }}</div>
</div>
<div class="rounded-lg border border-edge/60 p-3">
<div class="label">Win rate</div>
<div class="text-lg font-semibold tabular-nums">{{ fmtMoney(result.metrics.win_rate_pct) }}%</div>
</div>
<div class="rounded-lg border border-edge/60 p-3">
<div class="label">Profit factor</div>
<div class="text-lg font-semibold tabular-nums">
{{ result.metrics.profit_factor === null ? '—' : fmtMoney(result.metrics.profit_factor) }}
</div>
</div>
<div class="rounded-lg border border-edge/60 p-3">
<div class="label">Sharpe</div>
<div class="text-lg font-semibold tabular-nums">
{{ result.metrics.sharpe === null ? '—' : fmtMoney(result.metrics.sharpe) }}
</div>
</div>
</div>
<EquityChart :points="result.equity_curve" title="Backtest equity" />
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="label">
<tr>
<th class="pb-2">Side</th>
<th class="pb-2 text-right">Entry</th>
<th class="pb-2 text-right">Exit</th>
<th class="pb-2 text-right">P&amp;L</th>
<th class="pb-2 text-right">Return</th>
<th class="pb-2">Reason</th>
</tr>
</thead>
<tbody>
<tr v-for="(t, i) in result.trades" :key="i" class="border-t border-edge/60">
<td class="py-1.5" :class="t.side === 'long' ? 'text-emerald-400' : 'text-rose-400'">
{{ t.side }}
</td>
<td class="py-1.5 text-right tabular-nums">{{ fmtPrice(t.entry_price) }}</td>
<td class="py-1.5 text-right tabular-nums">{{ fmtPrice(t.exit_price) }}</td>
<td class="py-1.5 text-right tabular-nums" :class="pnlClass(t.pnl)">{{ signed(t.pnl) }}</td>
<td class="py-1.5 text-right tabular-nums" :class="pnlClass(t.return_pct)">
{{ signed(t.return_pct) }}%
</td>
<td class="py-1.5 text-slate-400">{{ t.reason }}</td>
</tr>
<tr v-if="!result.trades.length">
<td colspan="6" class="py-6 text-center text-slate-500">No trades</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
</template>
+47
View File
@@ -0,0 +1,47 @@
<script setup>
import { computed } from 'vue'
import { fmtMoney } from '../format'
const props = defineProps({
points: { type: Array, required: true },
title: { type: String, default: 'Equity' },
})
const WIDTH = 600
const HEIGHT = 160
const path = computed(() => {
const values = props.points.map((p) => p.equity)
if (values.length < 2) return ''
const min = Math.min(...values)
const max = Math.max(...values)
const span = max - min || 1
return values
.map((value, i) => {
const x = (i / (values.length - 1)) * WIDTH
const y = HEIGHT - ((value - min) / span) * HEIGHT
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
})
.join(' ')
})
const latest = computed(() => (props.points.length ? props.points[props.points.length - 1].equity : null))
</script>
<template>
<div class="card">
<div class="mb-3 flex items-center justify-between">
<h2 class="font-semibold">{{ title }}</h2>
<span class="tabular-nums text-slate-300">{{ fmtMoney(latest) }}</span>
</div>
<svg
v-if="path"
:viewBox="`0 0 ${WIDTH} ${HEIGHT}`"
preserveAspectRatio="none"
class="h-40 w-full"
>
<path :d="path" fill="none" stroke="#34d399" stroke-width="2" vector-effect="non-scaling-stroke" />
</svg>
<div v-else class="py-12 text-center text-sm text-slate-500">Not enough data yet</div>
</div>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup>
import { fmtTime } from '../format'
defineProps({
events: { type: Array, required: true },
})
const levelClass = {
info: 'text-slate-300',
trade: 'text-sky-300',
error: 'text-rose-300',
}
</script>
<template>
<div class="card">
<h2 class="mb-3 font-semibold">Activity</h2>
<div class="max-h-72 space-y-1 overflow-y-auto font-mono text-xs">
<div v-for="e in events" :key="e.id" class="flex gap-2 border-b border-edge/40 py-1">
<span class="shrink-0 text-slate-500">{{ fmtTime(e.created_at) }}</span>
<span class="shrink-0" :class="levelClass[e.level] || 'text-slate-300'">[{{ e.level }}]</span>
<span :class="levelClass[e.level] || 'text-slate-300'">{{ e.message }}</span>
</div>
<div v-if="!events.length" class="py-6 text-center text-slate-500">No activity yet</div>
</div>
</div>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script setup>
import { fmtNum, fmtPrice, fmtTime } from '../format'
defineProps({
orders: { type: Array, required: true },
})
</script>
<template>
<div class="card">
<h2 class="mb-3 font-semibold">Orders</h2>
<div class="max-h-72 overflow-y-auto">
<table class="w-full text-left text-sm">
<thead class="label sticky top-0 bg-panel">
<tr>
<th class="pb-2">Time</th>
<th class="pb-2">Side</th>
<th class="pb-2">Symbol</th>
<th class="pb-2 text-right">Qty</th>
<th class="pb-2 text-right">Price</th>
<th class="pb-2">Reason</th>
</tr>
</thead>
<tbody>
<tr v-for="o in orders" :key="o.id" class="border-t border-edge/60">
<td class="py-2 text-slate-400">{{ fmtTime(o.created_at) }}</td>
<td class="py-2" :class="o.side === 'buy' ? 'text-emerald-400' : 'text-rose-400'">
{{ o.side }}
</td>
<td class="py-2">{{ o.symbol }}</td>
<td class="py-2 text-right tabular-nums">{{ fmtNum(o.qty) }}</td>
<td class="py-2 text-right tabular-nums">{{ fmtPrice(o.price) }}</td>
<td class="py-2 text-slate-400">{{ o.reason }}</td>
</tr>
<tr v-if="!orders.length">
<td colspan="6" class="py-6 text-center text-slate-500">No orders yet</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
@@ -0,0 +1,77 @@
<script setup>
import { fmtNum, fmtPrice, fmtTime, pnlClass, signed } from '../format'
defineProps({
positions: { type: Array, required: true },
})
const emit = defineEmits(['close'])
</script>
<template>
<div class="card">
<h2 class="mb-3 font-semibold">Positions</h2>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="label">
<tr>
<th class="pb-2">#</th>
<th class="pb-2">Side</th>
<th class="pb-2">Symbol</th>
<th class="pb-2 text-right">Qty</th>
<th class="pb-2 text-right">Entry</th>
<th class="pb-2 text-right">Exit</th>
<th class="pb-2 text-right">P&L</th>
<th class="pb-2">Status</th>
<th class="pb-2">Opened</th>
<th class="pb-2"></th>
</tr>
</thead>
<tbody>
<tr v-for="p in positions" :key="p.id" class="border-t border-edge/60">
<td class="py-2 text-slate-400">{{ p.id }}</td>
<td class="py-2">
<span
class="rounded px-1.5 py-0.5 text-xs font-medium"
:class="
p.side === 'long'
? 'bg-emerald-500/20 text-emerald-300'
: 'bg-rose-500/20 text-rose-300'
"
>
{{ p.side }}
</span>
</td>
<td class="py-2">{{ p.symbol }}</td>
<td class="py-2 text-right tabular-nums">{{ fmtNum(p.qty) }}</td>
<td class="py-2 text-right tabular-nums">{{ fmtPrice(p.entry_price) }}</td>
<td class="py-2 text-right tabular-nums">{{ fmtPrice(p.exit_price) }}</td>
<td class="py-2 text-right tabular-nums" :class="pnlClass(p.realized_pnl ?? p.unrealized_pnl)">
{{ signed(p.realized_pnl ?? p.unrealized_pnl) }}
</td>
<td class="py-2">
<span
class="rounded px-1.5 py-0.5 text-xs"
:class="p.status === 'open' ? 'bg-sky-500/20 text-sky-300' : 'bg-slate-600/40 text-slate-300'"
>
{{ p.status }}
</span>
</td>
<td class="py-2 text-slate-400">{{ fmtTime(p.opened_at) }}</td>
<td class="py-2 text-right">
<button
v-if="p.status === 'open'"
class="btn-muted"
@click="emit('close', p.id)"
>
Close
</button>
</td>
</tr>
<tr v-if="!positions.length">
<td colspan="10" class="py-6 text-center text-slate-500">No positions yet</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
+32
View File
@@ -0,0 +1,32 @@
<script setup>
import { computed } from 'vue'
import { fmtMoney, fmtPrice, pnlClass, signed } from '../format'
const props = defineProps({ status: { type: Object, required: true } })
const cards = computed(() => [
{ label: 'Equity', value: fmtMoney(props.status.equity), cls: 'text-slate-100' },
{ label: 'Total P&L', value: signed(props.status.total_pnl), cls: pnlClass(props.status.total_pnl) },
{
label: 'Realized P&L',
value: signed(props.status.realized_pnl),
cls: pnlClass(props.status.realized_pnl),
},
{
label: 'Unrealized P&L',
value: signed(props.status.unrealized_pnl),
cls: pnlClass(props.status.unrealized_pnl),
},
{ label: 'Cash', value: fmtMoney(props.status.cash), cls: 'text-slate-100' },
{ label: 'Price', value: fmtPrice(props.status.price), cls: 'text-slate-100' },
])
</script>
<template>
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
<div v-for="card in cards" :key="card.label" class="card">
<div class="label">{{ card.label }}</div>
<div class="value" :class="card.cls">{{ card.value }}</div>
</div>
</div>
</template>
+122
View File
@@ -0,0 +1,122 @@
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
strategies: { type: Array, required: true },
disabled: { type: Boolean, default: false },
busy: { type: Boolean, default: false },
submitLabel: { type: String, default: 'Run' },
submitClass: { type: String, default: 'btn-primary' },
symbol: { type: String, default: 'BTC/USDT' },
timeframe: { type: String, default: '1m' },
})
const emit = defineEmits(['submit'])
const selectedKey = ref('')
const values = ref({})
const symbolValue = ref(props.symbol)
const timeframeValue = ref(props.timeframe)
const selected = computed(() => props.strategies.find((s) => s.key === selectedKey.value))
watch(
selected,
(strategy) => {
if (strategy) {
values.value = Object.fromEntries(strategy.params.map((p) => [p.name, p.default]))
}
},
{ immediate: true },
)
watch(
() => props.strategies,
(list) => {
if (!selectedKey.value && list.length) selectedKey.value = list[0].key
},
{ immediate: true },
)
watch(
() => props.symbol,
(value) => {
if (value) symbolValue.value = value
},
)
watch(
() => props.timeframe,
(value) => {
if (value) timeframeValue.value = value
},
)
function coerce(param, raw) {
if (param.type === 'int') return parseInt(raw, 10)
if (param.type === 'float') return parseFloat(raw)
if (param.type === 'bool') return raw === true || raw === 'true'
return raw
}
function submit() {
const params = {}
for (const param of selected.value.params) {
params[param.name] = coerce(param, values.value[param.name])
}
emit('submit', {
strategy_key: selectedKey.value,
params,
symbol: symbolValue.value,
timeframe: timeframeValue.value,
})
}
</script>
<template>
<div class="space-y-3">
<div class="grid gap-2 sm:grid-cols-3">
<label class="space-y-1">
<span class="label">Strategy</span>
<select v-model="selectedKey" class="input" :disabled="disabled">
<option v-for="s in strategies" :key="s.key" :value="s.key">{{ s.name }}</option>
</select>
</label>
<label class="space-y-1">
<span class="label">Symbol</span>
<input v-model="symbolValue" class="input" :disabled="disabled" />
</label>
<label class="space-y-1">
<span class="label">Timeframe</span>
<input v-model="timeframeValue" class="input" :disabled="disabled" />
</label>
</div>
<p v-if="selected" class="text-xs text-slate-400">{{ selected.description }}</p>
<div v-if="selected && selected.params.length" class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<label v-for="param in selected.params" :key="param.name" class="space-y-1">
<span class="label" :title="param.description">{{ param.name }}</span>
<select
v-if="param.type === 'bool'"
v-model="values[param.name]"
class="input"
:disabled="disabled"
>
<option :value="true">true</option>
<option :value="false">false</option>
</select>
<input
v-else
v-model="values[param.name]"
type="number"
step="any"
class="input"
:disabled="disabled"
/>
</label>
</div>
<button :class="submitClass" :disabled="busy || disabled || !selectedKey" @click="submit">
{{ submitLabel }}
</button>
</div>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup>
import StrategyForm from './StrategyForm.vue'
const props = defineProps({
strategies: { type: Array, required: true },
status: { type: Object, required: true },
busy: { type: Boolean, default: false },
})
const emit = defineEmits(['start', 'stop'])
</script>
<template>
<div class="card space-y-3">
<div class="flex items-center justify-between">
<h2 class="font-semibold">Strategy</h2>
<span
class="rounded-full px-2 py-0.5 text-xs font-medium"
:class="status.running ? 'bg-emerald-500/20 text-emerald-300' : 'bg-slate-600/40 text-slate-300'"
>
{{ status.running ? 'running' : 'stopped' }}
</span>
</div>
<StrategyForm
:strategies="strategies"
:disabled="status.running"
:busy="busy"
submit-label="Start bot"
:symbol="status.symbol"
:timeframe="status.timeframe"
@submit="emit('start', $event)"
/>
<button class="btn-danger" :disabled="busy || !status.running" @click="emit('stop')">
Stop bot
</button>
</div>
</template>
+36
View File
@@ -0,0 +1,36 @@
export function fmtMoney(value, digits = 2) {
if (value === null || value === undefined || Number.isNaN(value)) return '—'
return Number(value).toLocaleString('en-US', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
})
}
export function fmtPrice(value) {
if (value === null || value === undefined || Number.isNaN(value)) return '—'
const n = Number(value)
return n >= 100 ? fmtMoney(n, 2) : n.toFixed(6)
}
export function fmtNum(value, digits = 6) {
if (value === null || value === undefined || Number.isNaN(value)) return '—'
return Number(value).toFixed(digits)
}
export function fmtTime(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleTimeString()
}
export function pnlClass(value) {
if (value === null || value === undefined) return 'text-slate-300'
if (Number(value) > 0) return 'text-emerald-400'
if (Number(value) < 0) return 'text-rose-400'
return 'text-slate-300'
}
export function signed(value, digits = 2) {
if (value === null || value === undefined || Number.isNaN(value)) return '—'
const n = Number(value)
return `${n >= 0 ? '+' : ''}${fmtMoney(n, digits)}`
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { fmtMoney, fmtPrice, pnlClass, signed } from './format'
describe('format helpers', () => {
it('formats money with two decimals', () => {
expect(fmtMoney(10000)).toBe('10,000.00')
})
it('handles null', () => {
expect(fmtMoney(null)).toBe('—')
expect(fmtPrice(undefined)).toBe('—')
})
it('formats small prices with six decimals', () => {
expect(fmtPrice(0.5)).toBe('0.500000')
})
it('signs positive values', () => {
expect(signed(12.346)).toBe('+12.35')
expect(signed(-3)).toBe('-3.00')
})
it('classifies pnl colour', () => {
expect(pnlClass(5)).toBe('text-emerald-400')
expect(pnlClass(-5)).toBe('text-rose-400')
expect(pnlClass(0)).toBe('text-slate-300')
})
})
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+30
View File
@@ -0,0 +1,30 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.card {
@apply rounded-xl border border-edge bg-panel p-4 shadow;
}
.label {
@apply text-xs uppercase tracking-wide text-slate-400;
}
.value {
@apply text-2xl font-semibold tabular-nums;
}
.btn {
@apply rounded-lg px-3 py-1.5 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-40;
}
.btn-primary {
@apply btn bg-emerald-500 text-slate-900 hover:bg-emerald-400;
}
.btn-danger {
@apply btn bg-rose-500 text-white hover:bg-rose-400;
}
.btn-muted {
@apply btn border border-edge text-slate-200 hover:bg-slate-700;
}
.input {
@apply w-full rounded-lg border border-edge bg-surface px-2 py-1.5 text-sm text-slate-100 outline-none focus:border-emerald-400;
}
}
+14
View File
@@ -0,0 +1,14 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js}'],
theme: {
extend: {
colors: {
surface: '#0f172a',
panel: '#1e293b',
edge: '#334155',
},
},
},
plugins: [],
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
ws: true,
},
},
},
test: {
environment: 'jsdom',
globals: true,
},
})