Files
crypto_trading/backend/app/engine/engine.py
T
matitos 03dfa369e5
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
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).
2026-09-18 19:26:29 +00:00

440 lines
15 KiB
Python

from __future__ import annotations
import asyncio
import contextlib
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
from app.broker.base import Broker
from app.config import Settings
from app.engine.portfolio import (
account_equity,
apply_close,
apply_open,
order_qty,
realized_pnl,
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
from app.models import OrderSide, Position, PositionSide
from app.schemas import Candle, EventOut, StatusOut
from app.store import Store
Publish = Callable[[dict[str, Any]], None]
class TradingEngine:
"""Single-strategy trading loop.
The engine owns portfolio state (cash, open position), calls the strategy
once per candle, turns emitted intents into orders, and enforces stop-loss /
take-profit levels.
"""
def __init__(
self,
*,
feed: MarketData,
broker: Broker,
store: Store,
settings: Settings,
) -> None:
self.feed = feed
self.broker = broker
self.store = store
self.settings = settings
self.running = False
self.error: str | None = None
self.last_tick: datetime | None = None
self.last_price: float | None = None
self.cash: float = settings.starting_balance
self.starting_balance: float = settings.starting_balance
self.realized_pnl: float = 0.0
self.position: Position | None = None
self.strategy: Strategy | None = None
self.strategy_key: str | None = None
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
# --- lifecycle ----------------------------------------------------
def set_publisher(self, publish: Publish | None) -> None:
self._publish_cb = publish
async def start(
self,
strategy_key: str,
params: dict[str, Any] | None = None,
*,
symbol: str | None = None,
timeframe: str | None = None,
starting_balance: float | None = None,
) -> None:
if self.running:
raise RuntimeError("engine already running")
strategy_cls = get_strategy(strategy_key)
self.strategy = strategy_cls(params)
self.strategy_key = strategy_key
self.symbol = symbol or self.settings.symbol
self.timeframe = timeframe or self.settings.timeframe
self.starting_balance = float(starting_balance or self.settings.starting_balance)
self.store.abandon_open_positions(strategy_key, reason="reset on engine start")
self.position = None
self.cash = self.starting_balance
self.realized_pnl = 0.0
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}")
self._broadcast_status()
self._task = asyncio.create_task(self._run())
async def stop(self) -> None:
if not self.running:
return
self.running = False
if self._task:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
self._emit("info", "Engine stopped")
self._broadcast_status()
async def shutdown(self) -> None:
await self.stop()
await self.feed.close()
await self.broker.close()
# --- loop ---------------------------------------------------------
async def _run(self) -> None:
while self.running:
try:
await self.tick()
except asyncio.CancelledError:
raise
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:
candles = await self.feed.fetch_candles(
self.symbol, self.timeframe, self.settings.candle_limit
)
if not candles:
return
self.last_price = candles[-1].close
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])
if self.strategy is not None:
ctx = StrategyContext(
candles=candles,
position=self.position,
equity=self.equity,
cash=self.cash,
)
try:
self.strategy.on_candle(ctx)
except Exception as exc:
self.error = f"strategy error: {type(exc).__name__}: {exc}"
self._emit("error", self.error)
await self._process_intents(ctx)
self.store.record_equity(self.equity, self.cash, self.last_price)
self._broadcast_status()
# --- portfolio helpers -------------------------------------------
@property
def equity(self) -> float:
if self.position is None:
return self.cash
return account_equity(
self.cash, self.position.side, self.position.qty, self.last_price or 0.0
)
@property
def unrealized_pnl(self) -> float:
if self.position is None or self.last_price is None:
return 0.0
return unrealized_pnl(
self.position.side, self.position.entry_price, self.last_price, self.position.qty
)
async def _process_intents(self, ctx: StrategyContext) -> None:
for intent in ctx.take_intents():
if intent.action == "close":
if self.position is not None:
await self._close_position(intent.reason or "strategy close")
elif intent.action in {"enter_long", "enter_short"}:
if self.position is not None:
continue
side = PositionSide.LONG if intent.action == "enter_long" else PositionSide.SHORT
await self._open_position(
side,
size=intent.size if intent.size is not None else 1.0,
stop_loss=intent.stop_loss,
take_profit=intent.take_profit,
reason=intent.reason or intent.action,
)
async def _open_position(
self,
side: PositionSide,
*,
size: float,
stop_loss: float | None,
take_profit: float | None,
reason: str,
qty_override: float | None = None,
) -> None:
price = self.last_price
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, self.risk.clamp_size(size), side, self.cash, price)
if qty <= 0:
return
fill = (
await self.broker.buy(self.symbol, qty, price)
if side == PositionSide.LONG
else await self.broker.sell(self.symbol, qty, price)
)
self.cash = apply_open(self.cash, side, fill.qty, fill.price, fill.fee)
self.position = self.store.create_position(
strategy_key=self.strategy_key or "manual",
symbol=self.symbol,
side=side,
qty=fill.qty,
entry_price=fill.price,
entry_fee=fill.fee,
stop_loss=stop_loss,
take_profit=take_profit,
)
self.store.record_order(
position_id=self.position.id,
strategy_key=self.position.strategy_key,
symbol=self.symbol,
side=fill.side,
qty=fill.qty,
price=fill.price,
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})",
)
async def _close_position(self, reason: str) -> None:
position = self.position
price = self.last_price
if position is None or not price:
return
fill = (
await self.broker.sell(self.symbol, position.qty, price)
if position.side == PositionSide.LONG
else await self.broker.buy(self.symbol, position.qty, price)
)
self.cash = apply_close(self.cash, position.side, fill.qty, fill.price, fill.fee)
realized = realized_pnl(
position.side,
position.entry_price,
fill.price,
position.qty,
position.entry_fee,
fill.fee,
)
self.realized_pnl += realized
self.store.close_position(
position,
exit_price=fill.price,
exit_fee=fill.fee,
realized_pnl=realized,
)
self.store.record_order(
position_id=position.id,
strategy_key=position.strategy_key,
symbol=self.symbol,
side=fill.side,
qty=fill.qty,
price=fill.price,
fee=fill.fee,
reason=reason,
)
self.position = None
self._emit(
"trade",
f"Closed {position.side.value} {position.qty:.6f} {self.symbol} "
f"@ {fill.price:.4f} pnl={realized:.2f} ({reason})",
)
async def _check_risk(self, candle: Candle) -> None:
position = self.position
if position is None:
return
hit = risk_exit(
position.side,
position.stop_loss,
position.take_profit,
candle.high,
candle.low,
)
if hit is not None:
reason, level = hit
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
) -> None:
price = self.last_price or await self.feed.fetch_price(self.symbol)
self.last_price = price
if qty is None:
notional = quote_amount if quote_amount is not None else self.cash * 0.99
qty = notional / price
if qty <= 0:
return
if side == OrderSide.BUY:
if self.position is not None:
return
await self._open_position(
PositionSide.LONG,
size=0.0,
stop_loss=None,
take_profit=None,
reason=reason,
qty_override=qty,
)
else:
if self.position is not None:
await self._close_position(reason or "manual close")
else:
await self._open_position(
PositionSide.SHORT,
size=0.0,
stop_loss=None,
take_profit=None,
reason=reason,
qty_override=qty,
)
async def close_current_position(self, reason: str = "manual") -> None:
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(reason)
# --- status / events ----------------------------------------------
def status(self) -> StatusOut:
strategy_name = None
if self.strategy is not None:
strategy_name = self.strategy.name or self.strategy.key
return StatusOut(
running=self.running,
mode=self.settings.mode,
exchange=self.settings.exchange,
symbol=self.symbol,
timeframe=self.timeframe,
strategy_key=self.strategy_key,
strategy_name=strategy_name,
params=dict(self.strategy.params) if self.strategy else {},
price=self.last_price,
cash=self.cash,
equity=self.equity,
starting_balance=self.starting_balance,
realized_pnl=self.realized_pnl,
unrealized_pnl=self.unrealized_pnl,
total_pnl=self.equity - self.starting_balance,
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:
event = self.store.record_event(level, message, self.strategy_key)
self._publish(
{"type": "event", "data": EventOut.model_validate(event).model_dump(mode="json")}
)
def _broadcast_status(self) -> None:
self._publish({"type": "status", "data": self.status().model_dump(mode="json")})
def _publish(self, payload: dict[str, Any]) -> None:
if self._publish_cb is not None:
self._publish_cb(payload)