Risk controls, alerts, API token, more exchanges, presets
CI/CD / backend (pull_request) Successful in 6m18s
CI/CD / frontend (pull_request) Successful in 4m55s
CI/CD / build (pull_request) Skipped
CI/CD / deploy (pull_request) Skipped
CI/CD / cleanup (pull_request) Skipped

- RiskManager halts + flattens on max daily loss / drawdown, caps position
  size and trades per day; adds a manual kill switch (POST /api/bot/kill).
- Telegram and webhook notifications on trades, errors and halts.
- Optional API bearer token on /api, injected server-side by the frontend
  nginx so the browser never sees it.
- Kraken and Coinbase market data (public); Coinbase live broker. Live on an
  unsupported exchange now errors instead of silently falling back to paper.
- Strategy presets backed by the StrategyConfig table (CRUD API + UI).
- Idempotent additive-column migration so existing SQLite databases upgrade in
  place (fixes the new strategy_configs.name column).
This commit is contained in:
matitos committed 2026-09-18 19:26:29 +00:00
1 parent 9c3bee87ac
commit 03dfa369e5
35 files changed
+1409 -45

No files matched your search

+50 -1
View File
@@ -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: