Risk controls, alerts, API token, more exchanges, presets #1
No files matched your search
@@ -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=*
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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]:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -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
|
||||||
@@ -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 == []
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
<button class="btn-danger" :disabled="busy || !status.running" @click="emit('stop')">
|
<button class="btn-danger" :disabled="busy || !status.running" @click="emit('stop')">
|
||||||
Stop bot
|
Stop bot
|
||||||
</button>
|
</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>
|
||||||
Reference in new issue
Block a user