Crypto trading bot: engine, brokers, backtester, dashboard, deploy
FastAPI backend with a single-strategy async engine, paper and Binance brokers, pure-python indicators, a backtester that shares the engine's portfolio math, and a Vue dashboard with a live WebSocket feed. Adds the Gitea Actions CI/CD workflow that deploys to Traefik via docker_default.
This commit is contained in:
1 parent
98028248c2
commit
9c3bee87ac
73 files changed
+9759
-1
No files matched your search
@@ -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=*
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,11 @@
|
||||
.venv/
|
||||
tests/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
data/
|
||||
*.db
|
||||
*.sqlite3
|
||||
.env
|
||||
.git/
|
||||
@@ -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"]
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Whitespace-only changes.
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
addopts = -q
|
||||
@@ -0,0 +1,5 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.3
|
||||
pytest-asyncio>=0.25
|
||||
anyio>=4.7
|
||||
ruff>=0.8
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.log
|
||||
.DS_Store
|
||||
.git/
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+4906
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 & 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&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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user