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
@@ -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