Compare commits

...
2 Commits
Author SHA1 Message Date
matitos 186a6949ee Merge pull request 'Risk controls, alerts, API token, more exchanges, presets' (#1) from feat/risk-notify-exchanges-presets into main
CI/CD / backend (push) Successful in 5m16s
CI/CD / frontend (push) Successful in 4m58s
CI/CD / build (push) Successful in 23s
CI/CD / cleanup (push) Successful in 4s
CI/CD / deploy (push) Successful in 23s
2026-09-18 21:39:05 +02:00
matitos 03dfa369e5 Risk controls, alerts, API token, more exchanges, presets
CI/CD / backend (pull_request) Successful in 6m18s
CI/CD / frontend (pull_request) Successful in 4m55s
CI/CD / build (pull_request) Skipped
CI/CD / deploy (pull_request) Skipped
CI/CD / cleanup (pull_request) Skipped
- RiskManager halts + flattens on max daily loss / drawdown, caps position
  size and trades per day; adds a manual kill switch (POST /api/bot/kill).
- Telegram and webhook notifications on trades, errors and halts.
- Optional API bearer token on /api, injected server-side by the frontend
  nginx so the browser never sees it.
- Kraken and Coinbase market data (public); Coinbase live broker. Live on an
  unsupported exchange now errors instead of silently falling back to paper.
- Strategy presets backed by the StrategyConfig table (CRUD API + UI).
- Idempotent additive-column migration so existing SQLite databases upgrade in
  place (fixes the new strategy_configs.name column).
2026-09-18 19:26:29 +00:00
35 changed files with 1409 additions and 45 deletions

No files matched your search

+24
View File
@@ -33,6 +33,30 @@ EXCHANGE_API_SECRET=
# Set true for testnet/sandbox where supported # Set true for testnet/sandbox where supported
EXCHANGE_SANDBOX=false EXCHANGE_SANDBOX=false
# --- Risk controls (0 disables a limit) ---
# Halt + flatten if the day's loss reaches this amount in quote currency
MAX_DAILY_LOSS=0
# Halt + flatten if equity falls this percent from its peak
MAX_DRAWDOWN_PCT=0
# Cap a single position at this fraction of equity (1.0 = all-in)
MAX_POSITION_PCT=1.0
# Max new entries per UTC day (0 = unlimited)
MAX_TRADES_PER_DAY=0
# --- API token (optional) ---
# When set, /api requires this token. The frontend nginx injects it server-side,
# so the browser never sees it. Leave empty to disable.
API_TOKEN=
API_TOKEN_HEADER=X-API-Token
# --- Notifications (optional) ---
# Telegram: message @BotFather to create a bot, then get the chat id from
# https://api.telegram.org/bot<token>/getUpdates after messaging the bot.
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
# Generic webhook: receives POST {"level": "...", "text": "..."}
NOTIFY_WEBHOOK_URL=
# --- Web --- # --- Web ---
# Comma separated list, or * in dev # Comma separated list, or * in dev
CORS_ORIGINS=* CORS_ORIGINS=*
+12
View File
@@ -116,6 +116,18 @@ jobs:
CRYPTO_EXCHANGE_API_KEY: ${{ secrets.CRYPTO_EXCHANGE_API_KEY }} CRYPTO_EXCHANGE_API_KEY: ${{ secrets.CRYPTO_EXCHANGE_API_KEY }}
CRYPTO_EXCHANGE_API_SECRET: ${{ secrets.CRYPTO_EXCHANGE_API_SECRET }} CRYPTO_EXCHANGE_API_SECRET: ${{ secrets.CRYPTO_EXCHANGE_API_SECRET }}
CRYPTO_EXCHANGE_SANDBOX: ${{ vars.CRYPTO_EXCHANGE_SANDBOX || 'false' }} CRYPTO_EXCHANGE_SANDBOX: ${{ vars.CRYPTO_EXCHANGE_SANDBOX || 'false' }}
# Risk controls (0 disables a limit).
CRYPTO_MAX_DAILY_LOSS: ${{ vars.CRYPTO_MAX_DAILY_LOSS || '0' }}
CRYPTO_MAX_DRAWDOWN_PCT: ${{ vars.CRYPTO_MAX_DRAWDOWN_PCT || '0' }}
CRYPTO_MAX_POSITION_PCT: ${{ vars.CRYPTO_MAX_POSITION_PCT || '1.0' }}
CRYPTO_MAX_TRADES_PER_DAY: ${{ vars.CRYPTO_MAX_TRADES_PER_DAY || '0' }}
# Notifications.
CRYPTO_TELEGRAM_BOT_TOKEN: ${{ secrets.CRYPTO_TELEGRAM_BOT_TOKEN }}
CRYPTO_TELEGRAM_CHAT_ID: ${{ vars.CRYPTO_TELEGRAM_CHAT_ID || secrets.CRYPTO_TELEGRAM_CHAT_ID }}
CRYPTO_NOTIFY_WEBHOOK_URL: ${{ secrets.CRYPTO_NOTIFY_WEBHOOK_URL }}
# Optional API bearer token (injected server-side by the frontend nginx).
CRYPTO_API_TOKEN: ${{ secrets.CRYPTO_API_TOKEN }}
CRYPTO_API_TOKEN_HEADER: ${{ vars.CRYPTO_API_TOKEN_HEADER || 'X-API-Token' }}
CRYPTO_AUTH_RATE_LIMIT_AVERAGE: ${{ vars.CRYPTO_AUTH_RATE_LIMIT_AVERAGE || '10' }} CRYPTO_AUTH_RATE_LIMIT_AVERAGE: ${{ vars.CRYPTO_AUTH_RATE_LIMIT_AVERAGE || '10' }}
CRYPTO_AUTH_RATE_LIMIT_BURST: ${{ vars.CRYPTO_AUTH_RATE_LIMIT_BURST || '5' }} CRYPTO_AUTH_RATE_LIMIT_BURST: ${{ vars.CRYPTO_AUTH_RATE_LIMIT_BURST || '5' }}
# Persistent data root: repo var wins, else derive from the org # Persistent data root: repo var wins, else derive from the org
+45 -2
View File
@@ -123,6 +123,46 @@ candles you control and asserts the emitted intents. See
- Portfolio invariant: `equity = cash ± qty * mark`, realized P&L accumulates on - Portfolio invariant: `equity = cash ± qty * mark`, realized P&L accumulates on
close, and every order/equity point/event is persisted. close, and every order/equity point/event is persisted.
## Risk controls & notifications
- `app/engine/risk.py` (`RiskManager`) tracks daily loss, drawdown, position cap
and trades/day. `update(equity)` only *detects* a breach and returns a reason;
the engine's `_halt()` makes it sticky, flattens, and stops. Keep that split —
it is what lets the engine close the position before halting.
- Halt is sticky until the next `start()`. `POST /api/bot/kill` and the UI Kill
switch call `engine.kill()`.
- `app/notify.py` fans out to Telegram and/or a webhook. The runtime subscribes
to `trade` and `error` events (see `notify_levels`) and sends them in a
background task. Notifications must never break trading — failures are logged
and swallowed. Use `RecordingNotifier` in tests (see `tests/test_notify.py`).
- Status exposes `halted`, `halt_reason`, `trades_today`, `day_pnl`,
`drawdown_pct`; the dashboard renders a red banner and risk cards.
## Schema changes
`Base.metadata.create_all` creates missing tables but never alters existing ones,
so deployed SQLite databases would break on a new column. When you add a
nullable/defaulted column to an existing table, add it to `_ADDITIVE_COLUMNS` in
`app/db.py` as `(table, column, DDL)`. `init_db()` applies these on startup
(idempotent). Anything beyond additive changes (renames, type changes) needs a
real migration step.
## Exchanges, presets and API token
- Market data adapters live in `app/market/` (Binance, Kraken, Coinbase) behind
`MarketData`; live brokers in `app/broker/` (Binance, Coinbase) behind
`Broker`. Add a venue by writing one adapter and registering it in the
respective `__init__.py`. Timeframes differ per venue — each adapter validates
and raises `ValueError` on unsupported values.
- Kraken is market-data-only; `MODE=live` on it raises. Don't silently fall back
to paper for an unsupported live exchange.
- Strategy presets use the `StrategyConfig` table via `/api/presets`; the
dashboard's strategy form can load/save them.
- `API_TOKEN` guards `/api` when set (`get_guarded_runtime` dependency). The
frontend nginx injects it via the `API_TOKEN_HEADER` header on `/api`, and on
the WebSocket upgrade. `GET /api/health` is always open. When adding routes,
depend on `GuardedRuntimeDep`, not `RuntimeDep` (which no longer exists).
## Backtesting ## Backtesting
`app/backtest/` replays historical candles through the *same* strategy `app/backtest/` replays historical candles through the *same* strategy
@@ -166,14 +206,17 @@ backend/app/
runtime.py active engine + pub/sub fan-out to sockets runtime.py active engine + pub/sub fan-out to sockets
engine/ engine/
engine.py trading loop, portfolio, risk engine.py trading loop, portfolio, risk
risk.py RiskManager: limits, halt/kill
portfolio.py shared cash/P&L/sizing/risk math (engine + backtest) portfolio.py shared cash/P&L/sizing/risk math (engine + backtest)
context.py -> base.py (StrategyContext) lives in strategies/base.py context.py -> base.py (StrategyContext) lives in strategies/base.py
indicators.py pure-python indicators indicators.py pure-python indicators
strategies/ <-- add strategies here (auto-discovered) strategies/ <-- add strategies here (auto-discovered)
backtest/ historical replay + metrics backtest/ historical replay + metrics
broker/ paper + binance brokers broker/ paper + binance + coinbase brokers
market/ binance + fake data feeds market/ binance + kraken + coinbase + fake feeds
notify.py Telegram + webhook alerting
models.py/store.py SQLAlchemy models and persistence models.py/store.py SQLAlchemy models and persistence
db.py engine + additive column migrations
frontend/src/ Vue dashboard (App.vue + components/) frontend/src/ Vue dashboard (App.vue + components/)
docker-compose.yml production (Traefik + docker_default) docker-compose.yml production (Traefik + docker_default)
docker-compose.local.yml local, self-contained docker-compose.local.yml local, self-contained
+53
View File
@@ -84,6 +84,10 @@ Repo/org variables and secrets used by the deploy:
| `CRYPTO_STARTING_BALANCE` / `CRYPTO_POLL_INTERVAL` | var | Paper account + tick rate | | `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_API_KEY` / `CRYPTO_EXCHANGE_API_SECRET` | secret | Required only for `live` |
| `CRYPTO_EXCHANGE_SANDBOX` | var | Use the Binance testnet | | `CRYPTO_EXCHANGE_SANDBOX` | var | Use the Binance testnet |
| `CRYPTO_MAX_DAILY_LOSS` / `CRYPTO_MAX_DRAWDOWN_PCT` / `CRYPTO_MAX_POSITION_PCT` / `CRYPTO_MAX_TRADES_PER_DAY` | var | Risk limits |
| `CRYPTO_TELEGRAM_BOT_TOKEN` | secret | Telegram alerts |
| `CRYPTO_TELEGRAM_CHAT_ID` | var/secret | Telegram chat id |
| `CRYPTO_NOTIFY_WEBHOOK_URL` | secret | Generic webhook alerts |
| `CRYPTO_CORS_ORIGINS` | var | Defaults to `https://crypto.<BASE_DOMAIN>` | | `CRYPTO_CORS_ORIGINS` | var | Defaults to `https://crypto.<BASE_DOMAIN>` |
| `CRYPTO_DATA_DIR` / `PERSISTENT_MOUNT` | var | Host path for the SQLite DB | | `CRYPTO_DATA_DIR` / `PERSISTENT_MOUNT` | var | Host path for the SQLite DB |
| `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` | secret | Optional; for authenticated pulls | | `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` | secret | Optional; for authenticated pulls |
@@ -104,6 +108,52 @@ All settings are environment variables (see `.env.example`):
| `DATABASE_URL` | `sqlite:///./data/trader.db` | Postgres: `postgresql+psycopg://...` | | `DATABASE_URL` | `sqlite:///./data/trader.db` | Postgres: `postgresql+psycopg://...` |
| `EXCHANGE_API_KEY` / `EXCHANGE_API_SECRET` | — | Required only for live | | `EXCHANGE_API_KEY` / `EXCHANGE_API_SECRET` | — | Required only for live |
| `EXCHANGE_SANDBOX` | `false` | Use Binance testnet | | `EXCHANGE_SANDBOX` | `false` | Use Binance testnet |
| `MAX_DAILY_LOSS` | `0` | Halt+flatten after this daily loss (quote ccy); 0 disables |
| `MAX_DRAWDOWN_PCT` | `0` | Halt+flatten after this % drop from peak; 0 disables |
| `MAX_POSITION_PCT` | `1.0` | Cap a single position at this fraction of equity |
| `MAX_TRADES_PER_DAY` | `0` | Max new entries per UTC day; 0 disables |
| `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | — | Telegram alerts (both required) |
| `NOTIFY_WEBHOOK_URL` | — | Generic webhook alerts |
| `API_TOKEN` / `API_TOKEN_HEADER` | — | Bearer token required on `/api` when set |
## Exchanges
Market data works out of the box on **Binance**, **Kraken** and **Coinbase**
(public REST, no API key). Set `EXCHANGE` to `binance`, `kraken` or `coinbase`.
Live execution (`MODE=live`) is implemented for **Binance** (spot, optional
testnet via `EXCHANGE_SANDBOX`) and **Coinbase** (Advanced Trade; set
`EXCHANGE_API_SECRET` to the CDP EC private key in PEM). Kraken is
market-data-only for now — `MODE=live` on Kraken raises a clear error.
## API token (defense-in-depth)
Set `API_TOKEN` to require a bearer token on all `/api` routes (health stays
open). The frontend nginx injects it server-side, so the browser never sees it
and the dashboard keeps working unchanged. This sits *behind* the Traefik Basic
auth, not instead of it.
## Risk controls
The engine enforces limits on every tick and **halts + flattens** when one trips:
- **`MAX_DAILY_LOSS`** — absolute loss since the start of the UTC day.
- **`MAX_DRAWDOWN_PCT`** — percentage fall from peak equity.
- **`MAX_POSITION_PCT`** — position size cap as a fraction of equity.
- **`MAX_TRADES_PER_DAY`** — new-entry budget per UTC day.
A halt is sticky: the position is closed, the bot stops, a red banner appears in
the dashboard, and a notification is sent. Restarting the bot resets the limits.
The dashboard also has a manual **Kill switch** button (`POST /api/bot/kill`)
that flattens and halts immediately.
## Notifications
Set `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID` and/or `NOTIFY_WEBHOOK_URL` to
receive alerts on trades, stop-outs, errors and halts. Notifications are
best-effort: a failing channel never affects trading. For Telegram, create a bot
with [@BotFather](https://t.me/BotFather), message it once, then read the chat id
from `https://api.telegram.org/bot<token>/getUpdates`.
## Adding a strategy ## Adding a strategy
@@ -120,6 +170,7 @@ template and the `StrategyContext` API. Two examples ship in the repo:
| `GET` | `/api/strategies` | Available strategies + parameter specs | | `GET` | `/api/strategies` | Available strategies + parameter specs |
| `POST` | `/api/bot/start` | Start a strategy | | `POST` | `/api/bot/start` | Start a strategy |
| `POST` | `/api/bot/stop` | Stop the bot | | `POST` | `/api/bot/stop` | Stop the bot |
| `POST` | `/api/bot/kill` | Kill switch: flatten + halt |
| `POST` | `/api/backtest` | Backtest a strategy on historical candles | | `POST` | `/api/backtest` | Backtest a strategy on historical candles |
| `GET` | `/api/positions` | Positions (with unrealized P&L when open) | | `GET` | `/api/positions` | Positions (with unrealized P&L when open) |
| `GET` | `/api/orders` | Order history | | `GET` | `/api/orders` | Order history |
@@ -127,6 +178,8 @@ template and the `StrategyContext` API. Two examples ship in the repo:
| `GET` | `/api/equity` | Equity curve points | | `GET` | `/api/equity` | Equity curve points |
| `POST` | `/api/orders` | Manual market order | | `POST` | `/api/orders` | Manual market order |
| `POST` | `/api/positions/{id}/close` | Close the open position | | `POST` | `/api/positions/{id}/close` | Close the open position |
| `GET`/`POST` | `/api/presets` | List / save strategy presets |
| `DELETE` | `/api/presets/{id}` | Delete a preset |
| `WS` | `/api/ws` | Live status + event stream | | `WS` | `/api/ws` | Live status + event stream |
## Tests & lint ## Tests & lint
+81 -12
View File
@@ -6,7 +6,8 @@ from typing import Annotated
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from app.engine.strategies import list_strategies, strategy_spec from app.config import Settings
from app.engine.strategies import get_strategy, list_strategies, strategy_spec
from app.models import OrderSide, Position, PositionStatus from app.models import OrderSide, Position, PositionStatus
from app.runtime import Runtime from app.runtime import Runtime
from app.schemas import ( from app.schemas import (
@@ -15,11 +16,14 @@ from app.schemas import (
ClosePositionRequest, ClosePositionRequest,
EquityPointOut, EquityPointOut,
EventOut, EventOut,
KillRequest,
ManualOrderRequest, ManualOrderRequest,
OrderOut, OrderOut,
PositionOut, PositionOut,
StartRequest, StartRequest,
StatusOut, StatusOut,
StrategyConfigIn,
StrategyConfigOut,
StrategyOut, StrategyOut,
) )
@@ -30,7 +34,26 @@ def get_runtime(request: Request) -> Runtime:
return request.app.state.runtime return request.app.state.runtime
RuntimeDep = Annotated[Runtime, Depends(get_runtime)] def get_settings(request: Request) -> Settings:
return request.app.state.runtime.settings
def get_guarded_runtime(request: Request) -> Runtime:
"""Return the runtime, rejecting requests without the configured API token.
The check is a no-op when ``API_TOKEN`` is unset, so local dev and the
Basic-auth-only deployment keep working unchanged.
"""
runtime: Runtime = request.app.state.runtime
settings: Settings = runtime.settings
if settings.api_token:
provided = request.headers.get(settings.api_token_header, "")
if provided != settings.api_token:
raise HTTPException(status_code=401, detail="invalid or missing API token")
return runtime
GuardedRuntimeDep = Annotated[Runtime, Depends(get_guarded_runtime)]
@router.get("/health") @router.get("/health")
@@ -44,12 +67,12 @@ def strategies() -> list[StrategyOut]:
@router.get("/status", response_model=StatusOut) @router.get("/status", response_model=StatusOut)
def status(runtime: RuntimeDep) -> StatusOut: def status(runtime: GuardedRuntimeDep) -> StatusOut:
return runtime.status() return runtime.status()
@router.post("/bot/start", response_model=StatusOut) @router.post("/bot/start", response_model=StatusOut)
async def start_bot(payload: StartRequest, runtime: RuntimeDep) -> StatusOut: async def start_bot(payload: StartRequest, runtime: GuardedRuntimeDep) -> StatusOut:
try: try:
return await runtime.start( return await runtime.start(
payload.strategy_key, payload.strategy_key,
@@ -65,12 +88,17 @@ async def start_bot(payload: StartRequest, runtime: RuntimeDep) -> StatusOut:
@router.post("/bot/stop", response_model=StatusOut) @router.post("/bot/stop", response_model=StatusOut)
async def stop_bot(runtime: RuntimeDep) -> StatusOut: async def stop_bot(runtime: GuardedRuntimeDep) -> StatusOut:
return await runtime.stop() return await runtime.stop()
@router.post("/bot/kill", response_model=StatusOut)
async def kill_bot(payload: KillRequest, runtime: GuardedRuntimeDep) -> StatusOut:
return await runtime.kill(payload.reason)
@router.post("/backtest", response_model=BacktestResult) @router.post("/backtest", response_model=BacktestResult)
async def run_backtest(payload: BacktestRequest, runtime: RuntimeDep) -> BacktestResult: async def run_backtest(payload: BacktestRequest, runtime: GuardedRuntimeDep) -> BacktestResult:
try: try:
return await runtime.run_backtest(payload) return await runtime.run_backtest(payload)
except KeyError as exc: except KeyError as exc:
@@ -81,6 +109,36 @@ async def run_backtest(payload: BacktestRequest, runtime: RuntimeDep) -> Backtes
raise HTTPException(status_code=502, detail=f"market data error: {exc}") from exc raise HTTPException(status_code=502, detail=f"market data error: {exc}") from exc
@router.get("/presets", response_model=list[StrategyConfigOut])
def list_presets(runtime: GuardedRuntimeDep) -> list[StrategyConfigOut]:
return [
StrategyConfigOut.model_validate(config) for config in runtime.store.list_strategy_configs()
]
@router.post("/presets", response_model=StrategyConfigOut)
def create_preset(payload: StrategyConfigIn, runtime: GuardedRuntimeDep) -> StrategyConfigOut:
try:
get_strategy(payload.strategy_key)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
config = runtime.store.save_strategy_config(
name=payload.name,
strategy_key=payload.strategy_key,
symbol=payload.symbol,
timeframe=payload.timeframe,
params=payload.params,
)
return StrategyConfigOut.model_validate(config)
@router.delete("/presets/{config_id}")
def delete_preset(config_id: int, runtime: GuardedRuntimeDep) -> dict[str, bool]:
if not runtime.store.delete_strategy_config(config_id):
raise HTTPException(status_code=404, detail="preset not found")
return {"deleted": True}
def _position_out(position: Position, price: float | None) -> PositionOut: def _position_out(position: Position, price: float | None) -> PositionOut:
out = PositionOut.model_validate(position) out = PositionOut.model_validate(position)
if position.status == PositionStatus.OPEN and price is not None: if position.status == PositionStatus.OPEN and price is not None:
@@ -90,28 +148,28 @@ def _position_out(position: Position, price: float | None) -> PositionOut:
@router.get("/positions", response_model=list[PositionOut]) @router.get("/positions", response_model=list[PositionOut])
def positions(runtime: RuntimeDep, limit: int = 50) -> list[PositionOut]: def positions(runtime: GuardedRuntimeDep, limit: int = 50) -> list[PositionOut]:
price = runtime.engine.last_price if runtime.engine else None price = runtime.engine.last_price if runtime.engine else None
return [_position_out(position, price) for position in runtime.store.list_positions(limit)] return [_position_out(position, price) for position in runtime.store.list_positions(limit)]
@router.get("/orders", response_model=list[OrderOut]) @router.get("/orders", response_model=list[OrderOut])
def orders(runtime: RuntimeDep, limit: int = 50) -> list[OrderOut]: def orders(runtime: GuardedRuntimeDep, limit: int = 50) -> list[OrderOut]:
return [OrderOut.model_validate(order) for order in runtime.store.list_orders(limit)] return [OrderOut.model_validate(order) for order in runtime.store.list_orders(limit)]
@router.get("/events", response_model=list[EventOut]) @router.get("/events", response_model=list[EventOut])
def events(runtime: RuntimeDep, limit: int = 50) -> list[EventOut]: def events(runtime: GuardedRuntimeDep, limit: int = 50) -> list[EventOut]:
return [EventOut.model_validate(event) for event in runtime.store.list_events(limit)] return [EventOut.model_validate(event) for event in runtime.store.list_events(limit)]
@router.get("/equity", response_model=list[EquityPointOut]) @router.get("/equity", response_model=list[EquityPointOut])
def equity(runtime: RuntimeDep, limit: int = 500) -> list[EquityPointOut]: def equity(runtime: GuardedRuntimeDep, limit: int = 500) -> list[EquityPointOut]:
return [EquityPointOut.model_validate(point) for point in runtime.store.list_equity(limit)] return [EquityPointOut.model_validate(point) for point in runtime.store.list_equity(limit)]
@router.post("/orders", response_model=StatusOut) @router.post("/orders", response_model=StatusOut)
async def manual_order(payload: ManualOrderRequest, runtime: RuntimeDep) -> StatusOut: async def manual_order(payload: ManualOrderRequest, runtime: GuardedRuntimeDep) -> StatusOut:
engine = runtime.engine engine = runtime.engine
if engine is None or not engine.running: if engine is None or not engine.running:
raise HTTPException(status_code=409, detail="engine is not running") raise HTTPException(status_code=409, detail="engine is not running")
@@ -122,7 +180,7 @@ async def manual_order(payload: ManualOrderRequest, runtime: RuntimeDep) -> Stat
@router.post("/positions/{position_id}/close", response_model=StatusOut) @router.post("/positions/{position_id}/close", response_model=StatusOut)
async def close_position( async def close_position(
position_id: int, payload: ClosePositionRequest, runtime: RuntimeDep position_id: int, payload: ClosePositionRequest, runtime: GuardedRuntimeDep
) -> StatusOut: ) -> StatusOut:
engine = runtime.engine engine = runtime.engine
if engine is None or not engine.running: if engine is None or not engine.running:
@@ -136,6 +194,17 @@ async def close_position(
@router.websocket("/ws") @router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None: async def websocket_endpoint(websocket: WebSocket) -> None:
runtime: Runtime = websocket.app.state.runtime runtime: Runtime = websocket.app.state.runtime
settings: Settings = runtime.settings
if settings.api_token:
# The frontend nginx injects the token as a header on the upgrade request
# (browsers can't set WebSocket headers themselves); accept a query param
# too for direct/manual clients.
provided = websocket.headers.get(settings.api_token_header) or websocket.query_params.get(
"token", ""
)
if provided != settings.api_token:
await websocket.close(code=1008)
return
await websocket.accept() await websocket.accept()
queue = runtime.subscribe() queue = runtime.subscribe()
try: try:
+24 -2
View File
@@ -2,19 +2,41 @@ from __future__ import annotations
from app.broker.base import Broker, Fill from app.broker.base import Broker, Fill
from app.broker.binance import TESTNET_URL, BinanceBroker from app.broker.binance import TESTNET_URL, BinanceBroker
from app.broker.coinbase import CoinbaseBroker
from app.broker.paper import PaperBroker from app.broker.paper import PaperBroker
from app.config import Settings from app.config import Settings
from app.market.binance import BASE_URL from app.market.binance import BASE_URL
# Exchanges with a live execution adapter. Others fall back to paper and the
# engine surfaces a clear error at start.
_LIVE_BROKERS: dict[str, type[Broker]] = {
"binance": BinanceBroker,
"binanceus": BinanceBroker,
"coinbase": CoinbaseBroker,
}
def build_broker(settings: Settings) -> Broker: def build_broker(settings: Settings) -> Broker:
if settings.mode == "live": if settings.mode != "live":
return PaperBroker(fee_bps=settings.fee_bps, slippage_bps=settings.slippage_bps)
exchange = settings.exchange.lower()
if exchange not in _LIVE_BROKERS:
raise ValueError(
f"live trading is not supported on '{settings.exchange}' yet; "
f"use MODE=paper or one of {sorted(_LIVE_BROKERS)}"
)
if exchange in {"binance", "binanceus"}:
return BinanceBroker( return BinanceBroker(
api_key=settings.exchange_api_key, api_key=settings.exchange_api_key,
api_secret=settings.exchange_api_secret, api_secret=settings.exchange_api_secret,
base_url=TESTNET_URL if settings.exchange_sandbox else BASE_URL, base_url=TESTNET_URL if settings.exchange_sandbox else BASE_URL,
) )
return PaperBroker(fee_bps=settings.fee_bps, slippage_bps=settings.slippage_bps) return CoinbaseBroker(
api_key=settings.exchange_api_key,
api_secret=settings.exchange_api_secret,
)
__all__ = ["Broker", "Fill", "PaperBroker", "build_broker"] __all__ = ["Broker", "Fill", "PaperBroker", "build_broker"]
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
import base64
import json
import time
import uuid
import httpx
from app.broker.base import Broker, Fill
from app.market.coinbase import BASE_URL, to_product_id
from app.models import OrderSide, utcnow
class CoinbaseBroker(Broker):
"""Live Coinbase Advanced Trade execution (requires API credentials).
Credentials are a Coinbase CDP API key + an EC private key (PEM). The key
name is ``EXCHANGE_API_KEY`` and the PEM is ``EXCHANGE_API_SECRET`` (with
literal ``\\n`` escapes allowed).
"""
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(
"Coinbase broker requires EXCHANGE_API_KEY and EXCHANGE_API_SECRET (PEM)"
)
self.api_key = api_key
self.api_secret = api_secret
self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout)
def _token(self, method: str, path: str) -> str:
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
nonce = str(uuid.uuid4())
timestamp = str(int(time.time()))
message = f"{timestamp}{method}{path}{nonce}"
private_key = serialization.load_pem_private_key(
self.api_secret.replace("\\n", "\n").encode(), password=None
)
signature = private_key.sign(message.encode(), ec.ECDSA(hashes.SHA256()))
return f"Bearer {self.api_key}:{base64.b64encode(signature).decode()}:{nonce}:{timestamp}"
async def _market_order(self, symbol: str, side: OrderSide, qty: float) -> Fill:
path = "/api/v3/brokerage/orders"
body = {
"client_order_id": str(uuid.uuid4()),
"product_id": to_product_id(symbol),
"side": side.value.upper(),
"order_configuration": {"market_market_ioc": {"base_size": f"{qty:.8f}"}},
}
response = await self._client.post(
path,
content=json.dumps(body),
headers={
"Authorization": self._token("POST", path),
"Content-Type": "application/json",
},
)
response.raise_for_status()
data = response.json()
order = data.get("order", data)
success = order.get("success", True)
if not success:
raise RuntimeError(f"coinbase order rejected: {order.get('failure_reason', order)}")
filled = float(order.get("filled_size") or qty)
price = float(order.get("average_filled_price") or 0.0)
fee = float(order.get("total_fees") or 0.0)
return Fill(symbol=symbol, side=side, qty=filled, 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()
+20
View File
@@ -39,6 +39,26 @@ class Settings(BaseSettings):
exchange_api_secret: str = "" exchange_api_secret: str = ""
exchange_sandbox: bool = False exchange_sandbox: bool = False
# --- Risk controls (0 disables a limit) ---
# Absolute loss in quote currency since the start of the UTC day.
max_daily_loss: float = 0.0
# Percentage fall from peak equity.
max_drawdown_pct: float = 0.0
# Cap on a single position as a fraction of equity (1.0 = all-in).
max_position_pct: float = 1.0
# Maximum number of new entries per UTC day.
max_trades_per_day: int = 0
# --- Notifications ---
telegram_bot_token: str = ""
telegram_chat_id: str = ""
notify_webhook_url: str = ""
# Optional bearer token required on /api requests (defense-in-depth behind
# the Traefik Basic auth). Empty disables the check.
api_token: str = ""
api_token_header: str = "X-API-Token"
cors_origins: str = "*" cors_origins: str = "*"
@field_validator("mode") @field_validator("mode")
+26 -1
View File
@@ -1,15 +1,25 @@
from __future__ import annotations from __future__ import annotations
import logging
from collections.abc import Iterator from collections.abc import Iterator
from sqlalchemy import create_engine from sqlalchemy import Engine, create_engine, inspect, text
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from app.config import REPO_ROOT, get_settings from app.config import REPO_ROOT, get_settings
from app.models import Base from app.models import Base
logger = logging.getLogger(__name__)
_settings = get_settings() _settings = get_settings()
# Additive migrations for existing databases. ``create_all`` only creates missing
# tables; it never alters existing ones, so new nullable/defaulted columns are
# added here as (table, column, DDL type) tuples.
_ADDITIVE_COLUMNS: list[tuple[str, str, str]] = [
("strategy_configs", "name", "VARCHAR(80) DEFAULT ''"),
]
def _normalize_url(url: str) -> tuple[str, dict]: def _normalize_url(url: str) -> tuple[str, dict]:
connect_args: dict = {} connect_args: dict = {}
@@ -26,8 +36,23 @@ engine = create_engine(_url, connect_args=_connect_args, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def _apply_additive_columns(target: Engine) -> None:
inspector = inspect(target)
existing_tables = set(inspector.get_table_names())
with target.begin() as connection:
for table, column, ddl in _ADDITIVE_COLUMNS:
if table not in existing_tables:
continue
columns = {col["name"] for col in inspector.get_columns(table)}
if column in columns:
continue
logger.info("migrating: adding %s.%s", table, column)
connection.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}"))
def init_db() -> None: def init_db() -> None:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_apply_additive_columns(engine)
def get_session() -> Iterator[Session]: def get_session() -> Iterator[Session]:
+50 -1
View File
@@ -17,6 +17,7 @@ from app.engine.portfolio import (
risk_exit, risk_exit,
unrealized_pnl, unrealized_pnl,
) )
from app.engine.risk import RiskManager
from app.engine.strategies import Strategy, get_strategy from app.engine.strategies import Strategy, get_strategy
from app.engine.strategies.base import StrategyContext from app.engine.strategies.base import StrategyContext
from app.market.base import MarketData from app.market.base import MarketData
@@ -61,6 +62,8 @@ class TradingEngine:
self.symbol: str = settings.symbol self.symbol: str = settings.symbol
self.timeframe: str = settings.timeframe self.timeframe: str = settings.timeframe
self.risk = RiskManager.from_settings(settings, settings.starting_balance)
self._entry_blocked_logged = False
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
self._publish_cb: Publish | None = None self._publish_cb: Publish | None = None
@@ -94,6 +97,8 @@ class TradingEngine:
self.last_price = None self.last_price = None
self.error = None self.error = None
self.last_tick = None self.last_tick = None
self.risk.reset(self.starting_balance)
self._entry_blocked_logged = False
self.running = True self.running = True
self._emit("info", f"Started {strategy_cls.name or strategy_key} on {self.symbol}") self._emit("info", f"Started {strategy_cls.name or strategy_key} on {self.symbol}")
@@ -127,6 +132,8 @@ class TradingEngine:
except Exception as exc: # keep the loop alive on transient errors except Exception as exc: # keep the loop alive on transient errors
self.error = f"{type(exc).__name__}: {exc}" self.error = f"{type(exc).__name__}: {exc}"
self._emit("error", f"tick failed: {self.error}") self._emit("error", f"tick failed: {self.error}")
if not self.running:
break
await asyncio.sleep(self.settings.poll_interval) await asyncio.sleep(self.settings.poll_interval)
async def tick(self) -> None: async def tick(self) -> None:
@@ -139,6 +146,11 @@ class TradingEngine:
self.last_tick = datetime.now(UTC) self.last_tick = datetime.now(UTC)
self.error = None self.error = None
halt_reason = self.risk.update(self.equity)
if halt_reason is not None:
await self._halt(halt_reason)
return
if self.position is not None: if self.position is not None:
await self._check_risk(candles[-1]) await self._check_risk(candles[-1])
@@ -207,10 +219,17 @@ class TradingEngine:
if not price or price <= 0: if not price or price <= 0:
return return
if not self.risk.can_enter():
if not self._entry_blocked_logged:
self._entry_blocked_logged = True
reason = self.risk.entry_block_reason() or "entry blocked"
self._emit("warning", f"Entry blocked: {reason}")
return
if qty_override is not None: if qty_override is not None:
qty = qty_override qty = qty_override
else: else:
qty = order_qty(self.equity, size, side, self.cash, price) qty = order_qty(self.equity, self.risk.clamp_size(size), side, self.cash, price)
if qty <= 0: if qty <= 0:
return return
@@ -242,6 +261,7 @@ class TradingEngine:
fee=fill.fee, fee=fill.fee,
reason=reason, reason=reason,
) )
self.risk.record_entry()
self._emit( self._emit(
"trade", "trade",
f"Opened {side.value} {fill.qty:.6f} {self.symbol} @ {fill.price:.4f} ({reason})", f"Opened {side.value} {fill.qty:.6f} {self.symbol} @ {fill.price:.4f} ({reason})",
@@ -309,6 +329,30 @@ class TradingEngine:
self.last_price = level self.last_price = level
await self._close_position(reason) await self._close_position(reason)
# --- halt / kill switch -------------------------------------------
async def _halt(self, reason: str) -> None:
"""Flatten and stop trading. Sticky until the next start()."""
if self.risk.halted:
return
self.risk.halt(reason)
self._emit("error", f"HALTED: {reason}")
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(f"halt: {reason}")
self.running = False
self._broadcast_status()
async def kill(self, reason: str = "manual kill switch") -> None:
"""Manual kill switch: halt, flatten, and cancel the loop task."""
await self._halt(reason)
if self._task is not None and not self._task.done():
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
self._broadcast_status()
# --- manual controls ---------------------------------------------- # --- manual controls ----------------------------------------------
async def manual_order( async def manual_order(
self, side: OrderSide, qty: float | None, quote_amount: float | None, reason: str self, side: OrderSide, qty: float | None, quote_amount: float | None, reason: str
@@ -374,6 +418,11 @@ class TradingEngine:
open_positions=1 if self.position else 0, open_positions=1 if self.position else 0,
last_tick=self.last_tick, last_tick=self.last_tick,
error=self.error, error=self.error,
halted=self.risk.halted,
halt_reason=self.risk.halt_reason,
trades_today=self.risk.trades_today,
day_pnl=self.risk.day_pnl,
drawdown_pct=self.risk.drawdown_pct,
) )
def _emit(self, level: str, message: str) -> None: def _emit(self, level: str, message: str) -> None:
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
from datetime import UTC, datetime
from app.config import Settings
class RiskManager:
"""Tracks per-session and per-day limits and can halt trading.
A halt is sticky until ``reset`` is called (which happens on engine start).
All limits are optional; a value of ``0`` disables that limit.
"""
def __init__(
self,
*,
max_daily_loss: float = 0.0,
max_drawdown_pct: float = 0.0,
max_position_pct: float = 1.0,
max_trades_per_day: int = 0,
) -> None:
self.max_daily_loss = max_daily_loss
self.max_drawdown_pct = max_drawdown_pct
self.max_position_pct = max_position_pct
self.max_trades_per_day = max_trades_per_day
self.reset(0.0)
@classmethod
def from_settings(cls, settings: Settings, starting_equity: float) -> RiskManager:
manager = cls(
max_daily_loss=settings.max_daily_loss,
max_drawdown_pct=settings.max_drawdown_pct,
max_position_pct=settings.max_position_pct,
max_trades_per_day=settings.max_trades_per_day,
)
manager.reset(starting_equity)
return manager
def reset(self, starting_equity: float) -> None:
self.starting_equity = starting_equity
self.peak_equity = starting_equity
self.last_equity = starting_equity
self.day_start_equity = starting_equity
self.day = datetime.now(UTC).date()
self.trades_today = 0
self.halted = False
self.halt_reason: str | None = None
@property
def day_pnl(self) -> float:
return self.last_equity - self.day_start_equity
@property
def drawdown_pct(self) -> float:
if self.peak_equity <= 0:
return 0.0
return max(0.0, (self.peak_equity - self.last_equity) / self.peak_equity * 100)
def _roll_day(self, equity: float) -> None:
today = datetime.now(UTC).date()
if today != self.day:
self.day = today
self.day_start_equity = equity
self.trades_today = 0
def update(self, equity: float) -> str | None:
"""Update with the latest equity; return a halt reason if a limit tripped.
Detection only — the caller decides when to actually halt (so it can
flatten first). Use ``halt()`` to make it sticky.
"""
self.last_equity = equity
self._roll_day(equity)
self.peak_equity = max(self.peak_equity, equity)
if self.halted:
return self.halt_reason
if self.max_daily_loss > 0 and self.day_pnl <= -self.max_daily_loss:
return f"daily loss limit hit (P&L {self.day_pnl:.2f})"
if self.max_drawdown_pct > 0 and self.drawdown_pct >= self.max_drawdown_pct:
return f"max drawdown hit ({self.drawdown_pct:.2f}%)"
return None
def halt(self, reason: str) -> str:
self.halted = True
self.halt_reason = reason
return reason
def can_enter(self) -> bool:
return self.entry_block_reason() is None
def entry_block_reason(self) -> str | None:
if self.halted:
return self.halt_reason
if self.max_trades_per_day > 0 and self.trades_today >= self.max_trades_per_day:
return f"max trades per day reached ({self.max_trades_per_day})"
return None
def record_entry(self) -> None:
self.trades_today += 1
def clamp_size(self, size: float) -> float:
return max(0.0, min(size, self.max_position_pct))
+21 -5
View File
@@ -3,14 +3,30 @@ from __future__ import annotations
from app.config import Settings from app.config import Settings
from app.market.base import MarketData from app.market.base import MarketData
from app.market.binance import BinanceMarketData from app.market.binance import BinanceMarketData
from app.market.coinbase import CoinbaseMarketData
from app.market.fake import FakeMarketData from app.market.fake import FakeMarketData
from app.market.kraken import KrakenMarketData
_FEEDS: dict[str, type[MarketData]] = {
"binance": BinanceMarketData,
"binanceus": BinanceMarketData,
"kraken": KrakenMarketData,
"coinbase": CoinbaseMarketData,
}
def build_market_data(settings: Settings) -> MarketData: def build_market_data(settings: Settings) -> MarketData:
exchange = settings.exchange.lower() try:
if exchange in {"binance", "binanceus"}: feed_cls = _FEEDS[settings.exchange.lower()]
return BinanceMarketData() except KeyError:
raise ValueError(f"unsupported exchange: {settings.exchange}") raise ValueError(
f"unsupported exchange: {settings.exchange}. available: {sorted(_FEEDS)}"
) from None
return feed_cls()
__all__ = ["FakeMarketData", "MarketData", "build_market_data"] __all__ = [
"FakeMarketData",
"MarketData",
"build_market_data",
]
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
import httpx
from app.market.base import MarketData
from app.schemas import Candle
BASE_URL = "https://api.exchange.coinbase.com"
# Coinbase granularities are in seconds.
GRANULARITIES: dict[str, int] = {
"1m": 60,
"5m": 300,
"15m": 900,
"1h": 3600,
"6h": 21600,
"1d": 86400,
}
def to_product_id(symbol: str) -> str:
return symbol.replace("/", "-").upper()
class CoinbaseMarketData(MarketData):
"""Public Coinbase Exchange 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,
headers={"User-Agent": "crypto_trading/0.1"},
)
@staticmethod
def _granularity(timeframe: str) -> int:
try:
return GRANULARITIES[timeframe]
except KeyError:
raise ValueError(
f"unsupported timeframe '{timeframe}' for Coinbase; "
f"use one of {sorted(GRANULARITIES)}"
) from None
async def fetch_candles(self, symbol: str, timeframe: str, limit: int) -> list[Candle]:
response = await self._client.get(
f"/products/{to_product_id(symbol)}/candles",
params={"granularity": self._granularity(timeframe)},
)
response.raise_for_status()
# Coinbase returns newest-first rows: [time, low, high, open, close, volume].
rows = sorted(response.json(), key=lambda row: row[0])
candles = [
Candle(
ts=int(row[0]) * 1000,
open=float(row[3]),
high=float(row[2]),
low=float(row[1]),
close=float(row[4]),
volume=float(row[5]),
)
for row in rows
]
return candles[-limit:]
async def fetch_price(self, symbol: str) -> float:
response = await self._client.get(f"/products/{to_product_id(symbol)}/ticker")
response.raise_for_status()
return float(response.json()["price"])
async def close(self) -> None:
await self._client.aclose()
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
import httpx
from app.market.base import MarketData
from app.schemas import Candle
BASE_URL = "https://api.kraken.com"
# Kraken intervals are in minutes; map our timeframe strings to them.
INTERVALS: dict[str, int] = {
"1m": 1,
"5m": 5,
"15m": 15,
"30m": 30,
"1h": 60,
"4h": 240,
"1d": 1440,
"1w": 10080,
}
class KrakenMarketData(MarketData):
"""Public Kraken 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)
@staticmethod
def _interval(timeframe: str) -> int:
try:
return INTERVALS[timeframe]
except KeyError:
raise ValueError(
f"unsupported timeframe '{timeframe}' for Kraken; use one of {sorted(INTERVALS)}"
) from None
@staticmethod
def _result(payload: dict) -> dict:
errors = payload.get("error") or []
if errors:
raise ValueError(f"kraken error: {errors}")
return payload.get("result", {})
async def fetch_candles(self, symbol: str, timeframe: str, limit: int) -> list[Candle]:
response = await self._client.get(
"/0/public/OHLC",
params={"pair": symbol, "interval": self._interval(timeframe)},
)
response.raise_for_status()
result = self._result(response.json())
rows = next((value for key, value in result.items() if key != "last"), [])
candles = [
Candle(
ts=int(row[0]) * 1000,
open=float(row[1]),
high=float(row[2]),
low=float(row[3]),
close=float(row[4]),
volume=float(row[6]),
)
for row in rows
]
return candles[-limit:]
async def fetch_price(self, symbol: str) -> float:
response = await self._client.get("/0/public/Ticker", params={"pair": symbol})
response.raise_for_status()
result = self._result(response.json())
ticker = next(iter(result.values()))
return float(ticker["c"][0])
async def close(self) -> None:
await self._client.aclose()
+1
View File
@@ -45,6 +45,7 @@ class StrategyConfig(Base):
__tablename__ = "strategy_configs" __tablename__ = "strategy_configs"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(80), default="")
strategy_key: Mapped[str] = mapped_column(String(64), index=True) strategy_key: Mapped[str] = mapped_column(String(64), index=True)
symbol: Mapped[str] = mapped_column(String(32)) symbol: Mapped[str] = mapped_column(String(32))
timeframe: Mapped[str] = mapped_column(String(16)) timeframe: Mapped[str] = mapped_column(String(16))
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from contextlib import suppress
import httpx
from app.config import Settings
logger = logging.getLogger(__name__)
class Notifier(ABC):
@abstractmethod
async def send(self, level: str, message: str) -> None:
raise NotImplementedError
async def close(self) -> None:
return None
class TelegramNotifier(Notifier):
def __init__(
self,
token: str,
chat_id: str,
base_url: str = "https://api.telegram.org",
timeout: float = 10.0,
) -> None:
self.token = token
self.chat_id = chat_id
self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout)
async def send(self, level: str, message: str) -> None:
response = await self._client.post(
f"/bot{self.token}/sendMessage",
json={
"chat_id": self.chat_id,
"text": f"[{level.upper()}] {message}",
"disable_web_page_preview": True,
},
)
response.raise_for_status()
async def close(self) -> None:
await self._client.aclose()
class WebhookNotifier(Notifier):
def __init__(self, url: str, timeout: float = 10.0) -> None:
self.url = url
self._client = httpx.AsyncClient(timeout=timeout)
async def send(self, level: str, message: str) -> None:
response = await self._client.post(self.url, json={"level": level, "text": message})
response.raise_for_status()
async def close(self) -> None:
await self._client.aclose()
class FanoutNotifier(Notifier):
"""Sends to every configured channel, never letting one failure block others."""
def __init__(self, notifiers: list[Notifier]) -> None:
self.notifiers = [notifier for notifier in notifiers if notifier is not None]
async def send(self, level: str, message: str) -> None:
for notifier in self.notifiers:
try:
await notifier.send(level, message)
except Exception as exc: # notifications must never break trading
logger.warning("notification via %s failed: %s", type(notifier).__name__, exc)
async def close(self) -> None:
for notifier in self.notifiers:
with suppress(Exception):
await notifier.close()
def build_notifier(settings: Settings) -> Notifier | None:
notifiers: list[Notifier] = []
if settings.telegram_bot_token and settings.telegram_chat_id:
notifiers.append(TelegramNotifier(settings.telegram_bot_token, settings.telegram_chat_id))
if settings.notify_webhook_url:
notifiers.append(WebhookNotifier(settings.notify_webhook_url))
if not notifiers:
return None
return FanoutNotifier(notifiers)
+32
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
from collections.abc import Callable from collections.abc import Callable
from contextlib import suppress from contextlib import suppress
from typing import Any from typing import Any
@@ -13,11 +14,15 @@ from app.engine.engine import TradingEngine
from app.engine.strategies import get_strategy from app.engine.strategies import get_strategy
from app.market import build_market_data from app.market import build_market_data
from app.market.base import MarketData from app.market.base import MarketData
from app.notify import Notifier, build_notifier
from app.schemas import BacktestRequest, BacktestResult, StatusOut from app.schemas import BacktestRequest, BacktestResult, StatusOut
from app.store import Store from app.store import Store
logger = logging.getLogger(__name__)
FeedFactory = Callable[[Settings], MarketData] FeedFactory = Callable[[Settings], MarketData]
BrokerFactory = Callable[[Settings], Broker] BrokerFactory = Callable[[Settings], Broker]
NotifierFactory = Callable[[Settings], Notifier | None]
class Runtime: class Runtime:
@@ -30,13 +35,19 @@ class Runtime:
*, *,
feed_factory: FeedFactory = build_market_data, feed_factory: FeedFactory = build_market_data,
broker_factory: BrokerFactory = build_broker, broker_factory: BrokerFactory = build_broker,
notifier: Notifier | None = None,
notifier_factory: NotifierFactory = build_notifier,
notify_levels: set[str] | None = None,
) -> None: ) -> None:
self.settings = settings self.settings = settings
self.store = store or Store() self.store = store or Store()
self._feed_factory = feed_factory self._feed_factory = feed_factory
self._broker_factory = broker_factory self._broker_factory = broker_factory
self.notifier = notifier if notifier is not None else notifier_factory(settings)
self.notify_levels = notify_levels or {"trade", "error"}
self.engine: TradingEngine | None = None self.engine: TradingEngine | None = None
self._subscribers: set[asyncio.Queue] = set() self._subscribers: set[asyncio.Queue] = set()
self._notify_tasks: set[asyncio.Task] = set()
# --- pub/sub ------------------------------------------------------ # --- pub/sub ------------------------------------------------------
def subscribe(self) -> asyncio.Queue: def subscribe(self) -> asyncio.Queue:
@@ -54,6 +65,19 @@ class Runtime:
queue.get_nowait() queue.get_nowait()
with suppress(asyncio.QueueFull): with suppress(asyncio.QueueFull):
queue.put_nowait(payload) queue.put_nowait(payload)
if payload.get("type") == "event":
self._maybe_notify(payload["data"])
def _maybe_notify(self, event: dict[str, Any]) -> None:
if self.notifier is None or event.get("level") not in self.notify_levels:
return
message = event.get("message", "")
try:
task = asyncio.create_task(self.notifier.send(event["level"], message))
except RuntimeError: # no running loop (e.g. sync test context)
return
self._notify_tasks.add(task)
task.add_done_callback(self._notify_tasks.discard)
# --- lifecycle ---------------------------------------------------- # --- lifecycle ----------------------------------------------------
async def start( async def start(
@@ -90,6 +114,12 @@ class Runtime:
return self.engine.status() return self.engine.status()
return self._idle_status() return self._idle_status()
async def kill(self, reason: str = "manual kill switch") -> StatusOut:
if self.engine is not None:
await self.engine.kill(reason)
return self.engine.status()
return self._idle_status()
async def run_backtest(self, request: BacktestRequest) -> BacktestResult: async def run_backtest(self, request: BacktestRequest) -> BacktestResult:
symbol = request.symbol or self.settings.symbol symbol = request.symbol or self.settings.symbol
timeframe = request.timeframe or self.settings.timeframe timeframe = request.timeframe or self.settings.timeframe
@@ -112,6 +142,8 @@ class Runtime:
async def shutdown(self) -> None: async def shutdown(self) -> None:
if self.engine is not None: if self.engine is not None:
await self.engine.shutdown() await self.engine.shutdown()
if self.notifier is not None:
await self.notifier.close()
def status(self) -> StatusOut: def status(self) -> StatusOut:
if self.engine is not None: if self.engine is not None:
+29
View File
@@ -103,6 +103,35 @@ class StatusOut(BaseModel):
open_positions: int open_positions: int
last_tick: datetime | None last_tick: datetime | None
error: str | None = None error: str | None = None
halted: bool = False
halt_reason: str | None = None
trades_today: int = 0
day_pnl: float = 0.0
drawdown_pct: float = 0.0
class KillRequest(BaseModel):
reason: str = "manual kill switch"
class StrategyConfigIn(BaseModel):
name: str = Field(default="", max_length=80)
strategy_key: str
symbol: str
timeframe: str
params: dict[str, Any] = Field(default_factory=dict)
class StrategyConfigOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
strategy_key: str
symbol: str
timeframe: str
params: dict[str, Any]
created_at: datetime
class StartRequest(BaseModel): class StartRequest(BaseModel):
+27 -2
View File
@@ -162,12 +162,37 @@ class Store:
return list(reversed(rows)) return list(reversed(rows))
def save_strategy_config( def save_strategy_config(
self, strategy_key: str, symbol: str, timeframe: str, params: dict self,
*,
name: str,
strategy_key: str,
symbol: str,
timeframe: str,
params: dict,
) -> StrategyConfig: ) -> StrategyConfig:
with self._session_factory() as session: with self._session_factory() as session:
config = StrategyConfig( config = StrategyConfig(
strategy_key=strategy_key, symbol=symbol, timeframe=timeframe, params=params name=name,
strategy_key=strategy_key,
symbol=symbol,
timeframe=timeframe,
params=params,
) )
session.add(config) session.add(config)
session.commit() session.commit()
return config return config
def list_strategy_configs(self) -> list[StrategyConfig]:
with self._session_factory() as session:
return list(
session.scalars(select(StrategyConfig).order_by(StrategyConfig.id.desc())).all()
)
def delete_strategy_config(self, config_id: int) -> bool:
with self._session_factory() as session:
config = session.get(StrategyConfig, config_id)
if config is None:
return False
session.delete(config)
session.commit()
return True
+67
View File
@@ -76,6 +76,73 @@ def test_backtest_limit_validation(runtime: Runtime) -> None:
) )
def test_kill_endpoint(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
client.post("/api/bot/start", json={"strategy_key": "sma_cross"})
killed = client.post("/api/bot/kill", json={"reason": "test kill"}).json()
assert killed["running"] is False
assert killed["halted"] is True
assert killed["halt_reason"] == "test kill"
def test_status_exposes_risk_fields(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
status = client.get("/api/status").json()
assert {"halted", "halt_reason", "trades_today", "day_pnl", "drawdown_pct"} <= set(status)
def test_preset_crud(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
created = client.post(
"/api/presets",
json={
"name": "My SMA",
"strategy_key": "sma_cross",
"symbol": "BTC/USDT",
"timeframe": "1h",
"params": {"fast": 5, "slow": 20},
},
)
assert created.status_code == 200
preset = created.json()
assert preset["name"] == "My SMA"
listed = client.get("/api/presets").json()
assert any(p["id"] == preset["id"] for p in listed)
assert client.delete(f"/api/presets/{preset['id']}").status_code == 200
assert client.get("/api/presets").json() == []
def test_preset_unknown_strategy_404(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
response = client.post(
"/api/presets",
json={
"strategy_key": "nope",
"symbol": "BTC/USDT",
"timeframe": "1m",
"params": {},
},
)
assert response.status_code == 404
def test_api_token_enforced(runtime: Runtime) -> None:
runtime.settings.api_token = "secret"
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
assert client.get("/api/status").status_code == 401
ok = client.get("/api/status", headers={"X-API-Token": "secret"})
assert ok.status_code == 200
# Health stays open.
assert client.get("/api/health").status_code == 200
def test_websocket_sends_initial_status(runtime: Runtime) -> None: def test_websocket_sends_initial_status(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime) app = create_app(runtime.settings, runtime)
with TestClient(app) as client, client.websocket_connect("/api/ws") as websocket: with TestClient(app) as client, client.websocket_connect("/api/ws") as websocket:
+64
View File
@@ -7,6 +7,7 @@ import pytest
from app.broker.paper import PaperBroker from app.broker.paper import PaperBroker
from app.config import Settings from app.config import Settings
from app.engine.engine import TradingEngine from app.engine.engine import TradingEngine
from app.engine.risk import RiskManager # noqa: F401 (documents the dependency)
from app.engine.strategies.base import Strategy, StrategyContext from app.engine.strategies.base import Strategy, StrategyContext
from app.market.fake import FakeMarketData from app.market.fake import FakeMarketData
from app.models import OrderSide, PositionSide from app.models import OrderSide, PositionSide
@@ -94,6 +95,69 @@ async def test_take_profit_exits_at_level(settings: Settings, store: Store) -> N
assert engine.realized_pnl == pytest.approx(10.0) assert engine.realized_pnl == pytest.approx(10.0)
async def test_daily_loss_halts_and_flattens(settings: Settings, store: Store) -> None:
settings.max_daily_loss = 50.0
market = FakeMarketData.from_closes([100.0])
engine = build_engine(settings, store, market)
engine.symbol = "BTC/USDT"
engine.last_price = 100.0
engine.risk.reset(10_000)
await engine.manual_order(OrderSide.BUY, qty=1.0, quote_amount=None, reason="test")
market.candles[0] = Candle(ts=0, open=100, high=100, low=40, close=40, volume=1)
await engine.tick()
assert engine.risk.halted is True
assert engine.position is None
assert engine.running is False
async def test_kill_switch_flattens_and_stops(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
await engine.kill("panic")
assert engine.running is False
assert engine.risk.halted is True
assert engine.position is None
assert engine.status().halt_reason == "panic"
async def test_max_trades_per_day_blocks_new_entries(settings: Settings, store: Store) -> None:
settings.max_trades_per_day = 1
market = FakeMarketData.from_closes([100.0])
engine = build_engine(settings, store, market)
engine.symbol = "BTC/USDT"
engine.last_price = 100.0
engine.risk.reset(10_000)
await engine.manual_order(OrderSide.BUY, qty=1.0, quote_amount=None, reason="first")
assert engine.position is not None
await engine.close_current_position()
await engine.manual_order(OrderSide.BUY, qty=1.0, quote_amount=None, reason="second")
assert engine.position is None
async def test_max_position_pct_clamps_size(settings: Settings, store: Store) -> None:
settings.max_position_pct = 0.5
market = FakeMarketData.from_closes([100.0])
engine = build_engine(settings, store, market)
engine.symbol = "BTC/USDT"
engine.last_price = 100.0
engine.risk.reset(10_000)
await engine._open_position(
PositionSide.LONG, size=1.0, stop_loss=None, take_profit=None, reason="big"
)
assert engine.position is not None
assert engine.position.qty == pytest.approx(50.0)
async def test_tick_runs_strategy_and_opens_position(settings: Settings, store: Store) -> None: async def test_tick_runs_strategy_and_opens_position(settings: Settings, store: Store) -> None:
market = FakeMarketData.from_closes([100.0, 100.0]) market = FakeMarketData.from_closes([100.0, 100.0])
engine = build_engine(settings, store, market) engine = build_engine(settings, store, market)
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
import pytest
from app.config import Settings
from app.market.coinbase import CoinbaseMarketData, to_product_id
from app.market.kraken import KrakenMarketData
def test_symbol_mapping() -> None:
assert to_product_id("BTC/USDT") == "BTC-USDT"
assert to_product_id("eth/usd") == "ETH-USD"
def test_kraken_interval_mapping() -> None:
assert KrakenMarketData._interval("1h") == 60
assert KrakenMarketData._interval("1d") == 1440
with pytest.raises(ValueError):
KrakenMarketData._interval("2m")
def test_coinbase_granularity_mapping() -> None:
assert CoinbaseMarketData._granularity("1m") == 60
assert CoinbaseMarketData._granularity("1d") == 86400
with pytest.raises(ValueError):
CoinbaseMarketData._granularity("3m")
def test_build_market_data_supports_exchanges() -> None:
from app.market import build_market_data
assert type(build_market_data(Settings(exchange="binance"))).__name__ == "BinanceMarketData"
assert type(build_market_data(Settings(exchange="kraken"))).__name__ == "KrakenMarketData"
assert type(build_market_data(Settings(exchange="coinbase"))).__name__ == "CoinbaseMarketData"
def test_build_market_data_rejects_unknown() -> None:
from app.market import build_market_data
with pytest.raises(ValueError, match="unsupported exchange"):
build_market_data(Settings(exchange="ftx"))
def test_build_broker_paper_default() -> None:
from app.broker import build_broker
from app.broker.paper import PaperBroker
assert isinstance(build_broker(Settings(mode="paper", exchange="kraken")), PaperBroker)
def test_build_broker_live_unsupported_exchange() -> None:
from app.broker import build_broker
with pytest.raises(ValueError, match="not supported"):
build_broker(Settings(mode="live", exchange="kraken"))
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
from sqlalchemy import create_engine, inspect, text
from app.db import _apply_additive_columns
def test_additive_columns_added_to_legacy_table() -> None:
engine = create_engine("sqlite://")
with engine.begin() as connection:
connection.execute(
text(
"CREATE TABLE strategy_configs ("
"id INTEGER PRIMARY KEY, strategy_key VARCHAR(64), "
"symbol VARCHAR(32), timeframe VARCHAR(16), params JSON, "
"created_at DATETIME)"
)
)
_apply_additive_columns(engine)
columns = {col["name"] for col in inspect(engine).get_columns("strategy_configs")}
assert "name" in columns
def test_additive_columns_is_idempotent() -> None:
engine = create_engine("sqlite://")
with engine.begin() as connection:
connection.execute(
text(
"CREATE TABLE strategy_configs ("
"id INTEGER PRIMARY KEY, name VARCHAR(80), strategy_key VARCHAR(64), "
"symbol VARCHAR(32), timeframe VARCHAR(16), params JSON, "
"created_at DATETIME)"
)
)
_apply_additive_columns(engine)
_apply_additive_columns(engine)
columns = [col["name"] for col in inspect(engine).get_columns("strategy_configs")]
assert columns.count("name") == 1
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import asyncio
from app.config import Settings
from app.notify import FanoutNotifier, Notifier, WebhookNotifier, build_notifier
from app.runtime import Runtime
class RecordingNotifier(Notifier):
def __init__(self, name: str = "rec", fail: bool = False) -> None:
self.name = name
self.fail = fail
self.messages: list[tuple[str, str]] = []
async def send(self, level: str, message: str) -> None:
if self.fail:
raise RuntimeError("boom")
self.messages.append((level, message))
async def test_fanout_sends_to_all() -> None:
first = RecordingNotifier("a")
second = RecordingNotifier("b")
await FanoutNotifier([first, second]).send("trade", "hello")
assert first.messages == [("trade", "hello")]
assert second.messages == [("trade", "hello")]
async def test_fanout_survives_a_failing_channel() -> None:
bad = RecordingNotifier("bad", fail=True)
good = RecordingNotifier("good")
await FanoutNotifier([bad, good]).send("error", "still delivered")
assert good.messages == [("error", "still delivered")]
def test_build_notifier_none_without_config() -> None:
assert build_notifier(Settings(telegram_bot_token="", notify_webhook_url="")) is None
def test_build_notifier_telegram_requires_both_fields() -> None:
assert build_notifier(Settings(telegram_bot_token="t", telegram_chat_id="")) is None
notifier = build_notifier(Settings(telegram_bot_token="t", telegram_chat_id="c"))
assert notifier is not None
def test_build_notifier_webhook() -> None:
notifier = build_notifier(Settings(notify_webhook_url="https://example.com/hook"))
assert isinstance(notifier, FanoutNotifier)
assert any(isinstance(n, WebhookNotifier) for n in notifier.notifiers)
async def test_runtime_notifies_on_trade_events() -> None:
recorder = RecordingNotifier()
settings = Settings(telegram_bot_token="", notify_webhook_url="")
runtime = Runtime(settings, notifier=recorder)
runtime._broadcast({"type": "event", "data": {"level": "trade", "message": "opened long"}})
await asyncio.gather(*list(runtime._notify_tasks))
assert recorder.messages == [("trade", "opened long")]
async def test_runtime_ignores_info_events() -> None:
recorder = RecordingNotifier()
settings = Settings(telegram_bot_token="", notify_webhook_url="")
runtime = Runtime(settings, notifier=recorder)
runtime._broadcast({"type": "event", "data": {"level": "info", "message": "started"}})
await asyncio.gather(*list(runtime._notify_tasks))
assert recorder.messages == []
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import pytest
from app.engine.risk import RiskManager
def test_daily_loss_detects_and_halts() -> None:
risk = RiskManager(max_daily_loss=100.0)
risk.reset(10_000)
assert risk.update(9_950) is None
reason = risk.update(9_900)
assert reason is not None and "daily loss" in reason
assert risk.halted is False # detection only; caller decides
risk.halt(reason)
assert risk.halted is True
assert risk.can_enter() is False
def test_drawdown_halts() -> None:
risk = RiskManager(max_drawdown_pct=10.0)
risk.reset(10_000)
risk.update(11_000)
assert risk.update(10_000) is None
reason = risk.update(9_850)
assert reason is not None and "drawdown" in reason
risk.halt(reason)
assert risk.halted
def test_halt_is_sticky() -> None:
risk = RiskManager(max_daily_loss=100.0)
risk.reset(10_000)
reason = risk.update(9_800)
risk.halt(reason or "loss")
assert risk.halted
# A recovery must not silently resume trading.
assert risk.update(10_500) is not None
assert risk.halted
def test_max_trades_per_day() -> None:
risk = RiskManager(max_trades_per_day=2)
risk.reset(10_000)
assert risk.can_enter()
risk.record_entry()
risk.record_entry()
assert risk.can_enter() is False
assert "max trades" in (risk.entry_block_reason() or "")
def test_clamp_size() -> None:
risk = RiskManager(max_position_pct=0.25)
assert risk.clamp_size(1.0) == pytest.approx(0.25)
assert risk.clamp_size(0.1) == pytest.approx(0.1)
assert risk.clamp_size(-1.0) == pytest.approx(0.0)
def test_disabled_limits_never_halt() -> None:
risk = RiskManager()
risk.reset(10_000)
assert risk.update(1.0) is None
assert risk.can_enter()
+13 -1
View File
@@ -22,6 +22,15 @@ services:
- EXCHANGE_API_KEY=${EXCHANGE_API_KEY:-} - EXCHANGE_API_KEY=${EXCHANGE_API_KEY:-}
- EXCHANGE_API_SECRET=${EXCHANGE_API_SECRET:-} - EXCHANGE_API_SECRET=${EXCHANGE_API_SECRET:-}
- EXCHANGE_SANDBOX=${EXCHANGE_SANDBOX:-false} - EXCHANGE_SANDBOX=${EXCHANGE_SANDBOX:-false}
- MAX_DAILY_LOSS=${MAX_DAILY_LOSS:-0}
- MAX_DRAWDOWN_PCT=${MAX_DRAWDOWN_PCT:-0}
- MAX_POSITION_PCT=${MAX_POSITION_PCT:-1.0}
- MAX_TRADES_PER_DAY=${MAX_TRADES_PER_DAY:-0}
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
- NOTIFY_WEBHOOK_URL=${NOTIFY_WEBHOOK_URL:-}
- API_TOKEN=${API_TOKEN:-}
- API_TOKEN_HEADER=${API_TOKEN_HEADER:-X-API-Token}
volumes: volumes:
- ./data:/data - ./data:/data
ports: ports:
@@ -29,9 +38,12 @@ services:
frontend: frontend:
build: ./frontend build: ./frontend
container_name: crypto-trading-frontend-local container_name: crypto-trader-frontend-local
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- backend - backend
environment:
- API_TOKEN=${API_TOKEN:-}
- API_TOKEN_HEADER=${API_TOKEN_HEADER:-X-API-Token}
ports: ports:
- "8080:80" - "8080:80"
+12
View File
@@ -32,6 +32,15 @@ services:
- EXCHANGE_API_KEY=${CRYPTO_EXCHANGE_API_KEY:-} - EXCHANGE_API_KEY=${CRYPTO_EXCHANGE_API_KEY:-}
- EXCHANGE_API_SECRET=${CRYPTO_EXCHANGE_API_SECRET:-} - EXCHANGE_API_SECRET=${CRYPTO_EXCHANGE_API_SECRET:-}
- EXCHANGE_SANDBOX=${CRYPTO_EXCHANGE_SANDBOX:-false} - EXCHANGE_SANDBOX=${CRYPTO_EXCHANGE_SANDBOX:-false}
- MAX_DAILY_LOSS=${CRYPTO_MAX_DAILY_LOSS:-0}
- MAX_DRAWDOWN_PCT=${CRYPTO_MAX_DRAWDOWN_PCT:-0}
- MAX_POSITION_PCT=${CRYPTO_MAX_POSITION_PCT:-1.0}
- MAX_TRADES_PER_DAY=${CRYPTO_MAX_TRADES_PER_DAY:-0}
- TELEGRAM_BOT_TOKEN=${CRYPTO_TELEGRAM_BOT_TOKEN:-}
- TELEGRAM_CHAT_ID=${CRYPTO_TELEGRAM_CHAT_ID:-}
- NOTIFY_WEBHOOK_URL=${CRYPTO_NOTIFY_WEBHOOK_URL:-}
- API_TOKEN=${CRYPTO_API_TOKEN:-}
- API_TOKEN_HEADER=${CRYPTO_API_TOKEN_HEADER:-X-API-Token}
volumes: volumes:
# Persist the SQLite database across releases. Prefer an absolute host # Persist the SQLite database across releases. Prefer an absolute host
# path (CRYPTO_DATA_DIR / PERSISTENT_MOUNT); the repo-relative default # path (CRYPTO_DATA_DIR / PERSISTENT_MOUNT); the repo-relative default
@@ -63,6 +72,9 @@ services:
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
environment:
- API_TOKEN=${CRYPTO_API_TOKEN:-}
- API_TOKEN_HEADER=${CRYPTO_API_TOKEN_HEADER:-X-API-Token}
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:80/ || exit 1"] test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:80/ || exit 1"]
interval: 30s interval: 30s
+8 -2
View File
@@ -2,11 +2,17 @@ FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci --no-audit --no-fund
COPY . . COPY . .
RUN npm run build RUN npm run build
FROM nginx:alpine FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Defaults; override at runtime. The nginx entrypoint runs envsubst over
# /etc/nginx/templates/*.template, so only these referenced vars are replaced.
ENV API_TOKEN_HEADER=X-API-Token
ENV API_TOKEN=
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/templates/default.conf.template
EXPOSE 80 EXPOSE 80
+3
View File
@@ -16,5 +16,8 @@ server {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s; proxy_read_timeout 3600s;
# Inject the optional API token server-side so the browser never sees it
# and the WebUI keeps working without any client changes.
proxy_set_header ${API_TOKEN_HEADER} "${API_TOKEN}";
} }
} }
+45 -1
View File
@@ -28,6 +28,7 @@ const status = ref({
}) })
const strategies = ref([]) const strategies = ref([])
const presets = ref([])
const positions = ref([]) const positions = ref([])
const orders = ref([]) const orders = ref([])
const events = ref([]) const events = ref([])
@@ -55,6 +56,20 @@ async function loadStrategies() {
strategies.value = await api.getStrategies() strategies.value = await api.getStrategies()
} }
async function loadPresets() {
presets.value = await api.getPresets()
}
async function handleSavePreset(payload) {
await api.createPreset(payload)
await loadPresets()
}
async function handleDeletePreset(id) {
await api.deletePreset(id)
await loadPresets()
}
async function handleStart(payload) { async function handleStart(payload) {
busy.value = true busy.value = true
try { try {
@@ -77,6 +92,18 @@ async function handleStop() {
} }
} }
async function handleKill() {
busy.value = true
try {
status.value = await api.killBot('manual kill switch')
await refreshAll()
} catch (error) {
status.value = { ...status.value, error: error.response?.data?.detail || error.message }
} finally {
busy.value = false
}
}
async function handleClose(id) { async function handleClose(id) {
try { try {
status.value = await api.closePosition(id) status.value = await api.closePosition(id)
@@ -96,7 +123,12 @@ function handleMessage(message) {
} }
onMounted(async () => { onMounted(async () => {
await Promise.all([loadStrategies(), api.getStatus().then((s) => (status.value = s)), refreshAll()]) await Promise.all([
loadStrategies(),
loadPresets(),
api.getStatus().then((s) => (status.value = s)),
refreshAll(),
])
socket = api.openSocket({ socket = api.openSocket({
onMessage: handleMessage, onMessage: handleMessage,
onOpen: () => (connected.value = true), onOpen: () => (connected.value = true),
@@ -137,6 +169,14 @@ onUnmounted(() => {
</div> </div>
</header> </header>
<div
v-if="status.halted"
class="rounded-lg border border-rose-500 bg-rose-500/20 p-3 text-sm font-semibold text-rose-100"
>
HALTED — {{ status.halt_reason }}. Trading is stopped and any open position was
flattened. Restart the bot to resume (limits reset).
</div>
<div v-if="status.error" class="rounded-lg border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200"> <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 }} {{ status.error }}
</div> </div>
@@ -147,8 +187,12 @@ onUnmounted(() => {
:strategies="strategies" :strategies="strategies"
:status="status" :status="status"
:busy="busy" :busy="busy"
:presets="presets"
@start="handleStart" @start="handleStart"
@stop="handleStop" @stop="handleStop"
@kill="handleKill"
@save-preset="handleSavePreset"
@delete-preset="handleDeletePreset"
/> />
<BacktestPanel :strategies="strategies" :status="status" /> <BacktestPanel :strategies="strategies" :status="status" />
+6
View File
@@ -11,12 +11,18 @@ 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 startBot = (payload) => http.post('/bot/start', payload).then((r) => r.data)
export const stopBot = () => http.post('/bot/stop').then((r) => r.data) export const stopBot = () => http.post('/bot/stop').then((r) => r.data)
export const killBot = (reason = 'manual kill switch') =>
http.post('/bot/kill', { reason }).then((r) => r.data)
export const closePosition = (id, reason = 'manual') => export const closePosition = (id, reason = 'manual') =>
http.post(`/positions/${id}/close`, { reason }).then((r) => r.data) http.post(`/positions/${id}/close`, { reason }).then((r) => r.data)
export const placeOrder = (payload) => http.post('/orders', payload).then((r) => r.data) export const placeOrder = (payload) => http.post('/orders', payload).then((r) => r.data)
export const runBacktest = (payload) => export const runBacktest = (payload) =>
http.post('/backtest', payload, { timeout: 60000 }).then((r) => r.data) http.post('/backtest', payload, { timeout: 60000 }).then((r) => r.data)
export const getPresets = () => http.get('/presets').then((r) => r.data)
export const createPreset = (payload) => http.post('/presets', payload).then((r) => r.data)
export const deletePreset = (id) => http.delete(`/presets/${id}`).then((r) => r.data)
export function openSocket({ onMessage, onOpen, onClose }) { export function openSocket({ onMessage, onOpen, onClose }) {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
let socket let socket
+1
View File
@@ -8,6 +8,7 @@ defineProps({
const levelClass = { const levelClass = {
info: 'text-slate-300', info: 'text-slate-300',
trade: 'text-sky-300', trade: 'text-sky-300',
warning: 'text-amber-300',
error: 'text-rose-300', error: 'text-rose-300',
} }
</script> </script>
+8 -1
View File
@@ -19,11 +19,18 @@ const cards = computed(() => [
}, },
{ label: 'Cash', value: fmtMoney(props.status.cash), cls: 'text-slate-100' }, { label: 'Cash', value: fmtMoney(props.status.cash), cls: 'text-slate-100' },
{ label: 'Price', value: fmtPrice(props.status.price), cls: 'text-slate-100' }, { label: 'Price', value: fmtPrice(props.status.price), cls: 'text-slate-100' },
{ label: 'Day P&L', value: signed(props.status.day_pnl), cls: pnlClass(props.status.day_pnl) },
{ label: 'Drawdown', value: `${fmtMoney(props.status.drawdown_pct)}%`, cls: 'text-amber-300' },
{
label: 'Trades today',
value: String(props.status.trades_today ?? 0),
cls: 'text-slate-100',
},
]) ])
</script> </script>
<template> <template>
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6"> <div class="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-5">
<div v-for="card in cards" :key="card.label" class="card"> <div v-for="card in cards" :key="card.label" class="card">
<div class="label">{{ card.label }}</div> <div class="label">{{ card.label }}</div>
<div class="value" :class="card.cls">{{ card.value }}</div> <div class="value" :class="card.cls">{{ card.value }}</div>
+62 -11
View File
@@ -9,13 +9,45 @@ const props = defineProps({
submitClass: { type: String, default: 'btn-primary' }, submitClass: { type: String, default: 'btn-primary' },
symbol: { type: String, default: 'BTC/USDT' }, symbol: { type: String, default: 'BTC/USDT' },
timeframe: { type: String, default: '1m' }, timeframe: { type: String, default: '1m' },
presets: { type: Array, default: () => [] },
}) })
const emit = defineEmits(['submit']) const emit = defineEmits(['submit', 'save-preset', 'delete-preset', 'load-preset'])
const selectedKey = ref('') const selectedKey = ref('')
const values = ref({}) const values = ref({})
const symbolValue = ref(props.symbol) const symbolValue = ref(props.symbol)
const timeframeValue = ref(props.timeframe) const timeframeValue = ref(props.timeframe)
const presetName = ref('')
function loadPreset(event) {
const preset = props.presets.find((p) => p.id === Number(event.target.value))
if (!preset) return
selectedKey.value = preset.strategy_key
symbolValue.value = preset.symbol
timeframeValue.value = preset.timeframe
values.value = { ...preset.params }
emit('load-preset', preset)
event.target.value = ''
}
function savePreset() {
if (!selected.value) return
emit('save-preset', { name: presetName.value, ...buildPayload() })
presetName.value = ''
}
function buildPayload() {
const params = {}
for (const param of selected.value.params) {
params[param.name] = coerce(param, values.value[param.name])
}
return {
strategy_key: selectedKey.value,
params,
symbol: symbolValue.value,
timeframe: timeframeValue.value,
}
}
const selected = computed(() => props.strategies.find((s) => s.key === selectedKey.value)) const selected = computed(() => props.strategies.find((s) => s.key === selectedKey.value))
@@ -58,16 +90,7 @@ function coerce(param, raw) {
} }
function submit() { function submit() {
const params = {} emit('submit', buildPayload())
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> </script>
@@ -115,6 +138,34 @@ function submit() {
</label> </label>
</div> </div>
<div class="flex flex-wrap items-end gap-2 border-t border-edge/60 pt-3">
<label class="space-y-1">
<span class="label">Load preset</span>
<select class="input" :disabled="disabled" @change="loadPreset">
<option value="">—</option>
<option v-for="p in presets" :key="p.id" :value="p.id">
{{ p.name || `${p.strategy_key} #${p.id}` }}
</option>
</select>
</label>
<label class="space-y-1">
<span class="label">Save as</span>
<input v-model="presetName" class="input" placeholder="preset name" :disabled="disabled" />
</label>
<button class="btn-muted" :disabled="busy || disabled || !selectedKey" @click="savePreset">
Save preset
</button>
<button
v-if="presets.length"
class="btn-muted"
:disabled="busy || disabled"
@click="emit('delete-preset', presets[0].id)"
title="Delete the most recent preset"
>
Delete latest
</button>
</div>
<button :class="submitClass" :disabled="busy || disabled || !selectedKey" @click="submit"> <button :class="submitClass" :disabled="busy || disabled || !selectedKey" @click="submit">
{{ submitLabel }} {{ submitLabel }}
</button> </button>
+23 -4
View File
@@ -5,8 +5,15 @@ const props = defineProps({
strategies: { type: Array, required: true }, strategies: { type: Array, required: true },
status: { type: Object, required: true }, status: { type: Object, required: true },
busy: { type: Boolean, default: false }, busy: { type: Boolean, default: false },
presets: { type: Array, default: () => [] },
}) })
const emit = defineEmits(['start', 'stop']) const emit = defineEmits(['start', 'stop', 'kill', 'save-preset', 'delete-preset'])
function confirmKill() {
if (window.confirm('Kill switch: flatten the position and halt trading?')) {
emit('kill')
}
}
</script> </script>
<template> <template>
@@ -28,11 +35,23 @@ const emit = defineEmits(['start', 'stop'])
submit-label="Start bot" submit-label="Start bot"
:symbol="status.symbol" :symbol="status.symbol"
:timeframe="status.timeframe" :timeframe="status.timeframe"
:presets="presets"
@submit="emit('start', $event)" @submit="emit('start', $event)"
@save-preset="emit('save-preset', $event)"
@delete-preset="emit('delete-preset', $event)"
/> />
<button class="btn-danger" :disabled="busy || !status.running" @click="emit('stop')"> <div class="flex flex-wrap gap-2">
Stop bot <button class="btn-danger" :disabled="busy || !status.running" @click="emit('stop')">
</button> Stop bot
</button>
<button
class="btn bg-rose-700 font-bold uppercase text-white hover:bg-rose-600"
:disabled="busy || (!status.running && status.open_positions === 0)"
@click="confirmKill"
>
Kill switch
</button>
</div>
</div> </div>
</template> </template>