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).
This commit is contained in:
1 parent
9c3bee87ac
commit
03dfa369e5
35 files changed
+1409
-45
No files matched your search
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user