From 03dfa369e53c900bee2045dbd2f14b6c5bae65e5 Mon Sep 17 00:00:00 2001 From: matitos Date: Fri, 18 Sep 2026 19:26:29 +0000 Subject: [PATCH] Risk controls, alerts, API token, more exchanges, presets - RiskManager halts + flattens on max daily loss / drawdown, caps position size and trades per day; adds a manual kill switch (POST /api/bot/kill). - Telegram and webhook notifications on trades, errors and halts. - Optional API bearer token on /api, injected server-side by the frontend nginx so the browser never sees it. - Kraken and Coinbase market data (public); Coinbase live broker. Live on an unsupported exchange now errors instead of silently falling back to paper. - Strategy presets backed by the StrategyConfig table (CRUD API + UI). - Idempotent additive-column migration so existing SQLite databases upgrade in place (fixes the new strategy_configs.name column). --- .env.example | 24 +++++ .gitea/workflows/deploy.yml | 12 +++ AGENTS.md | 47 +++++++++- README.md | 53 +++++++++++ backend/app/api/routes.py | 93 ++++++++++++++++--- backend/app/broker/__init__.py | 26 +++++- backend/app/broker/coinbase.py | 85 ++++++++++++++++++ backend/app/config.py | 20 +++++ backend/app/db.py | 27 +++++- backend/app/engine/engine.py | 51 ++++++++++- backend/app/engine/risk.py | 103 ++++++++++++++++++++++ backend/app/market/__init__.py | 26 ++++-- backend/app/market/coinbase.py | 72 +++++++++++++++ backend/app/market/kraken.py | 74 ++++++++++++++++ backend/app/models.py | 1 + backend/app/notify.py | 90 +++++++++++++++++++ backend/app/runtime.py | 32 +++++++ backend/app/schemas.py | 29 ++++++ backend/app/store.py | 29 +++++- backend/tests/test_api.py | 67 ++++++++++++++ backend/tests/test_engine.py | 64 ++++++++++++++ backend/tests/test_exchanges.py | 55 ++++++++++++ backend/tests/test_migrations.py | 42 +++++++++ backend/tests/test_notify.py | 68 ++++++++++++++ backend/tests/test_risk.py | 63 +++++++++++++ docker-compose.local.yml | 14 ++- docker-compose.yml | 12 +++ frontend/Dockerfile | 10 ++- frontend/nginx.conf | 3 + frontend/src/App.vue | 46 +++++++++- frontend/src/api.js | 6 ++ frontend/src/components/EventLog.vue | 1 + frontend/src/components/StatusCards.vue | 9 +- frontend/src/components/StrategyForm.vue | 73 ++++++++++++--- frontend/src/components/StrategyPanel.vue | 27 +++++- 35 files changed, 1409 insertions(+), 45 deletions(-) create mode 100644 backend/app/broker/coinbase.py create mode 100644 backend/app/engine/risk.py create mode 100644 backend/app/market/coinbase.py create mode 100644 backend/app/market/kraken.py create mode 100644 backend/app/notify.py create mode 100644 backend/tests/test_exchanges.py create mode 100644 backend/tests/test_migrations.py create mode 100644 backend/tests/test_notify.py create mode 100644 backend/tests/test_risk.py diff --git a/.env.example b/.env.example index b5c2bc5..ba3a8fb 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,30 @@ EXCHANGE_API_SECRET= # Set true for testnet/sandbox where supported 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/getUpdates after messaging the bot. +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +# Generic webhook: receives POST {"level": "...", "text": "..."} +NOTIFY_WEBHOOK_URL= + # --- Web --- # Comma separated list, or * in dev CORS_ORIGINS=* diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 1dd27e1..f8074d2 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -116,6 +116,18 @@ jobs: CRYPTO_EXCHANGE_API_KEY: ${{ secrets.CRYPTO_EXCHANGE_API_KEY }} CRYPTO_EXCHANGE_API_SECRET: ${{ secrets.CRYPTO_EXCHANGE_API_SECRET }} CRYPTO_EXCHANGE_SANDBOX: ${{ vars.CRYPTO_EXCHANGE_SANDBOX || 'false' }} + # 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_BURST: ${{ vars.CRYPTO_AUTH_RATE_LIMIT_BURST || '5' }} # Persistent data root: repo var wins, else derive from the org diff --git a/AGENTS.md b/AGENTS.md index b40cc50..00530e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,46 @@ candles you control and asserts the emitted intents. See - Portfolio invariant: `equity = cash ± qty * mark`, realized P&L accumulates on 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 `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 engine/ engine.py trading loop, portfolio, risk + risk.py RiskManager: limits, halt/kill portfolio.py shared cash/P&L/sizing/risk math (engine + backtest) context.py -> base.py (StrategyContext) lives in strategies/base.py indicators.py pure-python indicators strategies/ <-- add strategies here (auto-discovered) backtest/ historical replay + metrics - broker/ paper + binance brokers - market/ binance + fake data feeds + broker/ paper + binance + coinbase brokers + market/ binance + kraken + coinbase + fake feeds + notify.py Telegram + webhook alerting models.py/store.py SQLAlchemy models and persistence + db.py engine + additive column migrations frontend/src/ Vue dashboard (App.vue + components/) docker-compose.yml production (Traefik + docker_default) docker-compose.local.yml local, self-contained diff --git a/README.md b/README.md index 5405f58..8dc6caa 100644 --- a/README.md +++ b/README.md @@ -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_EXCHANGE_API_KEY` / `CRYPTO_EXCHANGE_API_SECRET` | secret | Required only for `live` | | `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.` | | `CRYPTO_DATA_DIR` / `PERSISTENT_MOUNT` | var | Host path for the SQLite DB | | `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://...` | | `EXCHANGE_API_KEY` / `EXCHANGE_API_SECRET` | — | Required only for live | | `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/getUpdates`. ## 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 | | `POST` | `/api/bot/start` | Start a strategy | | `POST` | `/api/bot/stop` | Stop the bot | +| `POST` | `/api/bot/kill` | Kill switch: flatten + halt | | `POST` | `/api/backtest` | Backtest a strategy on historical candles | | `GET` | `/api/positions` | Positions (with unrealized P&L when open) | | `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 | | `POST` | `/api/orders` | Manual market order | | `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 | ## Tests & lint diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 3e054b6..3dead4d 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -6,7 +6,8 @@ from typing import Annotated import httpx from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect -from app.engine.strategies import list_strategies, strategy_spec +from app.config import Settings +from app.engine.strategies import get_strategy, list_strategies, strategy_spec from app.models import OrderSide, Position, PositionStatus from app.runtime import Runtime from app.schemas import ( @@ -15,11 +16,14 @@ from app.schemas import ( ClosePositionRequest, EquityPointOut, EventOut, + KillRequest, ManualOrderRequest, OrderOut, PositionOut, StartRequest, StatusOut, + StrategyConfigIn, + StrategyConfigOut, StrategyOut, ) @@ -30,7 +34,26 @@ def get_runtime(request: Request) -> 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") @@ -44,12 +67,12 @@ def strategies() -> list[StrategyOut]: @router.get("/status", response_model=StatusOut) -def status(runtime: RuntimeDep) -> StatusOut: +def status(runtime: GuardedRuntimeDep) -> StatusOut: return runtime.status() @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: return await runtime.start( payload.strategy_key, @@ -65,12 +88,17 @@ async def start_bot(payload: StartRequest, runtime: RuntimeDep) -> 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() +@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) -async def run_backtest(payload: BacktestRequest, runtime: RuntimeDep) -> BacktestResult: +async def run_backtest(payload: BacktestRequest, runtime: GuardedRuntimeDep) -> BacktestResult: try: return await runtime.run_backtest(payload) 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 +@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: out = PositionOut.model_validate(position) 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]) -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 return [_position_out(position, price) for position in runtime.store.list_positions(limit)] @router.get("/orders", response_model=list[OrderOut]) -def orders(runtime: RuntimeDep, limit: int = 50) -> list[OrderOut]: +def orders(runtime: GuardedRuntimeDep, limit: int = 50) -> list[OrderOut]: return [OrderOut.model_validate(order) for order in runtime.store.list_orders(limit)] @router.get("/events", response_model=list[EventOut]) -def events(runtime: RuntimeDep, limit: int = 50) -> list[EventOut]: +def events(runtime: GuardedRuntimeDep, limit: int = 50) -> list[EventOut]: return [EventOut.model_validate(event) for event in runtime.store.list_events(limit)] @router.get("/equity", response_model=list[EquityPointOut]) -def equity(runtime: RuntimeDep, limit: int = 500) -> list[EquityPointOut]: +def equity(runtime: GuardedRuntimeDep, limit: int = 500) -> list[EquityPointOut]: return [EquityPointOut.model_validate(point) for point in runtime.store.list_equity(limit)] @router.post("/orders", response_model=StatusOut) -async def manual_order(payload: ManualOrderRequest, runtime: RuntimeDep) -> StatusOut: +async def manual_order(payload: ManualOrderRequest, runtime: GuardedRuntimeDep) -> StatusOut: engine = runtime.engine if engine is None or not engine.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) async def close_position( - position_id: int, payload: ClosePositionRequest, runtime: RuntimeDep + position_id: int, payload: ClosePositionRequest, runtime: GuardedRuntimeDep ) -> StatusOut: engine = runtime.engine if engine is None or not engine.running: @@ -136,6 +194,17 @@ async def close_position( @router.websocket("/ws") async def websocket_endpoint(websocket: WebSocket) -> None: 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() queue = runtime.subscribe() try: diff --git a/backend/app/broker/__init__.py b/backend/app/broker/__init__.py index 2c219cf..6813979 100644 --- a/backend/app/broker/__init__.py +++ b/backend/app/broker/__init__.py @@ -2,19 +2,41 @@ from __future__ import annotations from app.broker.base import Broker, Fill from app.broker.binance import TESTNET_URL, BinanceBroker +from app.broker.coinbase import CoinbaseBroker from app.broker.paper import PaperBroker from app.config import Settings 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: - 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( api_key=settings.exchange_api_key, api_secret=settings.exchange_api_secret, base_url=TESTNET_URL if settings.exchange_sandbox else BASE_URL, ) - return PaperBroker(fee_bps=settings.fee_bps, slippage_bps=settings.slippage_bps) + return CoinbaseBroker( + api_key=settings.exchange_api_key, + api_secret=settings.exchange_api_secret, + ) __all__ = ["Broker", "Fill", "PaperBroker", "build_broker"] diff --git a/backend/app/broker/coinbase.py b/backend/app/broker/coinbase.py new file mode 100644 index 0000000..b93a232 --- /dev/null +++ b/backend/app/broker/coinbase.py @@ -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() diff --git a/backend/app/config.py b/backend/app/config.py index 0665625..5d7ed56 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -39,6 +39,26 @@ class Settings(BaseSettings): exchange_api_secret: str = "" 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 = "*" @field_validator("mode") diff --git a/backend/app/db.py b/backend/app/db.py index 257a725..16b51d5 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -1,15 +1,25 @@ from __future__ import annotations +import logging 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 app.config import REPO_ROOT, get_settings from app.models import Base +logger = logging.getLogger(__name__) + _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]: 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) +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: Base.metadata.create_all(bind=engine) + _apply_additive_columns(engine) def get_session() -> Iterator[Session]: diff --git a/backend/app/engine/engine.py b/backend/app/engine/engine.py index a2625d3..6341a4e 100644 --- a/backend/app/engine/engine.py +++ b/backend/app/engine/engine.py @@ -17,6 +17,7 @@ from app.engine.portfolio import ( risk_exit, unrealized_pnl, ) +from app.engine.risk import RiskManager from app.engine.strategies import Strategy, get_strategy from app.engine.strategies.base import StrategyContext from app.market.base import MarketData @@ -61,6 +62,8 @@ class TradingEngine: self.symbol: str = settings.symbol 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._publish_cb: Publish | None = None @@ -94,6 +97,8 @@ class TradingEngine: self.last_price = None self.error = None self.last_tick = None + self.risk.reset(self.starting_balance) + self._entry_blocked_logged = False self.running = True 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 self.error = f"{type(exc).__name__}: {exc}" self._emit("error", f"tick failed: {self.error}") + if not self.running: + break await asyncio.sleep(self.settings.poll_interval) async def tick(self) -> None: @@ -139,6 +146,11 @@ class TradingEngine: self.last_tick = datetime.now(UTC) 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: await self._check_risk(candles[-1]) @@ -207,10 +219,17 @@ class TradingEngine: if not price or price <= 0: 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: qty = qty_override 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: return @@ -242,6 +261,7 @@ class TradingEngine: fee=fill.fee, reason=reason, ) + self.risk.record_entry() self._emit( "trade", f"Opened {side.value} {fill.qty:.6f} {self.symbol} @ {fill.price:.4f} ({reason})", @@ -309,6 +329,30 @@ class TradingEngine: self.last_price = level 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 ---------------------------------------------- async def manual_order( 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, last_tick=self.last_tick, 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: diff --git a/backend/app/engine/risk.py b/backend/app/engine/risk.py new file mode 100644 index 0000000..70155be --- /dev/null +++ b/backend/app/engine/risk.py @@ -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)) diff --git a/backend/app/market/__init__.py b/backend/app/market/__init__.py index e90e2b8..9da6d61 100644 --- a/backend/app/market/__init__.py +++ b/backend/app/market/__init__.py @@ -3,14 +3,30 @@ from __future__ import annotations from app.config import Settings from app.market.base import MarketData from app.market.binance import BinanceMarketData +from app.market.coinbase import CoinbaseMarketData 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: - exchange = settings.exchange.lower() - if exchange in {"binance", "binanceus"}: - return BinanceMarketData() - raise ValueError(f"unsupported exchange: {settings.exchange}") + try: + feed_cls = _FEEDS[settings.exchange.lower()] + except KeyError: + 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", +] diff --git a/backend/app/market/coinbase.py b/backend/app/market/coinbase.py new file mode 100644 index 0000000..b396d18 --- /dev/null +++ b/backend/app/market/coinbase.py @@ -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() diff --git a/backend/app/market/kraken.py b/backend/app/market/kraken.py new file mode 100644 index 0000000..2541d7e --- /dev/null +++ b/backend/app/market/kraken.py @@ -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() diff --git a/backend/app/models.py b/backend/app/models.py index c331a2f..25da094 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -45,6 +45,7 @@ class StrategyConfig(Base): __tablename__ = "strategy_configs" 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) symbol: Mapped[str] = mapped_column(String(32)) timeframe: Mapped[str] = mapped_column(String(16)) diff --git a/backend/app/notify.py b/backend/app/notify.py new file mode 100644 index 0000000..405b0b6 --- /dev/null +++ b/backend/app/notify.py @@ -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) diff --git a/backend/app/runtime.py b/backend/app/runtime.py index eaa473f..787b057 100644 --- a/backend/app/runtime.py +++ b/backend/app/runtime.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import logging from collections.abc import Callable from contextlib import suppress from typing import Any @@ -13,11 +14,15 @@ from app.engine.engine import TradingEngine from app.engine.strategies import get_strategy from app.market import build_market_data from app.market.base import MarketData +from app.notify import Notifier, build_notifier from app.schemas import BacktestRequest, BacktestResult, StatusOut from app.store import Store +logger = logging.getLogger(__name__) + FeedFactory = Callable[[Settings], MarketData] BrokerFactory = Callable[[Settings], Broker] +NotifierFactory = Callable[[Settings], Notifier | None] class Runtime: @@ -30,13 +35,19 @@ class Runtime: *, feed_factory: FeedFactory = build_market_data, broker_factory: BrokerFactory = build_broker, + notifier: Notifier | None = None, + notifier_factory: NotifierFactory = build_notifier, + notify_levels: set[str] | None = None, ) -> None: self.settings = settings self.store = store or Store() self._feed_factory = feed_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._subscribers: set[asyncio.Queue] = set() + self._notify_tasks: set[asyncio.Task] = set() # --- pub/sub ------------------------------------------------------ def subscribe(self) -> asyncio.Queue: @@ -54,6 +65,19 @@ class Runtime: queue.get_nowait() with suppress(asyncio.QueueFull): 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 ---------------------------------------------------- async def start( @@ -90,6 +114,12 @@ class Runtime: return self.engine.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: symbol = request.symbol or self.settings.symbol timeframe = request.timeframe or self.settings.timeframe @@ -112,6 +142,8 @@ class Runtime: async def shutdown(self) -> None: if self.engine is not None: await self.engine.shutdown() + if self.notifier is not None: + await self.notifier.close() def status(self) -> StatusOut: if self.engine is not None: diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e2895bc..d697918 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -103,6 +103,35 @@ class StatusOut(BaseModel): open_positions: int last_tick: datetime | 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): diff --git a/backend/app/store.py b/backend/app/store.py index 3653735..9141f14 100644 --- a/backend/app/store.py +++ b/backend/app/store.py @@ -162,12 +162,37 @@ class Store: return list(reversed(rows)) 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: with self._session_factory() as session: 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.commit() 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 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index e14da9e..4e00249 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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: app = create_app(runtime.settings, runtime) with TestClient(app) as client, client.websocket_connect("/api/ws") as websocket: diff --git a/backend/tests/test_engine.py b/backend/tests/test_engine.py index 8bbfc7a..f280c38 100644 --- a/backend/tests/test_engine.py +++ b/backend/tests/test_engine.py @@ -7,6 +7,7 @@ import pytest from app.broker.paper import PaperBroker from app.config import Settings 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.market.fake import FakeMarketData 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) +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: market = FakeMarketData.from_closes([100.0, 100.0]) engine = build_engine(settings, store, market) diff --git a/backend/tests/test_exchanges.py b/backend/tests/test_exchanges.py new file mode 100644 index 0000000..762b1d1 --- /dev/null +++ b/backend/tests/test_exchanges.py @@ -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")) diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py new file mode 100644 index 0000000..f56f493 --- /dev/null +++ b/backend/tests/test_migrations.py @@ -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 diff --git a/backend/tests/test_notify.py b/backend/tests/test_notify.py new file mode 100644 index 0000000..01fe5a6 --- /dev/null +++ b/backend/tests/test_notify.py @@ -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 == [] diff --git a/backend/tests/test_risk.py b/backend/tests/test_risk.py new file mode 100644 index 0000000..3b4a479 --- /dev/null +++ b/backend/tests/test_risk.py @@ -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() diff --git a/docker-compose.local.yml b/docker-compose.local.yml index b11a962..ad6fc68 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -22,6 +22,15 @@ services: - EXCHANGE_API_KEY=${EXCHANGE_API_KEY:-} - EXCHANGE_API_SECRET=${EXCHANGE_API_SECRET:-} - 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: - ./data:/data ports: @@ -29,9 +38,12 @@ services: frontend: build: ./frontend - container_name: crypto-trading-frontend-local + container_name: crypto-trader-frontend-local restart: unless-stopped depends_on: - backend + environment: + - API_TOKEN=${API_TOKEN:-} + - API_TOKEN_HEADER=${API_TOKEN_HEADER:-X-API-Token} ports: - "8080:80" diff --git a/docker-compose.yml b/docker-compose.yml index 530c7d8..4cd5ca8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,15 @@ services: - EXCHANGE_API_KEY=${CRYPTO_EXCHANGE_API_KEY:-} - EXCHANGE_API_SECRET=${CRYPTO_EXCHANGE_API_SECRET:-} - 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: # Persist the SQLite database across releases. Prefer an absolute host # path (CRYPTO_DATA_DIR / PERSISTENT_MOUNT); the repo-relative default @@ -63,6 +72,9 @@ services: depends_on: backend: condition: service_healthy + environment: + - API_TOKEN=${CRYPTO_API_TOKEN:-} + - API_TOKEN_HEADER=${CRYPTO_API_TOKEN_HEADER:-X-API-Token} healthcheck: test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:80/ || exit 1"] interval: 30s diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 3f97eb8..59e26cf 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -2,11 +2,17 @@ FROM node:22-alpine AS build WORKDIR /app COPY package.json package-lock.json ./ -RUN npm ci +RUN npm ci --no-audit --no-fund COPY . . RUN npm run build 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 nginx.conf /etc/nginx/templates/default.conf.template EXPOSE 80 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index d0631a7..33e6c70 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -16,5 +16,8 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; 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}"; } } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0872a75..1e5d824 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -28,6 +28,7 @@ const status = ref({ }) const strategies = ref([]) +const presets = ref([]) const positions = ref([]) const orders = ref([]) const events = ref([]) @@ -55,6 +56,20 @@ async function loadStrategies() { 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) { busy.value = true 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) { try { status.value = await api.closePosition(id) @@ -96,7 +123,12 @@ function handleMessage(message) { } 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({ onMessage: handleMessage, onOpen: () => (connected.value = true), @@ -137,6 +169,14 @@ onUnmounted(() => { +
+ HALTED — {{ status.halt_reason }}. Trading is stopped and any open position was + flattened. Restart the bot to resume (limits reset). +
+
{{ status.error }}
@@ -147,8 +187,12 @@ onUnmounted(() => { :strategies="strategies" :status="status" :busy="busy" + :presets="presets" @start="handleStart" @stop="handleStop" + @kill="handleKill" + @save-preset="handleSavePreset" + @delete-preset="handleDeletePreset" /> diff --git a/frontend/src/api.js b/frontend/src/api.js index d041b69..0c659f5 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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 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') => http.post(`/positions/${id}/close`, { reason }).then((r) => r.data) export const placeOrder = (payload) => http.post('/orders', payload).then((r) => r.data) export const runBacktest = (payload) => http.post('/backtest', payload, { timeout: 60000 }).then((r) => r.data) +export 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 }) { const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' let socket diff --git a/frontend/src/components/EventLog.vue b/frontend/src/components/EventLog.vue index e02a6c4..91ad5a9 100644 --- a/frontend/src/components/EventLog.vue +++ b/frontend/src/components/EventLog.vue @@ -8,6 +8,7 @@ defineProps({ const levelClass = { info: 'text-slate-300', trade: 'text-sky-300', + warning: 'text-amber-300', error: 'text-rose-300', } diff --git a/frontend/src/components/StatusCards.vue b/frontend/src/components/StatusCards.vue index 5299f46..a573134 100644 --- a/frontend/src/components/StatusCards.vue +++ b/frontend/src/components/StatusCards.vue @@ -19,11 +19,18 @@ const cards = computed(() => [ }, { label: 'Cash', value: fmtMoney(props.status.cash), 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', + }, ])