Crypto trading bot: engine, brokers, backtester, dashboard, deploy
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.
This commit is contained in:
1 parent
98028248c2
commit
9c3bee87ac
73 files changed
+9759
-1
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,390 @@
|
||||
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)
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from app.schemas import Candle
|
||||
|
||||
Number = float | None
|
||||
|
||||
|
||||
def sma(values: Sequence[float], period: int) -> list[Number]:
|
||||
if period <= 0:
|
||||
raise ValueError("period must be positive")
|
||||
out: list[Number] = [None] * len(values)
|
||||
window_sum = 0.0
|
||||
for i, value in enumerate(values):
|
||||
window_sum += value
|
||||
if i >= period:
|
||||
window_sum -= values[i - period]
|
||||
if i >= period - 1:
|
||||
out[i] = window_sum / period
|
||||
return out
|
||||
|
||||
|
||||
def ema(values: Sequence[float], period: int) -> list[Number]:
|
||||
if period <= 0:
|
||||
raise ValueError("period must be positive")
|
||||
out: list[Number] = [None] * len(values)
|
||||
if len(values) < period:
|
||||
return out
|
||||
multiplier = 2 / (period + 1)
|
||||
seed = sum(values[:period]) / period
|
||||
out[period - 1] = seed
|
||||
prev = seed
|
||||
for i in range(period, len(values)):
|
||||
prev = (values[i] - prev) * multiplier + prev
|
||||
out[i] = prev
|
||||
return out
|
||||
|
||||
|
||||
def rsi(values: Sequence[float], period: int = 14) -> list[Number]:
|
||||
if period <= 0:
|
||||
raise ValueError("period must be positive")
|
||||
out: list[Number] = [None] * len(values)
|
||||
if len(values) <= period:
|
||||
return out
|
||||
gains = 0.0
|
||||
losses = 0.0
|
||||
for i in range(1, period + 1):
|
||||
change = values[i] - values[i - 1]
|
||||
gains += max(change, 0.0)
|
||||
losses += max(-change, 0.0)
|
||||
avg_gain = gains / period
|
||||
avg_loss = losses / period
|
||||
out[period] = _rsi_value(avg_gain, avg_loss)
|
||||
for i in range(period + 1, len(values)):
|
||||
change = values[i] - values[i - 1]
|
||||
gain = max(change, 0.0)
|
||||
loss = max(-change, 0.0)
|
||||
avg_gain = (avg_gain * (period - 1) + gain) / period
|
||||
avg_loss = (avg_loss * (period - 1) + loss) / period
|
||||
out[i] = _rsi_value(avg_gain, avg_loss)
|
||||
return out
|
||||
|
||||
|
||||
def _rsi_value(avg_gain: float, avg_loss: float) -> float:
|
||||
if avg_loss == 0:
|
||||
return 100.0
|
||||
rs = avg_gain / avg_loss
|
||||
return 100 - (100 / (1 + rs))
|
||||
|
||||
|
||||
def true_range(candles: Sequence[Candle]) -> list[float]:
|
||||
out: list[float] = []
|
||||
for i, candle in enumerate(candles):
|
||||
if i == 0:
|
||||
out.append(candle.high - candle.low)
|
||||
continue
|
||||
prev_close = candles[i - 1].close
|
||||
out.append(
|
||||
max(
|
||||
candle.high - candle.low,
|
||||
abs(candle.high - prev_close),
|
||||
abs(candle.low - prev_close),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def atr(candles: Sequence[Candle], period: int = 14) -> list[Number]:
|
||||
if period <= 0:
|
||||
raise ValueError("period must be positive")
|
||||
tr = true_range(candles)
|
||||
out: list[Number] = [None] * len(tr)
|
||||
if len(tr) < period:
|
||||
return out
|
||||
prev = sum(tr[:period]) / period
|
||||
out[period - 1] = prev
|
||||
for i in range(period, len(tr)):
|
||||
prev = (prev * (period - 1) + tr[i]) / period
|
||||
out[i] = prev
|
||||
return out
|
||||
|
||||
|
||||
def rolling_std(values: Sequence[float], period: int) -> list[Number]:
|
||||
if period <= 0:
|
||||
raise ValueError("period must be positive")
|
||||
out: list[Number] = [None] * len(values)
|
||||
for i in range(period - 1, len(values)):
|
||||
window = values[i - period + 1 : i + 1]
|
||||
mean = sum(window) / period
|
||||
variance = sum((x - mean) ** 2 for x in window) / period
|
||||
out[i] = variance**0.5
|
||||
return out
|
||||
|
||||
|
||||
def last(series: Sequence[Number]) -> Number:
|
||||
for value in reversed(series):
|
||||
if value is not None:
|
||||
return value
|
||||
return None
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.models import PositionSide
|
||||
|
||||
# All functions here are pure and shared by the live engine and the backtester
|
||||
# so both compute cash, P&L and risk exits identically.
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenPosition:
|
||||
"""Lightweight position used by the backtester (the engine uses a DB row)."""
|
||||
|
||||
side: PositionSide
|
||||
qty: float
|
||||
entry_price: float
|
||||
entry_fee: float = 0.0
|
||||
stop_loss: float | None = None
|
||||
take_profit: float | None = None
|
||||
entry_ts: int = 0
|
||||
|
||||
|
||||
def apply_open(cash: float, side: PositionSide, qty: float, price: float, fee: float) -> float:
|
||||
"""Cash after opening a position (buying long / selling short)."""
|
||||
notional = qty * price
|
||||
if side == PositionSide.LONG:
|
||||
return cash - notional - fee
|
||||
return cash + notional - fee
|
||||
|
||||
|
||||
def apply_close(cash: float, side: PositionSide, qty: float, price: float, fee: float) -> float:
|
||||
"""Cash after closing a position (selling long / buying back short)."""
|
||||
notional = qty * price
|
||||
if side == PositionSide.LONG:
|
||||
return cash + notional - fee
|
||||
return cash - notional - fee
|
||||
|
||||
|
||||
def realized_pnl(
|
||||
side: PositionSide,
|
||||
entry_price: float,
|
||||
exit_price: float,
|
||||
qty: float,
|
||||
entry_fee: float,
|
||||
exit_fee: float,
|
||||
) -> float:
|
||||
gross = (
|
||||
(exit_price - entry_price) * qty
|
||||
if side == PositionSide.LONG
|
||||
else (entry_price - exit_price) * qty
|
||||
)
|
||||
return gross - entry_fee - exit_fee
|
||||
|
||||
|
||||
def unrealized_pnl(side: PositionSide, entry_price: float, mark: float, qty: float) -> float:
|
||||
if side == PositionSide.LONG:
|
||||
return (mark - entry_price) * qty
|
||||
return (entry_price - mark) * qty
|
||||
|
||||
|
||||
def position_value(side: PositionSide, qty: float, mark: float) -> float:
|
||||
"""Mark-to-market value of the position as an asset (negative for shorts)."""
|
||||
return qty * mark if side == PositionSide.LONG else -qty * mark
|
||||
|
||||
|
||||
def account_equity(cash: float, side: PositionSide, qty: float, mark: float) -> float:
|
||||
return cash + position_value(side, qty, mark)
|
||||
|
||||
|
||||
def order_qty(equity: float, size: float, side: PositionSide, cash: float, price: float) -> float:
|
||||
"""Position size as a fraction of equity, capped by available cash for longs."""
|
||||
if price <= 0:
|
||||
return 0.0
|
||||
size = max(0.0, min(size, 1.0))
|
||||
notional = equity * size
|
||||
if side == PositionSide.LONG:
|
||||
notional = min(notional, cash * 0.99)
|
||||
return notional / price
|
||||
|
||||
|
||||
def risk_exit(
|
||||
side: PositionSide,
|
||||
stop_loss: float | None,
|
||||
take_profit: float | None,
|
||||
high: float,
|
||||
low: float,
|
||||
) -> tuple[str, float] | None:
|
||||
"""Return (reason, exit_price) if a stop/take level was hit within the bar."""
|
||||
if side == PositionSide.LONG:
|
||||
if stop_loss is not None and low <= stop_loss:
|
||||
return "stop loss", stop_loss
|
||||
if take_profit is not None and high >= take_profit:
|
||||
return "take profit", take_profit
|
||||
else:
|
||||
if stop_loss is not None and high >= stop_loss:
|
||||
return "stop loss", stop_loss
|
||||
if take_profit is not None and low <= take_profit:
|
||||
return "take profit", take_profit
|
||||
return None
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import pkgutil
|
||||
|
||||
from app.engine.strategies.base import (
|
||||
Strategy,
|
||||
StrategyContext,
|
||||
get_strategy,
|
||||
list_strategies,
|
||||
param_specs,
|
||||
register,
|
||||
strategy_spec,
|
||||
)
|
||||
|
||||
|
||||
def discover() -> None:
|
||||
"""Import every module in this package so its strategies self-register."""
|
||||
for module_info in pkgutil.iter_modules(__path__):
|
||||
if module_info.name.startswith("_") or module_info.name in {"base"}:
|
||||
continue
|
||||
importlib.import_module(f"{__name__}.{module_info.name}")
|
||||
|
||||
|
||||
discover()
|
||||
|
||||
__all__ = [
|
||||
"Strategy",
|
||||
"StrategyContext",
|
||||
"get_strategy",
|
||||
"list_strategies",
|
||||
"param_specs",
|
||||
"register",
|
||||
"strategy_spec",
|
||||
]
|
||||
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, ClassVar, Literal, Protocol
|
||||
|
||||
from app.engine.indicators import atr, ema, rolling_std, rsi, sma
|
||||
from app.models import PositionSide
|
||||
from app.schemas import Candle, ParamSpec, StrategyOut
|
||||
|
||||
|
||||
class PositionView(Protocol):
|
||||
"""Minimal position shape a strategy can see (DB row or backtest position)."""
|
||||
|
||||
side: PositionSide
|
||||
qty: float
|
||||
entry_price: float
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Intent:
|
||||
"""An order request emitted by a strategy. Executed by the engine."""
|
||||
|
||||
action: Literal["enter_long", "enter_short", "close"]
|
||||
size: float | None = None
|
||||
stop_loss: float | None = None
|
||||
take_profit: float | None = None
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class StrategyContext:
|
||||
"""Read-only market/portfolio view plus order intent helpers.
|
||||
|
||||
A fresh context is created for every candle. Strategy code stays synchronous
|
||||
and side-effect free: it calls ``enter_long`` / ``enter_short`` / ``close``
|
||||
and the engine executes the resulting intent after ``on_candle`` returns.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
candles: Sequence[Candle],
|
||||
position: PositionView | None,
|
||||
equity: float,
|
||||
cash: float,
|
||||
) -> None:
|
||||
self._candles = list(candles)
|
||||
self._position = position
|
||||
self._equity = equity
|
||||
self._cash = cash
|
||||
self._intents: list[Intent] = []
|
||||
|
||||
# --- market state -------------------------------------------------
|
||||
@property
|
||||
def candles(self) -> list[Candle]:
|
||||
return self._candles
|
||||
|
||||
@property
|
||||
def price(self) -> float:
|
||||
return self._candles[-1].close
|
||||
|
||||
@property
|
||||
def closes(self) -> list[float]:
|
||||
return [c.close for c in self._candles]
|
||||
|
||||
def high(self, lookback: int) -> float:
|
||||
return max(c.high for c in self._candles[-lookback:])
|
||||
|
||||
def low(self, lookback: int) -> float:
|
||||
return min(c.low for c in self._candles[-lookback:])
|
||||
|
||||
# --- portfolio state ----------------------------------------------
|
||||
@property
|
||||
def position(self) -> PositionView | None:
|
||||
return self._position
|
||||
|
||||
@property
|
||||
def is_flat(self) -> bool:
|
||||
return self._position is None
|
||||
|
||||
@property
|
||||
def side(self) -> PositionSide | None:
|
||||
return self._position.side if self._position else None
|
||||
|
||||
@property
|
||||
def equity(self) -> float:
|
||||
return self._equity
|
||||
|
||||
@property
|
||||
def cash(self) -> float:
|
||||
return self._cash
|
||||
|
||||
# --- indicators ----------------------------------------------------
|
||||
def sma(self, period: int) -> float | None:
|
||||
return sma(self.closes, period)[-1]
|
||||
|
||||
def ema(self, period: int) -> float | None:
|
||||
return ema(self.closes, period)[-1]
|
||||
|
||||
def rsi(self, period: int = 14) -> float | None:
|
||||
return rsi(self.closes, period)[-1]
|
||||
|
||||
def atr(self, period: int = 14) -> float | None:
|
||||
return atr(self._candles, period)[-1]
|
||||
|
||||
def std(self, period: int = 20) -> float | None:
|
||||
return rolling_std(self.closes, period)[-1]
|
||||
|
||||
# --- order intents ------------------------------------------------
|
||||
def enter_long(
|
||||
self,
|
||||
size: float | None = None,
|
||||
stop_loss: float | None = None,
|
||||
take_profit: float | None = None,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
self._intents.append(Intent("enter_long", size, stop_loss, take_profit, reason))
|
||||
|
||||
def enter_short(
|
||||
self,
|
||||
size: float | None = None,
|
||||
stop_loss: float | None = None,
|
||||
take_profit: float | None = None,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
self._intents.append(Intent("enter_short", size, stop_loss, take_profit, reason))
|
||||
|
||||
def close(self, reason: str = "") -> None:
|
||||
self._intents.append(Intent("close", reason=reason))
|
||||
|
||||
def take_intents(self) -> list[Intent]:
|
||||
intents, self._intents = self._intents, []
|
||||
return intents
|
||||
|
||||
|
||||
class Strategy(ABC):
|
||||
"""Base class for trading strategies.
|
||||
|
||||
Subclass it, set ``key``/``name``/``default_params`` and implement
|
||||
``on_candle``. Save the file under ``app/engine/strategies/`` and it is
|
||||
auto-discovered.
|
||||
"""
|
||||
|
||||
key: ClassVar[str] = ""
|
||||
name: ClassVar[str] = ""
|
||||
description: ClassVar[str] = ""
|
||||
default_params: ClassVar[dict[str, Any]] = {}
|
||||
param_help: ClassVar[dict[str, str]] = {}
|
||||
param_bounds: ClassVar[dict[str, tuple[float, float]]] = {}
|
||||
|
||||
def __init__(self, params: dict[str, Any] | None = None) -> None:
|
||||
unknown = set(params or {}) - set(self.default_params)
|
||||
if unknown:
|
||||
raise ValueError(f"unknown params for {self.key}: {sorted(unknown)}")
|
||||
self.params: dict[str, Any] = {**self.default_params, **(params or {})}
|
||||
|
||||
@abstractmethod
|
||||
def on_candle(self, ctx: StrategyContext) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def _infer_type(value: Any) -> Literal["int", "float", "bool", "str"]:
|
||||
if isinstance(value, bool):
|
||||
return "bool"
|
||||
if isinstance(value, int):
|
||||
return "int"
|
||||
if isinstance(value, float):
|
||||
return "float"
|
||||
return "str"
|
||||
|
||||
|
||||
def param_specs(strategy_cls: type[Strategy]) -> list[ParamSpec]:
|
||||
specs: list[ParamSpec] = []
|
||||
for name, default in strategy_cls.default_params.items():
|
||||
bounds = strategy_cls.param_bounds.get(name)
|
||||
specs.append(
|
||||
ParamSpec(
|
||||
name=name,
|
||||
type=_infer_type(default),
|
||||
default=default,
|
||||
description=strategy_cls.param_help.get(name, ""),
|
||||
min=bounds[0] if bounds else None,
|
||||
max=bounds[1] if bounds else None,
|
||||
)
|
||||
)
|
||||
return specs
|
||||
|
||||
|
||||
_REGISTRY: dict[str, type[Strategy]] = {}
|
||||
|
||||
|
||||
def register(strategy_cls: type[Strategy]) -> type[Strategy]:
|
||||
"""Class decorator that makes a strategy discoverable by key."""
|
||||
if not strategy_cls.key:
|
||||
raise ValueError(f"{strategy_cls.__name__} must define a non-empty 'key'")
|
||||
_REGISTRY[strategy_cls.key] = strategy_cls
|
||||
return strategy_cls
|
||||
|
||||
|
||||
def get_strategy(key: str) -> type[Strategy]:
|
||||
try:
|
||||
return _REGISTRY[key]
|
||||
except KeyError:
|
||||
raise KeyError(f"unknown strategy '{key}'. available: {sorted(_REGISTRY)}") from None
|
||||
|
||||
|
||||
def list_strategies() -> list[type[Strategy]]:
|
||||
return sorted(_REGISTRY.values(), key=lambda cls: cls.name or cls.key)
|
||||
|
||||
|
||||
def strategy_spec(strategy_cls: type[Strategy]) -> StrategyOut:
|
||||
return StrategyOut(
|
||||
key=strategy_cls.key,
|
||||
name=strategy_cls.name or strategy_cls.key,
|
||||
description=strategy_cls.description,
|
||||
params=param_specs(strategy_cls),
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from app.engine.strategies.base import Strategy, StrategyContext, register
|
||||
from app.models import PositionSide
|
||||
|
||||
|
||||
@register
|
||||
class RsiReversion(Strategy):
|
||||
"""Buy oversold RSI, exit on overbought, with an ATR-based stop."""
|
||||
|
||||
key: ClassVar[str] = "rsi_reversion"
|
||||
name: ClassVar[str] = "RSI Mean Reversion"
|
||||
description: ClassVar[str] = (
|
||||
"Enter long when RSI drops below the oversold level and exit when it rises "
|
||||
"above the overbought level. Places an ATR-based stop loss and take profit."
|
||||
)
|
||||
default_params: ClassVar[dict] = {
|
||||
"period": 14,
|
||||
"oversold": 30.0,
|
||||
"overbought": 70.0,
|
||||
"atr_period": 14,
|
||||
"atr_stop_mult": 2.0,
|
||||
"atr_target_mult": 3.0,
|
||||
"size": 1.0,
|
||||
}
|
||||
param_help: ClassVar[dict] = {
|
||||
"period": "RSI lookback period",
|
||||
"oversold": "RSI level below which to buy",
|
||||
"overbought": "RSI level above which to exit",
|
||||
"atr_period": "ATR lookback for stop sizing",
|
||||
"atr_stop_mult": "Stop distance as a multiple of ATR",
|
||||
"atr_target_mult": "Take-profit distance as a multiple of ATR",
|
||||
"size": "Fraction of equity to allocate per trade",
|
||||
}
|
||||
param_bounds: ClassVar[dict] = {
|
||||
"period": (2, 100),
|
||||
"oversold": (1, 50),
|
||||
"overbought": (50, 99),
|
||||
"atr_stop_mult": (0.5, 10.0),
|
||||
"atr_target_mult": (0.5, 20.0),
|
||||
"size": (0.01, 1.0),
|
||||
}
|
||||
|
||||
def on_candle(self, ctx: StrategyContext) -> None:
|
||||
period = int(self.params["period"])
|
||||
current = ctx.rsi(period)
|
||||
if current is None:
|
||||
return
|
||||
|
||||
if ctx.is_flat and current < float(self.params["oversold"]):
|
||||
stop = target = None
|
||||
atr_value = ctx.atr(int(self.params["atr_period"]))
|
||||
if atr_value:
|
||||
stop = ctx.price - float(self.params["atr_stop_mult"]) * atr_value
|
||||
target = ctx.price + float(self.params["atr_target_mult"]) * atr_value
|
||||
ctx.enter_long(
|
||||
size=float(self.params["size"]),
|
||||
stop_loss=stop,
|
||||
take_profit=target,
|
||||
reason=f"RSI {current:.1f} below oversold",
|
||||
)
|
||||
elif ctx.side == PositionSide.LONG and current > float(self.params["overbought"]):
|
||||
ctx.close(reason=f"RSI {current:.1f} above overbought")
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from app.engine.indicators import sma
|
||||
from app.engine.strategies.base import Strategy, StrategyContext, register
|
||||
from app.models import PositionSide
|
||||
|
||||
|
||||
@register
|
||||
class SmaCross(Strategy):
|
||||
"""Classic moving-average crossover, long-only."""
|
||||
|
||||
key: ClassVar[str] = "sma_cross"
|
||||
name: ClassVar[str] = "SMA Crossover"
|
||||
description: ClassVar[str] = (
|
||||
"Open a long position when the fast SMA crosses above the slow SMA, "
|
||||
"close it when the fast SMA crosses back below."
|
||||
)
|
||||
default_params: ClassVar[dict] = {"fast": 10, "slow": 30, "size": 1.0}
|
||||
param_help: ClassVar[dict] = {
|
||||
"fast": "Fast SMA period",
|
||||
"slow": "Slow SMA period",
|
||||
"size": "Fraction of equity to allocate per trade",
|
||||
}
|
||||
param_bounds: ClassVar[dict] = {"fast": (2, 200), "slow": (3, 400), "size": (0.01, 1.0)}
|
||||
|
||||
def on_candle(self, ctx: StrategyContext) -> None:
|
||||
fast_period = int(self.params["fast"])
|
||||
slow_period = int(self.params["slow"])
|
||||
closes = ctx.closes
|
||||
if len(closes) < slow_period + 2:
|
||||
return
|
||||
|
||||
fast = sma(closes, fast_period)
|
||||
slow = sma(closes, slow_period)
|
||||
f_prev, f_now = fast[-2], fast[-1]
|
||||
s_prev, s_now = slow[-2], slow[-1]
|
||||
if None in (f_prev, f_now, s_prev, s_now):
|
||||
return
|
||||
|
||||
crossed_up = f_prev <= s_prev and f_now > s_now
|
||||
crossed_down = f_prev >= s_prev and f_now < s_now
|
||||
|
||||
if ctx.is_flat and crossed_up:
|
||||
ctx.enter_long(size=float(self.params["size"]), reason="fast SMA crossed above slow")
|
||||
elif ctx.side == PositionSide.LONG and crossed_down:
|
||||
ctx.close(reason="fast SMA crossed below slow")
|
||||
Reference in new issue
Block a user