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:
|
||||
|
||||
@@ -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))
|
||||
Reference in new issue
Block a user