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