FastAPI backend with a single-strategy async engine, paper and Binance brokers, pure-python indicators, a backtester that shares the engine's portfolio math, and a Vue dashboard with a live WebSocket feed. Adds the Gitea Actions CI/CD workflow that deploys to Traefik via docker_default.
391 lines
13 KiB
Python
391 lines
13 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.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._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.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}")
|
|
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
|
|
|
|
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 qty_override is not None:
|
|
qty = qty_override
|
|
else:
|
|
qty = order_qty(self.equity, 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._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)
|
|
|
|
# --- 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,
|
|
)
|
|
|
|
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)
|